From 06a58efb2e42a78c88eed2d4e23fdd6d215fd777 Mon Sep 17 00:00:00 2001
From: Tin Chi Lo
Date: Mon, 27 Jul 2026 16:30:27 -0700
Subject: [PATCH 01/33] feat(mcp): manual authorization-code delivery for
headless MCP clients
The aggregate gateway DCR flow ends in a 303 to the client's loopback
redirect_uri. When the MCP client runs on a browserless machine (EC2,
SSH box, container) the user authorizes from a browser on another
machine, so the 303 dereferences the wrong loopback and the code never
reaches the client.
The connect banner now offers manual delivery for loopback clients: the
finish form posts delivery=manual and /authorize/complete renders the
callback URL on a no-store page instead of redirecting. The user pastes
it into the client (Claude Code v2.1.191+ accepts a pasted callback URL)
or fetches it from the client machine's terminal. Manual codes keep the
same sealing, PKCE binding, and single-use guard, with a 5 minute
expiry instead of 2 to survive the copy-paste hop; the used-code marker
TTL derives from the code's own remaining lifetime so the single-use
property holds for the full 5 minutes. The default redirect path is
unchanged.
Resolves LIT-4863
---
.../mcp_server/discoverable_endpoints.py | 9 +-
.../mcp_server/gateway_dcr_flow.py | 70 +++++-
.../mcp_server/test_gateway_dcr_flow.py | 207 ++++++++++++++++++
.../chat/ConnectFlowBanner.test.tsx | 35 ++-
.../src/components/chat/ConnectFlowBanner.tsx | 17 ++
5 files changed, 329 insertions(+), 9 deletions(-)
diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py
index caa5c65894c..cdc3ac15b1a 100644
--- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py
+++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py
@@ -1778,10 +1778,12 @@ async def token_endpoint(
@router.post("/authorize/complete")
-async def authorize_complete(request: Request, flow: str = Form(...)):
+async def authorize_complete(request: Request, flow: str = Form(...), delivery: str | None = Form(None)):
"""Finish an aggregate connect flow: mint the gateway authorization code for the
- signed-in user and redirect back to the DCR client. POST plus the per-flow HttpOnly
- cookie set at /authorize; an anonymous or bad-flow request just 400s."""
+ signed-in user and hand it back to the DCR client, by 303 redirect (default) or, for
+ a loopback client on a different machine, as a copyable callback URL
+ (``delivery=manual``). POST plus the per-flow HttpOnly cookie set at /authorize; an
+ anonymous or bad-flow request just 400s."""
from litellm.proxy.proxy_server import user_api_key_cache # noqa: PLC0415 # circular import at module load
return await complete_connect_flow(
@@ -1789,6 +1791,7 @@ async def authorize_complete(request: Request, flow: str = Form(...)):
flow_handle=flow,
session_user_id=_session_cookie_user_id(request),
cache=user_api_key_cache,
+ delivery=delivery,
)
diff --git a/litellm/proxy/_experimental/mcp_server/gateway_dcr_flow.py b/litellm/proxy/_experimental/mcp_server/gateway_dcr_flow.py
index 58233c4c9e5..7177b798c5f 100644
--- a/litellm/proxy/_experimental/mcp_server/gateway_dcr_flow.py
+++ b/litellm/proxy/_experimental/mcp_server/gateway_dcr_flow.py
@@ -39,6 +39,7 @@ from __future__ import annotations
import hashlib
import hmac
+import html
import secrets
from base64 import urlsafe_b64encode
from collections.abc import Mapping
@@ -47,7 +48,7 @@ from typing import Awaitable, Callable, Literal, TypeVar
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
from fastapi import HTTPException, Request
-from fastapi.responses import JSONResponse, RedirectResponse, Response
+from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from typing_extensions import assert_never
@@ -94,6 +95,13 @@ server-side session store, and the sealed value never appears in a URL)."""
CONNECT_FLOW_TTL_SECONDS = 600
GATEWAY_AUTH_CODE_TTL_SECONDS = 120
+MANUAL_DELIVERY_AUTH_CODE_TTL_SECONDS = 300
+"""Lifetime of a code the user delivers by hand (headless/remote client, LIT-4863 class):
+copy-pasting a callback URL from a laptop browser to an SSH session is slower than a
+browser redirect, so manual-delivery codes get 5 minutes instead of 2, still well under
+the 10-minute ceiling RFC 6749 section 4.1.2 recommends. Single-use and PKCE binding are
+unchanged, so the longer window only extends how long the legitimate holder has to paste
+it, not what an observer could do with it."""
_CLAIM_TTL_BUFFER_SECONDS = 60
_USED_CODE_CACHE_PREFIX = "mcp_gateway_dcr_code_used:"
_USED_FLOW_CACHE_PREFIX = "mcp_gateway_dcr_flow_used:"
@@ -390,6 +398,7 @@ async def complete_connect_flow(
flow_handle: str,
session_user_id: str | None,
cache: DualCache,
+ delivery: str | None = None,
) -> Response:
"""The deliberate finish step of the connect flow: mint the gateway authorization
code and send the browser back to the client.
@@ -399,7 +408,24 @@ async def complete_connect_flow(
into the flow: a link crafted by another party dies here with ``access_denied``
instead of minting a code for the victim's identity. The flow is single-use (an atomic
claim on its ``jti``), so a double-submit cannot mint two codes from one sign-in.
+
+ ``delivery`` chooses how the code reaches the client. Default (absent or
+ ``"redirect"``) is the 303 to the client's registered redirect URI. ``"manual"``
+ renders the callback URL on a page instead, for a client whose redirect URI is a
+ loopback host but which runs on a DIFFERENT machine than the browser (EC2/SSH box,
+ container): the 303 would dereference the browser machine's loopback and the code
+ would never arrive, so the user carries it over by pasting the URL into the client or
+ fetching it from the client machine's terminal. Manual delivery is honored only for
+ loopback redirect URIs; a routable redirect URI works from any browser by
+ construction, so those flows always redirect. The user who sees the page is exactly
+ the user the 303 would have carried the code to, and the same user already sees the
+ code today in the dead redirect's address bar, so the page exposes the code to no new
+ party. Unknown ``delivery`` values are rejected rather than defaulted: a client that
+ asked for manual delivery and got a dead redirect instead would silently lose its
+ code.
"""
+ if delivery not in (None, "redirect", "manual"):
+ return _oauth_error(400, "invalid_request", "delivery must be 'redirect' or 'manual'")
sealed_flow = request.cookies.get(_flow_cookie_name(flow_handle))
if sealed_flow is None:
return _oauth_error(400, "invalid_request", "unknown or expired connect flow")
@@ -417,6 +443,8 @@ async def complete_connect_flow(
f"{_USED_FLOW_CACHE_PREFIX}{flow.jti}", CONNECT_FLOW_TTL_SECONDS + _CLAIM_TTL_BUFFER_SECONDS
):
return _oauth_error(400, "invalid_request", "this connect flow was already completed; restart the connection")
+ manual_delivery = delivery == "manual" and is_loopback_redirect_host(urlparse(flow.redirect_uri))
+ code_ttl = MANUAL_DELIVERY_AUTH_CODE_TTL_SECONDS if manual_delivery else GATEWAY_AUTH_CODE_TTL_SECONDS
code = _seal(
GATEWAY_AUTH_CODE_PREFIX,
_GatewayAuthCode(
@@ -426,16 +454,46 @@ async def complete_connect_flow(
code_challenge=flow.code_challenge,
jti=secrets.token_urlsafe(24),
iat=int(now.timestamp()),
- exp=int(now.timestamp()) + GATEWAY_AUTH_CODE_TTL_SECONDS,
+ exp=int(now.timestamp()) + code_ttl,
),
)
params = {"code": code, **({"state": flow.state} if flow.state else {})}
- response = RedirectResponse(_append_query_params(flow.redirect_uri, params), status_code=303)
+ callback_url = _append_query_params(flow.redirect_uri, params)
+ response: Response = (
+ _manual_delivery_response(callback_url) if manual_delivery else RedirectResponse(callback_url, status_code=303)
+ )
path, secure = _cookie_path_and_secure(request)
response.delete_cookie(key=_flow_cookie_name(flow_handle), path=path, secure=secure, httponly=True, samesite="lax")
return response
+def _manual_delivery_response(callback_url: str) -> Response:
+ """The manual code-delivery page: the callback URL the 303 would have followed,
+ rendered for the user to carry to the machine the client actually runs on (paste into
+ the client's prompt, or fetch with curl from that machine's terminal). Served
+ no-store because the body holds a live single-use code, and the URL is HTML-escaped
+ because it is client-influenced. The page renders the URL as data only, never as a
+ ready-to-paste shell command: no single quoting of an attacker-influenced string is
+ correct across POSIX shells, cmd.exe, and PowerShell (cmd.exe ignores single quotes
+ and percent-expands inside double quotes), so any command string this page suggested
+ would be wrong for some shell the user might paste it into."""
+ safe_url = html.escape(callback_url, quote=True)
+ minutes = MANUAL_DELIVERY_AUTH_CODE_TTL_SECONDS // 60
+ body = (
+ "Finish connecting "
+ "Almost done "
+ "Your MCP client runs on a different machine, so this browser cannot deliver the"
+ " authorization code to it. On the machine where the client runs, paste this URL into"
+ " the client's prompt (Claude Code accepts the pasted callback URL), or pass it as the"
+ " quoted argument of a curl command from that machine's terminal:
"
+ f'
'
+ f"The code is single-use and expires in {minutes} minutes. You can close this window"
+ " once the client confirms it is connected.
"
+ ""
+ )
+ return HTMLResponse(body, headers=TOKEN_NO_CACHE_HEADERS)
+
+
def _pkce_verifier_matches(code_verifier: str, code_challenge: str) -> bool:
"""RFC 7636 S256 verification, total over hostile input. The comparison is over bytes
so a non-ASCII ``code_challenge`` (which reaches here unvalidated from the client's
@@ -601,9 +659,11 @@ async def _authorization_code_grant(
if failure is not None:
return _reload_failure_response(failure)
# Atomic single-use claim is the gate: on a concurrent double-redeem exactly one caller
- # wins, and a claim that cannot be recorded fails closed.
+ # wins, and a claim that cannot be recorded fails closed. The marker's TTL derives from
+ # the code's own remaining lifetime so it outlives whichever lifetime the code was minted with.
if not await guard.claim(
- f"{_USED_CODE_CACHE_PREFIX}{parsed.jti}", GATEWAY_AUTH_CODE_TTL_SECONDS + _CLAIM_TTL_BUFFER_SECONDS
+ f"{_USED_CODE_CACHE_PREFIX}{parsed.jti}",
+ parsed.exp - int(now.timestamp()) + _CLAIM_TTL_BUFFER_SECONDS,
):
return _oauth_error(400, "invalid_grant", "the authorization code was already used")
return _session_token_pair(SessionPrincipal(user_id=parsed.user_id, client_id=client_id), keys, now)
diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_gateway_dcr_flow.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_gateway_dcr_flow.py
index 375ec022115..85a19331777 100644
--- a/tests/test_litellm/proxy/_experimental/mcp_server/test_gateway_dcr_flow.py
+++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_gateway_dcr_flow.py
@@ -1,6 +1,7 @@
"""Tests for the aggregate gateway DCR flow (register, authorize, complete, token)."""
import hashlib
+import html
import json
from base64 import urlsafe_b64encode
from datetime import datetime, timedelta, timezone
@@ -16,7 +17,10 @@ from litellm.proxy._experimental.mcp_server.gateway_dcr_flow import (
GATEWAY_AUTH_CODE_PREFIX,
GATEWAY_AUTH_CODE_TTL_SECONDS,
GATEWAY_DCR_CLIENT_ID_PREFIX,
+ MANUAL_DELIVERY_AUTH_CODE_TTL_SECONDS,
+ _AUTH_CODE_DEBUG_KEY,
_GatewayAuthCode,
+ _open_sealed,
_seal,
aggregate_authorize,
aggregate_token,
@@ -588,3 +592,206 @@ async def test_single_use_guard_fails_closed_when_redis_errors():
guard = _SingleUseGuard(cache)
assert await guard.claim("jti-fault", 60) is False # fail closed, not a fallback count of 1
+
+
+LOOPBACK_REDIRECT_URI = "http://localhost:3118/callback"
+
+
+async def _complete(redirect_uri: str, delivery, cookies=None, handle=None, session_user_id="u1"):
+ client_id = (await _register([redirect_uri]))["client_id"]
+ if cookies is None:
+ handle, cookies = _flow_cookie_from(_authorize(client_id, session_user_id="u1", redirect_uri=redirect_uri))
+ response = await complete_connect_flow(
+ request=_request("/authorize/complete", cookies=cookies, method="POST"),
+ flow_handle=handle,
+ session_user_id=session_user_id,
+ cache=DualCache(),
+ delivery=delivery,
+ )
+ return client_id, response
+
+
+def _callback_url_from_page(response) -> str:
+ import html as html_lib
+ import re
+
+ match = re.search(r'value="([^"]+)"', response.body.decode())
+ assert match is not None
+ return html_lib.unescape(match.group(1))
+
+
+@pytest.mark.asyncio
+async def test_manual_delivery_renders_pasteable_callback_url_for_loopback_client():
+ """The LIT-4863 headless path: a loopback client on another machine gets the callback
+ URL on a page instead of a dead 303, and the code on that page is a full-fidelity
+ authorization code (PKCE-bound, single-use, redeemable at /token)."""
+ client_id, response = await _complete(LOOPBACK_REDIRECT_URI, delivery="manual")
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("text/html")
+ assert response.headers["cache-control"] == "no-store"
+ assert f"{CONNECT_FLOW_COOKIE_PREFIX}" in response.headers["set-cookie"]
+
+ callback_url = _callback_url_from_page(response)
+ parsed = urlparse(callback_url)
+ assert f"{parsed.scheme}://{parsed.netloc}{parsed.path}" == LOOPBACK_REDIRECT_URI
+ params = parse_qs(parsed.query)
+ assert params["state"] == ["client-state-123"]
+ code = params["code"][0]
+ assert code.startswith(GATEWAY_AUTH_CODE_PREFIX)
+
+ cache = DualCache()
+ token_response = await aggregate_token(
+ request=_request("/token", method="POST"),
+ grant_type="authorization_code",
+ code=code,
+ redirect_uri=LOOPBACK_REDIRECT_URI,
+ client_id=client_id,
+ code_verifier=CODE_VERIFIER,
+ refresh_token=None,
+ master_key=MASTER_KEY,
+ reload_user=_reload_user_active,
+ cache=cache,
+ )
+ assert token_response.status_code == 200
+
+ replay = await aggregate_token(
+ request=_request("/token", method="POST"),
+ grant_type="authorization_code",
+ code=code,
+ redirect_uri=LOOPBACK_REDIRECT_URI,
+ client_id=client_id,
+ code_verifier=CODE_VERIFIER,
+ refresh_token=None,
+ master_key=MASTER_KEY,
+ reload_user=_reload_user_active,
+ cache=cache,
+ )
+ assert json.loads(replay.body)["error"] == "invalid_grant"
+
+
+@pytest.mark.asyncio
+async def test_manual_delivery_code_gets_the_longer_ttl_and_redirect_code_does_not():
+ _, manual = await _complete(LOOPBACK_REDIRECT_URI, delivery="manual")
+ manual_code = parse_qs(urlparse(_callback_url_from_page(manual)).query)["code"][0]
+ opened_manual = _open_sealed(manual_code, GATEWAY_AUTH_CODE_PREFIX, _GatewayAuthCode, _AUTH_CODE_DEBUG_KEY)
+ assert opened_manual is not None
+ assert opened_manual.exp - opened_manual.iat == MANUAL_DELIVERY_AUTH_CODE_TTL_SECONDS
+
+ _, redirected = await _complete(LOOPBACK_REDIRECT_URI, delivery=None)
+ redirect_code = parse_qs(urlparse(redirected.headers["location"]).query)["code"][0]
+ opened_redirect = _open_sealed(redirect_code, GATEWAY_AUTH_CODE_PREFIX, _GatewayAuthCode, _AUTH_CODE_DEBUG_KEY)
+ assert opened_redirect is not None
+ assert opened_redirect.exp - opened_redirect.iat == GATEWAY_AUTH_CODE_TTL_SECONDS
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("delivery", [None, "redirect"])
+async def test_loopback_client_still_redirects_when_manual_not_requested(delivery):
+ _, response = await _complete(LOOPBACK_REDIRECT_URI, delivery=delivery)
+ assert response.status_code == 303
+ assert response.headers["location"].startswith(LOOPBACK_REDIRECT_URI)
+
+
+@pytest.mark.asyncio
+async def test_manual_delivery_is_ignored_for_routable_redirect_uri():
+ """A routable redirect URI works from any browser by construction, so manual is a
+ no-op there and the flow keeps its normal shape."""
+ _, response = await _complete(REDIRECT_URI, delivery="manual")
+ assert response.status_code == 303
+ assert response.headers["location"].startswith(REDIRECT_URI)
+
+
+@pytest.mark.asyncio
+async def test_unknown_delivery_value_is_rejected_before_the_flow_is_consumed():
+ """A typo'd delivery must not burn the single-use flow: the user fixes the form and
+ finishes normally."""
+ client_id = (await _register([LOOPBACK_REDIRECT_URI]))["client_id"]
+ handle, cookies = _flow_cookie_from(_authorize(client_id, session_user_id="u1", redirect_uri=LOOPBACK_REDIRECT_URI))
+
+ rejected = await complete_connect_flow(
+ request=_request("/authorize/complete", cookies=cookies, method="POST"),
+ flow_handle=handle,
+ session_user_id="u1",
+ cache=DualCache(),
+ delivery="carrier-pigeon",
+ )
+ assert rejected.status_code == 400
+ assert json.loads(rejected.body)["error"] == "invalid_request"
+
+ retried = await complete_connect_flow(
+ request=_request("/authorize/complete", cookies=cookies, method="POST"),
+ flow_handle=handle,
+ session_user_id="u1",
+ cache=DualCache(),
+ delivery="manual",
+ )
+ assert retried.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_manual_delivery_page_escapes_client_influenced_values():
+ """redirect_uri (and everything else on the page) is client-registered input; a quote
+ or tag in its path must render inert."""
+ hostile_uri = 'http://127.0.0.1:9/cb">'
+ _, response = await _complete(hostile_uri, delivery="manual")
+ assert response.status_code == 200
+ body = response.body.decode()
+ assert "" not in body
+ assert "<script>" in body
+
+
+class _TtlRecordingCache(DualCache):
+ """Captures the TTL of every single-use claim recorded through the in-memory arm."""
+
+ def __init__(self):
+ super().__init__()
+ self.claim_ttls: dict = {}
+
+ async def async_increment_cache(self, key, value, ttl=None, **kwargs):
+ self.claim_ttls[key] = ttl
+ return await super().async_increment_cache(key, value, ttl=ttl, **kwargs)
+
+
+@pytest.mark.asyncio
+async def test_used_code_marker_outlives_the_manually_delivered_code():
+ """Veria review finding on the LIT-4863 change: a manual code lives 300s, but the
+ used-code marker was retained for the 120s redirect lifetime plus buffer, so a client
+ could redeem, wait out the marker, and redeem the still-valid code again. The marker's
+ TTL must cover the code's own remaining lifetime plus the claim buffer."""
+ client_id, response = await _complete(LOOPBACK_REDIRECT_URI, delivery="manual")
+ code = parse_qs(urlparse(_callback_url_from_page(response)).query)["code"][0]
+
+ cache = _TtlRecordingCache()
+ token_response = await aggregate_token(
+ request=_request("/token", method="POST"),
+ grant_type="authorization_code",
+ code=code,
+ redirect_uri=LOOPBACK_REDIRECT_URI,
+ client_id=client_id,
+ code_verifier=CODE_VERIFIER,
+ refresh_token=None,
+ master_key=MASTER_KEY,
+ reload_user=_reload_user_active,
+ cache=cache,
+ )
+ assert token_response.status_code == 200
+
+ marker_ttls = [ttl for key, ttl in cache.claim_ttls.items() if key.startswith("mcp_gateway_dcr_code_used:")]
+ assert len(marker_ttls) == 1
+ assert marker_ttls[0] >= MANUAL_DELIVERY_AUTH_CODE_TTL_SECONDS
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("redirect_uri", [LOOPBACK_REDIRECT_URI, "http://127.0.0.1:9/cb$(whoami)&calc& rem x"])
+async def test_manual_delivery_page_renders_the_url_as_data_never_as_a_shell_command(redirect_uri):
+ """Two review rounds proved no single command string is safe across POSIX shells,
+ cmd.exe, and PowerShell (single quotes are not quoting in cmd.exe; percent expands
+ there even inside double quotes), so the page must render the callback URL as data
+ only and never as a ready-to-paste command."""
+ _, response = await _complete(redirect_uri, delivery="manual")
+ assert response.status_code == 200
+ body = response.body.decode()
+ assert "" not in body
+ assert 'curl "' not in body
+ assert "curl '" not in body
+ assert 'value="' in body
diff --git a/ui/litellm-dashboard/src/components/chat/ConnectFlowBanner.test.tsx b/ui/litellm-dashboard/src/components/chat/ConnectFlowBanner.test.tsx
index a565ae5db08..4833b4ad8a4 100644
--- a/ui/litellm-dashboard/src/components/chat/ConnectFlowBanner.test.tsx
+++ b/ui/litellm-dashboard/src/components/chat/ConnectFlowBanner.test.tsx
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
-import ConnectFlowBanner from "./ConnectFlowBanner";
+import ConnectFlowBanner, { isLoopbackOrigin } from "./ConnectFlowBanner";
vi.mock("@/components/networking", () => ({
getProxyBaseUrl: () => "https://gateway.example.com",
@@ -36,6 +36,39 @@ describe("ConnectFlowBanner", () => {
expect(screen.getAllByText(/the application/).length).toBeGreaterThan(0);
});
+ it("offers manual delivery for a loopback client, posted only when checked", () => {
+ const { container } = render(
+ ,
+ );
+
+ const checkbox = container.querySelector('input[type="checkbox"][name="delivery"]') as HTMLInputElement;
+ expect(checkbox).not.toBeNull();
+ expect(checkbox.value).toBe("manual");
+ expect(checkbox.checked).toBe(false);
+ expect(screen.getByText(/remote or SSH machine/i)).toBeInTheDocument();
+ });
+
+ it("does not offer manual delivery for a routable client origin or an unknown one", () => {
+ const routable = render( );
+ expect(routable.container.querySelector('input[name="delivery"]')).toBeNull();
+
+ const unknown = render( );
+ expect(unknown.container.querySelector('input[name="delivery"]')).toBeNull();
+ });
+
+ it("classifies loopback origins like the server does", () => {
+ expect(isLoopbackOrigin("http://localhost:3118")).toBe(true);
+ expect(isLoopbackOrigin("http://127.0.0.1:8080")).toBe(true);
+ expect(isLoopbackOrigin("http://127.5.4.3:1")).toBe(true);
+ expect(isLoopbackOrigin("http://[::1]:9000")).toBe(true);
+ expect(isLoopbackOrigin("http://[0:0:0:0:0:0:0:1]:9000")).toBe(true);
+ expect(isLoopbackOrigin("https://claude.ai")).toBe(false);
+ expect(isLoopbackOrigin("http://127.evil.com")).toBe(false);
+ expect(isLoopbackOrigin("http://localhost.evil.com")).toBe(false);
+ expect(isLoopbackOrigin(null)).toBe(false);
+ expect(isLoopbackOrigin("not a url")).toBe(false);
+ });
+
it("does NOT complete the flow on pagehide (completion requires the explicit button)", () => {
// Security regression: an attacker could lure a signed-in victim to their own client's
// authorize URL; the victim merely closing the tab must NOT deliver a victim-bound code.
diff --git a/ui/litellm-dashboard/src/components/chat/ConnectFlowBanner.tsx b/ui/litellm-dashboard/src/components/chat/ConnectFlowBanner.tsx
index ac2c508e815..cea42f916f8 100644
--- a/ui/litellm-dashboard/src/components/chat/ConnectFlowBanner.tsx
+++ b/ui/litellm-dashboard/src/components/chat/ConnectFlowBanner.tsx
@@ -26,9 +26,20 @@ interface Props {
* (no click). Merely visiting the authorize URL is attacker-inducible, so completion has to be a
* deliberate user action, not a side effect of leaving the page.
*/
+export function isLoopbackOrigin(origin: string | null): boolean {
+ if (!origin) return false;
+ try {
+ const hostname = new URL(origin).hostname.replace(/^\[|\]$/g, "");
+ return hostname === "localhost" || hostname === "::1" || /^127(\.\d{1,3}){3}$/.test(hostname);
+ } catch {
+ return false;
+ }
+}
+
const ConnectFlowBanner: React.FC = ({ flowHandle, clientOrigin }) => {
const action = `${getProxyBaseUrl()}/authorize/complete`;
const clientLabel = clientOrigin ?? "the application";
+ const loopbackClient = isLoopbackOrigin(clientOrigin);
return (
From f9c5be8ebfb13b20d671de12f76182ab1714ff15 Mon Sep 17 00:00:00 2001
From: mateo
Date: Thu, 30 Jul 2026 01:43:59 +0000
Subject: [PATCH 02/33] fix(fireworks_ai): correct Kimi K2.5/K2.6/K2.7 max
output token limits
Fireworks publishes a 262144-token context window for the Kimi K2.5, K2.6
and K2.7 models but caps generation well below that. Every fireworks_ai
Kimi K2.5/K2.6/K2.7 alias had max_output_tokens/max_tokens flattened to
262144 (equal to the context window), so the pre-call context-window check
admitted requests asking for a full 262144-token completion that Fireworks
rejects. Correct max_output_tokens/max_tokens to 32768 while keeping
max_input_tokens at 262144, and add a regression test pinning the limits
for all ten aliases.
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
...odel_prices_and_context_window_backup.json | 40 +++++-----
model_prices_and_context_window.json | 40 +++++-----
.../test_fireworks_ai_kimi_model_metadata.py | 76 +++++++++++++++++++
3 files changed, 116 insertions(+), 40 deletions(-)
create mode 100644 tests/test_litellm/llms/fireworks_ai/test_fireworks_ai_kimi_model_metadata.py
diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json
index 87d9b6afc18..5b2a5efc714 100644
--- a/litellm/model_prices_and_context_window_backup.json
+++ b/litellm/model_prices_and_context_window_backup.json
@@ -16703,8 +16703,8 @@
"input_cost_per_token": 6e-07,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 262144,
- "max_output_tokens": 262144,
- "max_tokens": 262144,
+ "max_output_tokens": 32768,
+ "max_tokens": 32768,
"mode": "chat",
"output_cost_per_token": 3e-06,
"source": "https://fireworks.ai/pricing",
@@ -16717,8 +16717,8 @@
"input_cost_per_token": 9.5e-07,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 262144,
- "max_output_tokens": 262144,
- "max_tokens": 262144,
+ "max_output_tokens": 32768,
+ "max_tokens": 32768,
"mode": "chat",
"output_cost_per_token": 4e-06,
"source": "https://docs.fireworks.ai/serverless/pricing",
@@ -16733,8 +16733,8 @@
"input_cost_per_token": 9.5e-07,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 262144,
- "max_output_tokens": 262144,
- "max_tokens": 262144,
+ "max_output_tokens": 32768,
+ "max_tokens": 32768,
"mode": "chat",
"output_cost_per_token": 4e-06,
"source": "https://docs.fireworks.ai/serverless/pricing",
@@ -17077,8 +17077,8 @@
"input_cost_per_token": 6e-07,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 262144,
- "max_output_tokens": 262144,
- "max_tokens": 262144,
+ "max_output_tokens": 32768,
+ "max_tokens": 32768,
"mode": "chat",
"output_cost_per_token": 3e-06,
"source": "https://fireworks.ai/pricing",
@@ -17091,8 +17091,8 @@
"input_cost_per_token": 9.5e-07,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 262144,
- "max_output_tokens": 262144,
- "max_tokens": 262144,
+ "max_output_tokens": 32768,
+ "max_tokens": 32768,
"mode": "chat",
"output_cost_per_token": 4e-06,
"source": "https://docs.fireworks.ai/serverless/pricing",
@@ -17107,8 +17107,8 @@
"input_cost_per_token": 2e-06,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 262144,
- "max_output_tokens": 262144,
- "max_tokens": 262144,
+ "max_output_tokens": 32768,
+ "max_tokens": 32768,
"mode": "chat",
"output_cost_per_token": 8e-06,
"source": "https://docs.fireworks.ai/serverless/pricing",
@@ -17123,8 +17123,8 @@
"input_cost_per_token": 9.5e-07,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 262144,
- "max_output_tokens": 262144,
- "max_tokens": 262144,
+ "max_output_tokens": 32768,
+ "max_tokens": 32768,
"mode": "chat",
"output_cost_per_token": 4e-06,
"source": "https://docs.fireworks.ai/serverless/pricing",
@@ -17139,8 +17139,8 @@
"input_cost_per_token": 1.9e-06,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 262144,
- "max_output_tokens": 262144,
- "max_tokens": 262144,
+ "max_output_tokens": 32768,
+ "max_tokens": 32768,
"mode": "chat",
"output_cost_per_token": 8e-06,
"source": "https://docs.fireworks.ai/serverless/pricing",
@@ -42501,8 +42501,8 @@
"input_cost_per_token": 2e-06,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 262144,
- "max_output_tokens": 262144,
- "max_tokens": 262144,
+ "max_output_tokens": 32768,
+ "max_tokens": 32768,
"mode": "chat",
"output_cost_per_token": 8e-06,
"source": "https://docs.fireworks.ai/serverless/pricing",
@@ -42517,8 +42517,8 @@
"input_cost_per_token": 1.9e-06,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 262144,
- "max_output_tokens": 262144,
- "max_tokens": 262144,
+ "max_output_tokens": 32768,
+ "max_tokens": 32768,
"mode": "chat",
"output_cost_per_token": 8e-06,
"source": "https://docs.fireworks.ai/serverless/pricing",
diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json
index 0edd3bd5f30..b375b770aea 100644
--- a/model_prices_and_context_window.json
+++ b/model_prices_and_context_window.json
@@ -16703,8 +16703,8 @@
"input_cost_per_token": 6e-07,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 262144,
- "max_output_tokens": 262144,
- "max_tokens": 262144,
+ "max_output_tokens": 32768,
+ "max_tokens": 32768,
"mode": "chat",
"output_cost_per_token": 3e-06,
"source": "https://fireworks.ai/pricing",
@@ -16717,8 +16717,8 @@
"input_cost_per_token": 9.5e-07,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 262144,
- "max_output_tokens": 262144,
- "max_tokens": 262144,
+ "max_output_tokens": 32768,
+ "max_tokens": 32768,
"mode": "chat",
"output_cost_per_token": 4e-06,
"source": "https://docs.fireworks.ai/serverless/pricing",
@@ -16733,8 +16733,8 @@
"input_cost_per_token": 9.5e-07,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 262144,
- "max_output_tokens": 262144,
- "max_tokens": 262144,
+ "max_output_tokens": 32768,
+ "max_tokens": 32768,
"mode": "chat",
"output_cost_per_token": 4e-06,
"source": "https://docs.fireworks.ai/serverless/pricing",
@@ -17077,8 +17077,8 @@
"input_cost_per_token": 6e-07,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 262144,
- "max_output_tokens": 262144,
- "max_tokens": 262144,
+ "max_output_tokens": 32768,
+ "max_tokens": 32768,
"mode": "chat",
"output_cost_per_token": 3e-06,
"source": "https://fireworks.ai/pricing",
@@ -17091,8 +17091,8 @@
"input_cost_per_token": 9.5e-07,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 262144,
- "max_output_tokens": 262144,
- "max_tokens": 262144,
+ "max_output_tokens": 32768,
+ "max_tokens": 32768,
"mode": "chat",
"output_cost_per_token": 4e-06,
"source": "https://docs.fireworks.ai/serverless/pricing",
@@ -17107,8 +17107,8 @@
"input_cost_per_token": 2e-06,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 262144,
- "max_output_tokens": 262144,
- "max_tokens": 262144,
+ "max_output_tokens": 32768,
+ "max_tokens": 32768,
"mode": "chat",
"output_cost_per_token": 8e-06,
"source": "https://docs.fireworks.ai/serverless/pricing",
@@ -17123,8 +17123,8 @@
"input_cost_per_token": 9.5e-07,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 262144,
- "max_output_tokens": 262144,
- "max_tokens": 262144,
+ "max_output_tokens": 32768,
+ "max_tokens": 32768,
"mode": "chat",
"output_cost_per_token": 4e-06,
"source": "https://docs.fireworks.ai/serverless/pricing",
@@ -17139,8 +17139,8 @@
"input_cost_per_token": 1.9e-06,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 262144,
- "max_output_tokens": 262144,
- "max_tokens": 262144,
+ "max_output_tokens": 32768,
+ "max_tokens": 32768,
"mode": "chat",
"output_cost_per_token": 8e-06,
"source": "https://docs.fireworks.ai/serverless/pricing",
@@ -42622,8 +42622,8 @@
"input_cost_per_token": 2e-06,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 262144,
- "max_output_tokens": 262144,
- "max_tokens": 262144,
+ "max_output_tokens": 32768,
+ "max_tokens": 32768,
"mode": "chat",
"output_cost_per_token": 8e-06,
"source": "https://docs.fireworks.ai/serverless/pricing",
@@ -42638,8 +42638,8 @@
"input_cost_per_token": 1.9e-06,
"litellm_provider": "fireworks_ai",
"max_input_tokens": 262144,
- "max_output_tokens": 262144,
- "max_tokens": 262144,
+ "max_output_tokens": 32768,
+ "max_tokens": 32768,
"mode": "chat",
"output_cost_per_token": 8e-06,
"source": "https://docs.fireworks.ai/serverless/pricing",
diff --git a/tests/test_litellm/llms/fireworks_ai/test_fireworks_ai_kimi_model_metadata.py b/tests/test_litellm/llms/fireworks_ai/test_fireworks_ai_kimi_model_metadata.py
new file mode 100644
index 00000000000..5641439aa54
--- /dev/null
+++ b/tests/test_litellm/llms/fireworks_ai/test_fireworks_ai_kimi_model_metadata.py
@@ -0,0 +1,76 @@
+"""
+Regression test for Fireworks Kimi K2.5 / K2.6 / K2.7 context and output limits.
+
+Fireworks publishes a 262144-token context window for every Kimi K2.5, K2.6 and
+K2.7 model, but caps generation well below that. A previous bulk edit had flattened
+max_output_tokens/max_tokens to 262144 (equal to the context window), which let the
+pre-call context-window check admit requests asking for a full 262144-token
+completion that Fireworks then rejects. These assertions pin the corrected per-alias
+limits so a future bulk edit can't silently flatten them again.
+"""
+
+import json
+from importlib.resources import files
+
+import pytest
+
+CONTEXT_WINDOW = 262144
+OUTPUT_LIMIT = 32768
+
+KIMI_ALIASES = (
+ "fireworks_ai/kimi-k2p5",
+ "fireworks_ai/kimi-k2p6",
+ "fireworks_ai/kimi-k2p6-fast",
+ "fireworks_ai/kimi-k2p7-code",
+ "fireworks_ai/kimi-k2p7-code-fast",
+ "fireworks_ai/accounts/fireworks/models/kimi-k2p5",
+ "fireworks_ai/accounts/fireworks/models/kimi-k2p6",
+ "fireworks_ai/accounts/fireworks/models/kimi-k2p7-code",
+ "fireworks_ai/accounts/fireworks/routers/kimi-k2p6-fast",
+ "fireworks_ai/accounts/fireworks/routers/kimi-k2p7-code-fast",
+)
+
+
+@pytest.fixture(scope="module")
+def use_local_model_cost_map():
+ monkeypatch = pytest.MonkeyPatch()
+ monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
+
+ import litellm
+ from litellm.utils import _invalidate_model_cost_lowercase_map
+
+ original_model_cost = litellm.model_cost
+ litellm.model_cost = json.loads(
+ files("litellm")
+ .joinpath("model_prices_and_context_window_backup.json")
+ .read_text(encoding="utf-8")
+ )
+ litellm.get_model_info.cache_clear()
+ _invalidate_model_cost_lowercase_map()
+ try:
+ yield litellm
+ finally:
+ litellm.model_cost = original_model_cost
+ litellm.get_model_info.cache_clear()
+ _invalidate_model_cost_lowercase_map()
+ monkeypatch.undo()
+
+
+@pytest.mark.parametrize("alias", KIMI_ALIASES)
+def test_fireworks_kimi_raw_cost_entry_limits(use_local_model_cost_map, alias):
+ entry = use_local_model_cost_map.model_cost[alias]
+
+ assert entry["litellm_provider"] == "fireworks_ai"
+ assert entry["max_input_tokens"] == CONTEXT_WINDOW
+ assert entry["max_output_tokens"] == OUTPUT_LIMIT
+ assert entry["max_tokens"] == OUTPUT_LIMIT
+ assert entry["max_output_tokens"] < entry["max_input_tokens"]
+
+
+@pytest.mark.parametrize("alias", KIMI_ALIASES)
+def test_fireworks_kimi_get_model_info_limits(use_local_model_cost_map, alias):
+ model_info = use_local_model_cost_map.get_model_info(model=alias)
+
+ assert model_info["max_input_tokens"] == CONTEXT_WINDOW
+ assert model_info["max_output_tokens"] == OUTPUT_LIMIT
+ assert model_info["max_tokens"] == OUTPUT_LIMIT
From b0a48d516c53c4d44eb096fe61a77853404fceed Mon Sep 17 00:00:00 2001
From: mateo
Date: Thu, 30 Jul 2026 01:59:25 +0000
Subject: [PATCH 03/33] test(fireworks_ai): align Kimi output-limit
expectations with cost map fix
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
tests/test_litellm/test_utils.py | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py
index b22e69f0942..3b2bc7e647d 100644
--- a/tests/test_litellm/test_utils.py
+++ b/tests/test_litellm/test_utils.py
@@ -4432,7 +4432,7 @@ _FIREWORKS_MODELS = [
4e-06,
1.9e-07,
262144,
- 262144,
+ 32768,
True,
True,
),
@@ -4442,7 +4442,7 @@ _FIREWORKS_MODELS = [
8e-06,
3.8e-07,
262144,
- 262144,
+ 32768,
True,
True,
),
@@ -4452,7 +4452,7 @@ _FIREWORKS_MODELS = [
4e-06,
1.6e-07,
262144,
- 262144,
+ 32768,
True,
True,
),
@@ -4462,7 +4462,7 @@ _FIREWORKS_MODELS = [
8e-06,
3e-07,
262144,
- 262144,
+ 32768,
True,
True,
),
From e0946ccf0d02bb558cd6aab98815ae30d525890f Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Thu, 30 Jul 2026 02:43:54 +0000
Subject: [PATCH 04/33] fix(pricing): correct gpt-5.4-mini and gpt-5.4-nano
token limits
gpt-5.4-mini and gpt-5.4-nano are 400K-context models (272K input,
128K output), but their cost map entries carried gpt-5.4's 1.05M window.
The router's pre-call context window check therefore admitted prompts far
past what the models accept, so oversized requests were dispatched to the
provider and failed there instead of being caught locally or routed
through context_window_fallbacks.
The azure_ai entries also inherited gpt-5.4's above-272K tiered pricing.
OpenAI applies that surcharge to the 1.05M-window models only, so those
keys are removed.
Limits per OpenAI's model reference and Azure AI Foundry's model table:
gpt-5.4-mini and gpt-5.4-nano are 400,000 context / 272,000 input /
128,000 output
---
...odel_prices_and_context_window_backup.json | 40 ++-------
model_prices_and_context_window.json | 48 +++--------
.../test_gpt_5_4_model_metadata.py | 81 +++++++++++++++++++
3 files changed, 101 insertions(+), 68 deletions(-)
create mode 100644 tests/test_litellm/test_gpt_5_4_model_metadata.py
diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json
index 87d9b6afc18..cc418c9c428 100644
--- a/litellm/model_prices_and_context_window_backup.json
+++ b/litellm/model_prices_and_context_window_backup.json
@@ -3454,22 +3454,16 @@
},
"azure_ai/gpt-5.4-mini": {
"cache_read_input_token_cost": 7.5e-08,
- "cache_read_input_token_cost_above_272k_tokens": 1.5e-07,
"cache_read_input_token_cost_priority": 1.5e-07,
- "cache_read_input_token_cost_above_272k_tokens_priority": 3e-07,
"input_cost_per_token": 7.5e-07,
- "input_cost_per_token_above_272k_tokens": 1.5e-06,
"input_cost_per_token_priority": 1.5e-06,
- "input_cost_per_token_above_272k_tokens_priority": 3e-06,
"litellm_provider": "azure_ai",
- "max_input_tokens": 400000,
+ "max_input_tokens": 272000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 4.5e-06,
- "output_cost_per_token_above_272k_tokens": 6.75e-06,
"output_cost_per_token_priority": 9e-06,
- "output_cost_per_token_above_272k_tokens_priority": 1.35e-05,
"source": "https://ai.azure.com/catalog/models/gpt-5.4-mini",
"supported_endpoints": [
"/v1/chat/completions",
@@ -3500,22 +3494,16 @@
},
"azure_ai/gpt-5.4-mini-2026-03-17": {
"cache_read_input_token_cost": 7.5e-08,
- "cache_read_input_token_cost_above_272k_tokens": 1.5e-07,
"cache_read_input_token_cost_priority": 1.5e-07,
- "cache_read_input_token_cost_above_272k_tokens_priority": 3e-07,
"input_cost_per_token": 7.5e-07,
- "input_cost_per_token_above_272k_tokens": 1.5e-06,
"input_cost_per_token_priority": 1.5e-06,
- "input_cost_per_token_above_272k_tokens_priority": 3e-06,
"litellm_provider": "azure_ai",
- "max_input_tokens": 400000,
+ "max_input_tokens": 272000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 4.5e-06,
- "output_cost_per_token_above_272k_tokens": 6.75e-06,
"output_cost_per_token_priority": 9e-06,
- "output_cost_per_token_above_272k_tokens_priority": 1.35e-05,
"source": "https://ai.azure.com/catalog/models/gpt-5.4-mini",
"supported_endpoints": [
"/v1/chat/completions",
@@ -3546,22 +3534,16 @@
},
"azure_ai/gpt-5.4-nano": {
"cache_read_input_token_cost": 2e-08,
- "cache_read_input_token_cost_above_272k_tokens": 4e-08,
"cache_read_input_token_cost_priority": 4e-08,
- "cache_read_input_token_cost_above_272k_tokens_priority": 8e-08,
"input_cost_per_token": 2e-07,
- "input_cost_per_token_above_272k_tokens": 4e-07,
"input_cost_per_token_priority": 4e-07,
- "input_cost_per_token_above_272k_tokens_priority": 8e-07,
"litellm_provider": "azure_ai",
- "max_input_tokens": 400000,
+ "max_input_tokens": 272000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 1.25e-06,
- "output_cost_per_token_above_272k_tokens": 1.875e-06,
"output_cost_per_token_priority": 2.5e-06,
- "output_cost_per_token_above_272k_tokens_priority": 3.75e-06,
"source": "https://ai.azure.com/catalog/models/gpt-5.4-nano",
"supported_endpoints": [
"/v1/chat/completions",
@@ -3592,22 +3574,16 @@
},
"azure_ai/gpt-5.4-nano-2026-03-17": {
"cache_read_input_token_cost": 2e-08,
- "cache_read_input_token_cost_above_272k_tokens": 4e-08,
"cache_read_input_token_cost_priority": 4e-08,
- "cache_read_input_token_cost_above_272k_tokens_priority": 8e-08,
"input_cost_per_token": 2e-07,
- "input_cost_per_token_above_272k_tokens": 4e-07,
"input_cost_per_token_priority": 4e-07,
- "input_cost_per_token_above_272k_tokens_priority": 8e-07,
"litellm_provider": "azure_ai",
- "max_input_tokens": 400000,
+ "max_input_tokens": 272000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 1.25e-06,
- "output_cost_per_token_above_272k_tokens": 1.875e-06,
"output_cost_per_token_priority": 2.5e-06,
- "output_cost_per_token_above_272k_tokens_priority": 3.75e-06,
"source": "https://ai.azure.com/catalog/models/gpt-5.4-nano",
"supported_endpoints": [
"/v1/chat/completions",
@@ -7201,7 +7177,7 @@
"cache_read_input_token_cost": 7.5e-08,
"input_cost_per_token": 7.5e-07,
"litellm_provider": "azure",
- "max_input_tokens": 1050000,
+ "max_input_tokens": 272000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
@@ -7236,7 +7212,7 @@
"cache_read_input_token_cost": 7.5e-08,
"input_cost_per_token": 7.5e-07,
"litellm_provider": "azure",
- "max_input_tokens": 1050000,
+ "max_input_tokens": 272000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
@@ -7271,7 +7247,7 @@
"cache_read_input_token_cost": 2e-08,
"input_cost_per_token": 2e-07,
"litellm_provider": "azure",
- "max_input_tokens": 1050000,
+ "max_input_tokens": 272000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
@@ -7306,7 +7282,7 @@
"cache_read_input_token_cost": 2e-08,
"input_cost_per_token": 2e-07,
"litellm_provider": "azure",
- "max_input_tokens": 1050000,
+ "max_input_tokens": 272000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json
index 0edd3bd5f30..c4628fecdb8 100644
--- a/model_prices_and_context_window.json
+++ b/model_prices_and_context_window.json
@@ -3454,22 +3454,16 @@
},
"azure_ai/gpt-5.4-mini": {
"cache_read_input_token_cost": 7.5e-08,
- "cache_read_input_token_cost_above_272k_tokens": 1.5e-07,
"cache_read_input_token_cost_priority": 1.5e-07,
- "cache_read_input_token_cost_above_272k_tokens_priority": 3e-07,
"input_cost_per_token": 7.5e-07,
- "input_cost_per_token_above_272k_tokens": 1.5e-06,
"input_cost_per_token_priority": 1.5e-06,
- "input_cost_per_token_above_272k_tokens_priority": 3e-06,
"litellm_provider": "azure_ai",
- "max_input_tokens": 400000,
+ "max_input_tokens": 272000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 4.5e-06,
- "output_cost_per_token_above_272k_tokens": 6.75e-06,
"output_cost_per_token_priority": 9e-06,
- "output_cost_per_token_above_272k_tokens_priority": 1.35e-05,
"source": "https://ai.azure.com/catalog/models/gpt-5.4-mini",
"supported_endpoints": [
"/v1/chat/completions",
@@ -3500,22 +3494,16 @@
},
"azure_ai/gpt-5.4-mini-2026-03-17": {
"cache_read_input_token_cost": 7.5e-08,
- "cache_read_input_token_cost_above_272k_tokens": 1.5e-07,
"cache_read_input_token_cost_priority": 1.5e-07,
- "cache_read_input_token_cost_above_272k_tokens_priority": 3e-07,
"input_cost_per_token": 7.5e-07,
- "input_cost_per_token_above_272k_tokens": 1.5e-06,
"input_cost_per_token_priority": 1.5e-06,
- "input_cost_per_token_above_272k_tokens_priority": 3e-06,
"litellm_provider": "azure_ai",
- "max_input_tokens": 400000,
+ "max_input_tokens": 272000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 4.5e-06,
- "output_cost_per_token_above_272k_tokens": 6.75e-06,
"output_cost_per_token_priority": 9e-06,
- "output_cost_per_token_above_272k_tokens_priority": 1.35e-05,
"source": "https://ai.azure.com/catalog/models/gpt-5.4-mini",
"supported_endpoints": [
"/v1/chat/completions",
@@ -3546,22 +3534,16 @@
},
"azure_ai/gpt-5.4-nano": {
"cache_read_input_token_cost": 2e-08,
- "cache_read_input_token_cost_above_272k_tokens": 4e-08,
"cache_read_input_token_cost_priority": 4e-08,
- "cache_read_input_token_cost_above_272k_tokens_priority": 8e-08,
"input_cost_per_token": 2e-07,
- "input_cost_per_token_above_272k_tokens": 4e-07,
"input_cost_per_token_priority": 4e-07,
- "input_cost_per_token_above_272k_tokens_priority": 8e-07,
"litellm_provider": "azure_ai",
- "max_input_tokens": 400000,
+ "max_input_tokens": 272000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 1.25e-06,
- "output_cost_per_token_above_272k_tokens": 1.875e-06,
"output_cost_per_token_priority": 2.5e-06,
- "output_cost_per_token_above_272k_tokens_priority": 3.75e-06,
"source": "https://ai.azure.com/catalog/models/gpt-5.4-nano",
"supported_endpoints": [
"/v1/chat/completions",
@@ -3592,22 +3574,16 @@
},
"azure_ai/gpt-5.4-nano-2026-03-17": {
"cache_read_input_token_cost": 2e-08,
- "cache_read_input_token_cost_above_272k_tokens": 4e-08,
"cache_read_input_token_cost_priority": 4e-08,
- "cache_read_input_token_cost_above_272k_tokens_priority": 8e-08,
"input_cost_per_token": 2e-07,
- "input_cost_per_token_above_272k_tokens": 4e-07,
"input_cost_per_token_priority": 4e-07,
- "input_cost_per_token_above_272k_tokens_priority": 8e-07,
"litellm_provider": "azure_ai",
- "max_input_tokens": 400000,
+ "max_input_tokens": 272000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 1.25e-06,
- "output_cost_per_token_above_272k_tokens": 1.875e-06,
"output_cost_per_token_priority": 2.5e-06,
- "output_cost_per_token_above_272k_tokens_priority": 3.75e-06,
"source": "https://ai.azure.com/catalog/models/gpt-5.4-nano",
"supported_endpoints": [
"/v1/chat/completions",
@@ -7201,7 +7177,7 @@
"cache_read_input_token_cost": 7.5e-08,
"input_cost_per_token": 7.5e-07,
"litellm_provider": "azure",
- "max_input_tokens": 1050000,
+ "max_input_tokens": 272000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
@@ -7236,7 +7212,7 @@
"cache_read_input_token_cost": 7.5e-08,
"input_cost_per_token": 7.5e-07,
"litellm_provider": "azure",
- "max_input_tokens": 1050000,
+ "max_input_tokens": 272000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
@@ -7271,7 +7247,7 @@
"cache_read_input_token_cost": 2e-08,
"input_cost_per_token": 2e-07,
"litellm_provider": "azure",
- "max_input_tokens": 1050000,
+ "max_input_tokens": 272000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
@@ -7306,7 +7282,7 @@
"cache_read_input_token_cost": 2e-08,
"input_cost_per_token": 2e-07,
"litellm_provider": "azure",
- "max_input_tokens": 1050000,
+ "max_input_tokens": 272000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
@@ -24364,7 +24340,7 @@
"input_cost_per_token_batches": 3.75e-07,
"input_cost_per_token_priority": 1.5e-06,
"litellm_provider": "openai",
- "max_input_tokens": 1050000,
+ "max_input_tokens": 272000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
@@ -24410,7 +24386,7 @@
"input_cost_per_token_batches": 3.75e-07,
"input_cost_per_token_priority": 1.5e-06,
"litellm_provider": "openai",
- "max_input_tokens": 1050000,
+ "max_input_tokens": 272000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
@@ -24454,7 +24430,7 @@
"input_cost_per_token_flex": 1e-07,
"input_cost_per_token_batches": 1e-07,
"litellm_provider": "openai",
- "max_input_tokens": 1050000,
+ "max_input_tokens": 272000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
@@ -24497,7 +24473,7 @@
"input_cost_per_token_flex": 1e-07,
"input_cost_per_token_batches": 1e-07,
"litellm_provider": "openai",
- "max_input_tokens": 1050000,
+ "max_input_tokens": 272000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
diff --git a/tests/test_litellm/test_gpt_5_4_model_metadata.py b/tests/test_litellm/test_gpt_5_4_model_metadata.py
new file mode 100644
index 00000000000..f93e6187dcb
--- /dev/null
+++ b/tests/test_litellm/test_gpt_5_4_model_metadata.py
@@ -0,0 +1,81 @@
+import json
+from functools import lru_cache
+from pathlib import Path
+
+import pytest
+
+REPO_ROOT = Path(__file__).parents[2]
+MAIN_PATH = REPO_ROOT / "model_prices_and_context_window.json"
+BACKUP_PATH = REPO_ROOT / "litellm" / "model_prices_and_context_window_backup.json"
+
+DOCUMENTED_MAX_INPUT_TOKENS = 272000
+DOCUMENTED_MAX_OUTPUT_TOKENS = 128000
+
+SMALL_MODEL_NAMES = (
+ "gpt-5.4-mini",
+ "gpt-5.4-mini-2026-03-17",
+ "gpt-5.4-nano",
+ "gpt-5.4-nano-2026-03-17",
+)
+SMALL_MODELS = tuple(f"{prefix}{name}" for prefix in ("", "azure/", "azure_ai/") for name in SMALL_MODEL_NAMES)
+
+STANDARD_PRICING = {
+ "gpt-5.4-mini": (7.5e-07, 4.5e-06, 7.5e-08),
+ "gpt-5.4-nano": (2e-07, 1.25e-06, 2e-08),
+}
+
+LONG_CONTEXT_MODELS = ("gpt-5.4", "gpt-5.4-pro")
+
+
+@lru_cache(maxsize=2)
+def _load(path: Path) -> dict[str, dict[str, object]]:
+ with open(path) as f:
+ return json.load(f)
+
+
+def _pricing_key(model: str) -> str:
+ return "gpt-5.4-nano" if "nano" in model else "gpt-5.4-mini"
+
+
+@pytest.mark.parametrize("model", SMALL_MODELS)
+def test_gpt_5_4_small_models_use_documented_token_limits(model: str) -> None:
+ """gpt-5.4-mini/nano are 400K-window models: 272K in, 128K out, not gpt-5.4's 1.05M window."""
+ info = _load(MAIN_PATH).get(model)
+ assert info is not None, f"{model} not found in model_prices_and_context_window.json"
+
+ assert info["max_input_tokens"] == DOCUMENTED_MAX_INPUT_TOKENS
+ assert info["max_output_tokens"] == DOCUMENTED_MAX_OUTPUT_TOKENS
+ assert info["max_tokens"] == DOCUMENTED_MAX_OUTPUT_TOKENS
+
+
+@pytest.mark.parametrize("model", SMALL_MODELS)
+def test_gpt_5_4_small_models_have_no_long_context_surcharge(model: str) -> None:
+ """OpenAI prices prompts above 272K at 2x input / 1.5x output for the 1.05M-window models only."""
+ info = _load(MAIN_PATH)[model]
+ assert [key for key in info if "above_272k" in key] == []
+
+
+@pytest.mark.parametrize("model", SMALL_MODELS)
+def test_gpt_5_4_small_models_standard_pricing(model: str) -> None:
+ info = _load(MAIN_PATH)[model]
+ input_cost, output_cost, cache_read_cost = STANDARD_PRICING[_pricing_key(model)]
+
+ assert info["input_cost_per_token"] == input_cost
+ assert info["output_cost_per_token"] == output_cost
+ assert info["cache_read_input_token_cost"] == cache_read_cost
+
+
+@pytest.mark.parametrize("model", LONG_CONTEXT_MODELS)
+def test_gpt_5_4_long_context_models_keep_surcharge(model: str) -> None:
+ """The mini/nano correction must leave gpt-5.4 and gpt-5.4-pro tiered pricing intact."""
+ info = _load(MAIN_PATH)[model]
+
+ assert info["input_cost_per_token_above_272k_tokens"] == pytest.approx(info["input_cost_per_token"] * 2)
+ assert info["output_cost_per_token_above_272k_tokens"] == pytest.approx(info["output_cost_per_token"] * 1.5)
+
+
+@pytest.mark.parametrize("model", SMALL_MODELS)
+def test_gpt_5_4_small_models_backup_matches_main(model: str) -> None:
+ assert _load(BACKUP_PATH).get(model) == _load(MAIN_PATH).get(model), (
+ f"{model} differs between main and backup model cost maps"
+ )
From fec7f5f2461ab610858f870b65bae5108b3844df Mon Sep 17 00:00:00 2001
From: Tin Chi Lo
Date: Tue, 28 Jul 2026 15:20:07 -0700
Subject: [PATCH 05/33] feat(ui): give auto-routers their own tab on Models +
Endpoints
Auto-routers had no home and no list. The create form was mounted in two unrelated places,
inside Models + Endpoints > Add Model and again under Cost Optimization, and neither showed
which auto routers already existed; seeing or editing one meant finding its row in the models
table and drilling in. They now get a dedicated Auto-Routers tab beside All Models, listing
every auto_router/* deployment with create, edit and delete in one place, and both former
entry points are removed.
Creating opens in a shadcn dialog rather than swapping the whole panel out, so the list stays
on screen behind it; the dialog caps its height and scrolls, since the complexity form is long.
The form's own heading goes with it, the dialog header owning that now.
An auto router is a routing construct rather than a deployment, so it also comes off the All
Models table. That table pages server-side off total_count, so a client-side filter would page
over a total including rows it never renders; /v2/model/info therefore gains
exclude_auto_routers (default false, so every existing caller is unaffected) and the filter
runs before the count. /v1/models is untouched, so clients still see auto-routers as models.
Clicking a router opens the same `?model=` drill-in the All Models table uses, so it lands in
ModelInfoView with the full Model Settings, Edit Settings, Edit Auto Router and Delete. An
earlier revision had a bespoke detail page here, which was a partial reimplementation of that
view and showed the router's type twice, once as a Type pill and again as a "Routing strategy"
field saying the same thing. Both are gone.
The auto-router list is keyed under the same `models/list` namespace as the models table
rather than a private one. It reads the same /v2/model/info data, and six call sites across
the app already invalidate ["models","list"] after a write; a separate key meant an edit made
through ModelInfoView left the tab stale until a full reload, and every future writer would
have had to remember a second key.
An auto router has no upstream credential, so its detail header drops Update API Key and
Re-use Credentials, and the destructive action names what it removes rather than saying model.
Test Connection was gated on the editor-aware predicate, which let adaptive and quality routers
through to a check that builds its targets from complexity config they do not have; it now
gates on the deployment predicate.
The edit modal also applies the semantic-matching guard the create form has. It renders those
controls now, and the backend raises on semantic_keyword_matching without an embedding model or
keyword rules, so skipping the shared validator turned an inline message into a raw 400.
Whether a row is writable has two independent axes and the dashboard needs both. STRATEGY:
there are four auto_router/* kinds and only complexity and semantic have a form here, so
adaptive and quality must not be handed an editor that would write auto_router_config onto a
deployment storing its settings elsewhere. ORIGIN: a config.yaml row reports db_model false and
the API refuses it whatever its strategy (PATCH /model/{id}/update 404s, POST /model/delete
400s). Capability is derived per capability rather than as one editable flag, because the
constraints differ: editing needs an editor, deleting removes a row by id and never reads its
config, so a DB-created adaptive router stays deletable. Both axes live in
add_model/auto_router_strategies.ts as a declarative table, one record per strategy, so a fifth
strategy is a table row rather than another branch. That also retired four copies of "is this a
complexity router", one of which was written twice in a row in model_info_view.
Creation narrows to the complexity router, which the UI calls Auto-Router v2; the semantic
option was already badged "to be deprecated" in the picker, so the picker goes away along with
the semantic submit path and its validation helper. Existing semantic routers stay editable.
The edit modal mounted ComplexityRouterConfig without the keyword, escalation and
semantic-matching handlers, so those sections never rendered and could only be set at create
time. It now hydrates them from the stored config, and the five keys become managed only when a
caller supplies that state, so a caller rendering no such control still carries them through. A
component-level round-trip test covers it: a payload-builder test cannot see a hydration bug.
A complexity tier is str | list[str] on the backend, and the UI carried three readers of that
rule, one of which dropped a pinned string. They collapse into one owner,
add_model/complexity_router_tiers.ts.
---
litellm/proxy/proxy_server.py | 29 ++
.../proxy_server/test_routes_model_info.py | 146 ++++++++
ui/litellm-dashboard/eslint-suppressions.json | 27 +-
.../_components/AutorouterTab.tsx | 28 --
.../CostOptimizationView.activity.test.tsx | 1 -
.../_components/CostOptimizationView.test.tsx | 7 +-
.../_components/CostOptimizationView.tsx | 9 +-
.../hooks/models/useModels.test.ts | 27 ++
.../app/(dashboard)/hooks/models/useModels.ts | 92 ++++-
.../components/AllModelsTab.tsx | 3 +
.../AutoRouters/AutoRoutersPanel.test.tsx | 252 ++++++++++++++
.../AutoRouters/AutoRoutersPanel.tsx | 117 +++++++
.../AutoRouters/AutoRoutersTable.tsx | 68 ++++
.../AutoRouters/AutoRoutersTableColumns.tsx | 173 ++++++++++
.../AutoRouters/autoRouterRows.test.ts | 196 +++++++++++
.../components/AutoRouters/autoRouterRows.ts | 103 ++++++
.../components/AutoRouters/fitPills.test.ts | 43 +++
.../components/AutoRouters/fitPills.ts | 52 +++
.../models-and-endpoints/page.test.tsx | 31 ++
.../(dashboard)/models-and-endpoints/page.tsx | 22 +-
.../panels/AddModelPanel.tsx | 8 +-
.../panels/AutoRoutersTabPanel.tsx | 23 ++
.../_components/general_settings.test.tsx | 17 +-
.../_components/general_settings.tsx | 152 +++++----
.../add_model/AddModelForm.test.tsx | 13 +
.../add_model/add_auto_router_tab.test.tsx | 6 +-
.../add_model/add_auto_router_tab.tsx | 197 ++---------
.../add_model/add_model_tab.test.tsx | 318 ------------------
.../components/add_model/add_model_tab.tsx | 98 ------
.../add_model/auto_router_strategies.ts | 143 ++++++++
.../build_complexity_router_config.ts | 8 +-
.../build_semantic_router_validation.test.ts | 67 ----
.../build_semantic_router_validation.ts | 29 --
.../add_model/complexity_router_keywords.ts | 40 +++
.../add_model/complexity_router_tiers.test.ts | 30 ++
.../add_model/complexity_router_tiers.ts | 14 +
...d_updated_complexity_router_config.test.ts | 88 +++++
.../edit_auto_router_modal.test.tsx | 121 +++++++
.../edit_auto_router_modal.tsx | 122 +++++--
.../src/components/leftnav.test.tsx | 21 ++
.../src/components/model_info_view.test.tsx | 85 +++++
.../src/components/model_info_view.tsx | 78 +++--
.../src/components/networking.tsx | 4 +
ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 +
44 files changed, 2198 insertions(+), 912 deletions(-)
delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutorouterTab.tsx
create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.test.tsx
create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.tsx
create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersTable.tsx
create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersTableColumns.tsx
create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/autoRouterRows.test.ts
create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/autoRouterRows.ts
create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/fitPills.test.ts
create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/fitPills.ts
create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AutoRoutersTabPanel.tsx
delete mode 100644 ui/litellm-dashboard/src/components/add_model/add_model_tab.test.tsx
delete mode 100644 ui/litellm-dashboard/src/components/add_model/add_model_tab.tsx
create mode 100644 ui/litellm-dashboard/src/components/add_model/auto_router_strategies.ts
delete mode 100644 ui/litellm-dashboard/src/components/add_model/build_semantic_router_validation.test.ts
delete mode 100644 ui/litellm-dashboard/src/components/add_model/build_semantic_router_validation.ts
create mode 100644 ui/litellm-dashboard/src/components/add_model/complexity_router_keywords.ts
create mode 100644 ui/litellm-dashboard/src/components/add_model/complexity_router_tiers.test.ts
create mode 100644 ui/litellm-dashboard/src/components/add_model/complexity_router_tiers.ts
create mode 100644 ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts
create mode 100644 ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.tsx
diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py
index 70484eb1e4e..bf010c7f249 100644
--- a/litellm/proxy/proxy_server.py
+++ b/litellm/proxy/proxy_server.py
@@ -11773,6 +11773,22 @@ def _sort_models(
return all_models
+def _is_auto_router_model(model: Mapping[str, object]) -> bool:
+ """
+ True for any auto-router deployment, i.e. every `auto_router/*` strategy
+ (semantic, complexity, adaptive, quality).
+
+ Router._is_auto_router_deployment is deliberately narrower; it answers "is this the
+ *semantic* auto-router strategy" and returns False for the complexity and adaptive
+ prefixes, so it is not reusable here.
+ """
+ litellm_params = model.get("litellm_params")
+ if not isinstance(litellm_params, Mapping):
+ return False
+ litellm_model = litellm_params.get("model")
+ return isinstance(litellm_model, str) and litellm_model.startswith("auto_router/")
+
+
def _paginate_models_response(
all_models: List[Dict[str, Any]],
page: int,
@@ -12067,6 +12083,14 @@ async def model_info_v2(
"asc",
description="Sort order. Options: asc, desc",
),
+ exclude_auto_routers: bool | None = fastapi.Query(
+ False,
+ description=(
+ "Omit auto-router deployments (litellm model prefixed `auto_router/`). "
+ "They are routing constructs rather than deployments, and are managed on the "
+ "Router Settings page. Defaults to false, so existing callers are unaffected"
+ ),
+ ),
):
"""
Paginated model metadata for proxy deployments (pricing, provider, team access).
@@ -12234,6 +12258,11 @@ async def model_info_v2(
user_api_key_dict=user_api_key_dict,
)
+ # `is True` because direct-call tests bypass FastAPI, so the Query default arrives as a
+ # truthy sentinel object rather than False.
+ if exclude_auto_routers is True:
+ all_models = [m for m in all_models if not _is_auto_router_model(m)]
+
# Update total count to include agents
search_total_count = len(all_models)
diff --git a/tests/test_litellm/proxy/proxy_server/test_routes_model_info.py b/tests/test_litellm/proxy/proxy_server/test_routes_model_info.py
index 00c3c5b1e74..b0e8a85d3fa 100644
--- a/tests/test_litellm/proxy/proxy_server/test_routes_model_info.py
+++ b/tests/test_litellm/proxy/proxy_server/test_routes_model_info.py
@@ -292,3 +292,149 @@ def test_model_group_info_invalid_method(client, auth_as, null_router):
response = client.post("/model_group/info", json={})
assert response.status_code == 405
assert len(response.content) > 0
+
+
+# ---------------------------------------------------------------------------
+# GET /v2/model/info?exclude_auto_routers
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+def mixed_auto_router_router(monkeypatch):
+ """Router carrying one ordinary deployment per auto-router strategy plus two plain ones."""
+ model_list = [
+ {
+ "model_name": "gpt-4o-mini",
+ "litellm_params": {"model": "openai/gpt-4o-mini"},
+ "model_info": {"id": "plain-1", "db_model": False},
+ },
+ {
+ "model_name": "tri-tier-router",
+ "litellm_params": {"model": "auto_router/complexity_router"},
+ "model_info": {"id": "auto-complexity", "db_model": True},
+ },
+ {
+ "model_name": "support-router",
+ "litellm_params": {"model": "auto_router/support-router"},
+ "model_info": {"id": "auto-semantic", "db_model": True},
+ },
+ {
+ "model_name": "adaptive-router",
+ "litellm_params": {"model": "auto_router/adaptive_router"},
+ "model_info": {"id": "auto-adaptive", "db_model": True},
+ },
+ {
+ "model_name": "claude-opus",
+ "litellm_params": {"model": "anthropic/claude-opus-4-6"},
+ "model_info": {"id": "plain-2", "db_model": False},
+ },
+ ]
+ from unittest.mock import AsyncMock
+
+ router = MagicMock()
+ router.model_list = model_list
+ monkeypatch.setattr(proxy_server, "llm_router", router)
+ monkeypatch.setattr(proxy_server, "llm_model_list", model_list)
+ monkeypatch.setattr(proxy_server, "prisma_client", MagicMock())
+ monkeypatch.setattr(proxy_server, "user_model", None)
+ monkeypatch.setattr(proxy_server.proxy_config, "get_config", AsyncMock(return_value={}))
+ monkeypatch.setattr(
+ proxy_server,
+ "_apply_search_filter_to_models",
+ AsyncMock(side_effect=lambda all_models, **kw: (all_models, len(all_models))),
+ )
+ monkeypatch.setattr(proxy_server, "_enrich_model_info_with_litellm_data", lambda model, **kw: model)
+
+ import litellm.proxy.agent_endpoints.model_list_helpers as mlh
+
+ monkeypatch.setattr(mlh, "append_agents_to_model_info", AsyncMock(side_effect=lambda models, **kw: models))
+ yield router
+
+
+def _model_names(payload) -> list:
+ return [m["model_name"] for m in payload["data"]]
+
+
+def test_v2_model_info_includes_auto_routers_by_default(client, auth_as, mixed_auto_router_router):
+ """The new param is opt-in; omitting it must not change what any existing caller sees."""
+ with auth_as():
+ response = client.get("/v2/model/info")
+ assert response.status_code == 200
+ payload = response.json()
+ assert "tri-tier-router" in _model_names(payload)
+ assert payload["total_count"] == 5
+
+
+def test_v2_model_info_excludes_every_auto_router_strategy(client, auth_as, mixed_auto_router_router):
+ """All four `auto_router/*` strategies go, not just the semantic one that
+ Router._is_auto_router_deployment recognises."""
+ with auth_as():
+ response = client.get("/v2/model/info", params={"exclude_auto_routers": "true"})
+ assert response.status_code == 200
+ payload = response.json()
+ assert _model_names(payload) == ["gpt-4o-mini", "claude-opus"]
+
+
+def test_v2_model_info_exclude_auto_routers_shrinks_total_count(client, auth_as, mixed_auto_router_router):
+ """The filter must run before the count, or the table pages off a total that
+ includes rows it never renders (49 shown, 50 claimed)."""
+ with auth_as():
+ response = client.get("/v2/model/info", params={"exclude_auto_routers": "true"})
+ payload = response.json()
+ assert payload["total_count"] == 2
+ assert len(payload["data"]) == payload["total_count"]
+
+
+def test_v2_model_info_exclude_auto_routers_paginates_over_the_filtered_set(
+ client, auth_as, mixed_auto_router_router
+):
+ """Page size applies to the filtered list, so no page silently comes back short."""
+ with auth_as():
+ response = client.get(
+ "/v2/model/info", params={"exclude_auto_routers": "true", "page": 1, "size": 1}
+ )
+ payload = response.json()
+ assert payload["total_count"] == 2
+ assert payload["total_pages"] == 2
+ assert len(payload["data"]) == 1
+
+
+@pytest.mark.asyncio
+async def test_model_info_v2_query_sentinel_does_not_filter(monkeypatch, mixed_auto_router_router):
+ """Called directly (not through FastAPI) the default arrives as a truthy Query object.
+ Guarding on `is True` is what stops every direct-call test from silently filtering."""
+ from unittest.mock import AsyncMock
+
+ from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
+
+ monkeypatch.setattr(proxy_server, "prisma_client", MagicMock())
+ monkeypatch.setattr(proxy_server.proxy_config, "get_config", AsyncMock(return_value={}))
+ monkeypatch.setattr(
+ proxy_server,
+ "_apply_search_filter_to_models",
+ AsyncMock(side_effect=lambda all_models, **kw: (all_models, len(all_models))),
+ )
+ monkeypatch.setattr(proxy_server, "_enrich_model_info_with_litellm_data", lambda model, **kw: model)
+
+ import litellm.proxy.agent_endpoints.model_list_helpers as mlh
+
+ monkeypatch.setattr(mlh, "append_agents_to_model_info", AsyncMock(side_effect=lambda models, **kw: models))
+
+ admin = UserAPIKeyAuth(user_id="u", user_role=LitellmUserRoles.PROXY_ADMIN)
+ # Deliberately omit exclude_auto_routers, exactly as the pre-existing direct-call tests do.
+ resp = await proxy_server.model_info_v2(
+ user_api_key_dict=admin,
+ model=None,
+ user_models_only=False,
+ include_team_models=False,
+ debug=False,
+ page=1,
+ size=50,
+ search=None,
+ modelId=None,
+ teamId=None,
+ sortBy=None,
+ sortOrder="asc",
+ )
+
+ assert "tri-tier-router" in [m["model_name"] for m in resp["data"]]
diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json
index 7686cc05fa6..c0d24d3a1e6 100644
--- a/ui/litellm-dashboard/eslint-suppressions.json
+++ b/ui/litellm-dashboard/eslint-suppressions.json
@@ -210,11 +210,6 @@
"count": 1
}
},
- "src/app/(dashboard)/cost-optimization/_components/AutorouterTab.tsx": {
- "no-restricted-imports": {
- "count": 1
- }
- },
"src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx": {
"no-restricted-imports": {
"count": 1
@@ -1696,7 +1691,7 @@
"count": 1
},
"no-restricted-imports": {
- "count": 3
+ "count": 2
},
"prefer-const": {
"count": 2
@@ -2550,17 +2545,12 @@
"count": 1
}
},
- "src/components/add_model/add_auto_router_tab.test.tsx": {
- "no-restricted-imports": {
- "count": 1
- }
- },
"src/components/add_model/add_auto_router_tab.tsx": {
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
- "count": 3
+ "count": 2
}
},
"src/components/add_model/add_model_modes.tsx": {
@@ -2568,19 +2558,6 @@
"count": 1
}
},
- "src/components/add_model/add_model_tab.test.tsx": {
- "no-restricted-imports": {
- "count": 2
- }
- },
- "src/components/add_model/add_model_tab.tsx": {
- "local/filename-pascal-case": {
- "count": 1
- },
- "no-restricted-imports": {
- "count": 4
- }
- },
"src/components/add_model/advanced_settings.tsx": {
"local/filename-pascal-case": {
"count": 1
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutorouterTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutorouterTab.tsx
deleted file mode 100644
index 3474036528a..00000000000
--- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutorouterTab.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-"use client";
-
-import React from "react";
-import { Form } from "antd";
-
-import AddAutoRouterTab from "@/components/add_model/add_auto_router_tab";
-
-interface AutorouterTabProps {
- accessToken: string | null;
- userId: string | null;
- userRole: string;
-}
-
-const AutorouterTab: React.FC = ({ accessToken, userRole }) => {
- const [form] = Form.useForm();
-
- if (!accessToken) {
- return null;
- }
-
- return (
-
-
form.resetFields()} accessToken={accessToken} userRole={userRole} />
-
- );
-};
-
-export default AutorouterTab;
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx
index 363525c48af..ca7adf07941 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx
@@ -27,7 +27,6 @@ vi.mock("@/app/(dashboard)/router-settings/_components/general_settings", () =>
}));
vi.mock("./PromptCompressionTab", () => ({ __esModule: true, default: () =>
}));
-vi.mock("./AutorouterTab", () => ({ __esModule: true, default: () =>
}));
import CostOptimizationView from "./CostOptimizationView";
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx
index 46aa23fcfc0..42dc7719144 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx
@@ -3,7 +3,6 @@ import { describe, expect, it, vi } from "vitest";
vi.mock("./UsageTab", () => ({ __esModule: true, default: () =>
}));
vi.mock("./PromptCompressionTab", () => ({ __esModule: true, default: () =>
}));
-vi.mock("./AutorouterTab", () => ({ __esModule: true, default: () =>
}));
vi.mock("./PromptCachingTab", () => ({ __esModule: true, default: () =>
}));
import CostOptimizationView from "./CostOptimizationView";
@@ -11,13 +10,13 @@ import CostOptimizationView from "./CostOptimizationView";
const renderView = () => render( );
describe("CostOptimizationView", () => {
- it("renders all four cost-optimization tabs", () => {
- const { getByText } = renderView();
+ it("renders the three cost-optimization tabs and no autorouter tab", () => {
+ const { getByText, queryByText } = renderView();
expect(getByText("Usage")).toBeInTheDocument();
expect(getByText("Prompt Compression")).toBeInTheDocument();
- expect(getByText("Autorouter")).toBeInTheDocument();
expect(getByText("Prompt Caching")).toBeInTheDocument();
+ expect(queryByText("Autorouter")).not.toBeInTheDocument();
});
it("defaults to the Usage tab and switches the active tab on click", () => {
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx
index 3bab6afee57..9c2d0b20b56 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx
@@ -6,7 +6,6 @@ import { Alert, Tabs } from "antd";
import UsageTab from "./UsageTab";
import PromptCompressionTab from "./PromptCompressionTab";
-import AutorouterTab from "./AutorouterTab";
import PromptCachingTab from "./PromptCachingTab";
import { useDailyActivityRange } from "./useDailyActivityRange";
@@ -30,11 +29,6 @@ const CostOptimizationView: React.FC = ({ accessToken
label: "Prompt Compression",
children: ,
},
- {
- key: "autorouter",
- label: "Autorouter",
- children: ,
- },
{
key: "caching",
label: "Prompt Caching",
@@ -50,7 +44,8 @@ const CostOptimizationView: React.FC = ({ accessToken
Cost Optimization
- Track and configure the mechanisms that save you money: prompt compression, prompt caching, and auto routing
+ Track and configure the mechanisms that save you money: prompt compression and prompt caching. Auto routers
+ live on the Router Settings page
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts
index f83ebd2622a..f04c4b7bfcd 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts
@@ -7,6 +7,7 @@ import {
selectAutoRouterModelGroups,
useAllProxyModels,
useAutoRouterModelGroups,
+ useAutoRouters,
useInfiniteModelInfo,
useModelHub,
useModelsInfo,
@@ -113,6 +114,9 @@ describe("useModelsInfo", () => {
undefined,
undefined,
undefined,
+ // exclude_auto_routers defaults off: only the Models + Endpoints table opts in, so
+ // every other consumer of this hook keeps seeing auto-routers.
+ false,
);
expect(modelInfoCall).toHaveBeenCalledTimes(1);
});
@@ -137,6 +141,9 @@ describe("useModelsInfo", () => {
undefined,
undefined,
undefined,
+ // exclude_auto_routers defaults off: only the Models + Endpoints table opts in, so
+ // every other consumer of this hook keeps seeing auto-routers.
+ false,
);
});
@@ -1079,4 +1086,24 @@ describe("useAutoRouterModelGroups", () => {
await waitFor(() => expect(modelInfoCall).toHaveBeenCalled());
expect(result.current.size).toBe(0);
});
+
+ // The Auto-Routers tab and the models table read the same /v2/model/info data. Six call
+ // sites across the app invalidate ["models","list"] after a write; if the auto-router query
+ // sits in its own namespace, an edit through ModelInfoView leaves the tab stale until a full
+ // reload, and every future writer has to remember a second key.
+ describe("auto-router cache namespace", () => {
+ it("keys the auto-router list under models/list so existing invalidations reach it", async () => {
+ (modelInfoCall as any).mockResolvedValue(mockPaginatedModelInfoResponse);
+ const { result } = renderHook(() => useAutoRouters(), { wrapper });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ const keys = queryClient
+ .getQueryCache()
+ .findAll({ queryKey: ["models", "list"] })
+ .map((query) => query.queryKey);
+
+ expect(keys.some((key) => JSON.stringify(key).includes("autoRouters"))).toBe(true);
+ });
+ });
});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts
index ad5e3c91ec3..88c4836f112 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts
@@ -1,4 +1,4 @@
-import { useQuery, useInfiniteQuery, UseQueryResult } from "@tanstack/react-query";
+import { useQuery, useInfiniteQuery, useQueryClient, UseQueryResult } from "@tanstack/react-query";
import { createQueryKeys } from "../common/queryKeysFactory";
import { modelInfoCall, modelHubCall, modelAvailableCall } from "@/components/networking";
import useAuthorized from "../useAuthorized";
@@ -24,7 +24,6 @@ export interface PaginatedModelInfoResponse {
const modelKeys = createQueryKeys("models");
const modelHubKeys = createQueryKeys("modelHub");
-const autoRouterKeys = createQueryKeys("autoRouterModelGroups");
const allProxyModelsKeys = createQueryKeys("allProxyModels");
const selectedTeamModelsKeys = createQueryKeys("selectedTeamModels");
const infiniteModelKeys = createQueryKeys("infiniteModels");
@@ -38,6 +37,7 @@ export const useModelsInfo = (
teamId?: string,
sortBy?: string,
sortOrder?: string,
+ excludeAutoRouters: boolean = false,
) => {
const { accessToken, userId, userRole } = useAuthorized();
return useQuery({
@@ -52,10 +52,25 @@ export const useModelsInfo = (
...(teamId && { teamId }),
...(sortBy && { sortBy }),
...(sortOrder && { sortOrder }),
+ // Part of the key: callers that exclude auto-routers must not share a cache entry
+ // with callers that keep them.
+ ...(excludeAutoRouters && { excludeAutoRouters: "true" }),
},
}),
queryFn: async () =>
- await modelInfoCall(accessToken!, userId!, userRole!, page, size, search, modelId, teamId, sortBy, sortOrder),
+ await modelInfoCall(
+ accessToken!,
+ userId!,
+ userRole!,
+ page,
+ size,
+ search,
+ modelId,
+ teamId,
+ sortBy,
+ sortOrder,
+ excludeAutoRouters,
+ ),
enabled: Boolean(accessToken && userId && userRole),
});
};
@@ -69,6 +84,29 @@ export interface AutoRouterCandidateDeployment {
litellm_params?: { model?: string | null } | null;
}
+export interface AutoRouterDeployment extends AutoRouterCandidateDeployment {
+ litellm_params?: {
+ model?: string | null;
+ complexity_router_config?: unknown;
+ complexity_router_default_model?: string | null;
+ auto_router_config?: unknown;
+ auto_router_default_model?: string | null;
+ auto_router_embedding_model?: string | null;
+ adaptive_router_config?: unknown;
+ adaptive_router_default_model?: string | null;
+ quality_router_config?: unknown;
+ quality_router_default_model?: string | null;
+ } | null;
+ model_info?: {
+ id?: string | null;
+ /** False for config.yaml-defined deployments, which the update and delete routes refuse. */
+ db_model?: boolean | null;
+ created_at?: string | null;
+ updated_at?: string | null;
+ team_id?: string | null;
+ } | null;
+}
+
export const isAutoRouterDeployment = (deployment: AutoRouterCandidateDeployment): boolean =>
Boolean(deployment?.litellm_params?.model?.startsWith(AUTO_ROUTER_MODEL_PREFIX));
@@ -80,11 +118,14 @@ export const selectAutoRouterModelGroups = (deployments: AutoRouterCandidateDepl
.filter((modelName): modelName is string => Boolean(modelName)),
);
+export const selectAutoRouterDeployments = (deployments: AutoRouterDeployment[]): AutoRouterDeployment[] =>
+ deployments.filter(isAutoRouterDeployment);
+
const fetchAllModelDeployments = async (
accessToken: string,
userId: string,
userRole: string,
-): Promise => {
+): Promise => {
const firstPage: PaginatedModelInfoResponse = await modelInfoCall(
accessToken,
userId,
@@ -100,18 +141,28 @@ const fetchAllModelDeployments = async (
);
return [firstPage, ...remainingPages].flatMap(
(page: PaginatedModelInfoResponse) => page?.data ?? [],
- ) as AutoRouterCandidateDeployment[];
+ ) as AutoRouterDeployment[];
};
+/**
+ * Deliberately under the same `models/list` namespace as useModelsInfo: it is the same
+ * /v2/model/info data, and every writer in the app already invalidates ["models","list"].
+ * A private namespace meant an edit through ModelInfoView left this list stale, and every
+ * future writer would have had to remember a second key.
+ */
+const autoRouterListKey = (userId: string | null, userRole: string | null) =>
+ modelKeys.list({
+ filters: {
+ scope: "autoRouters",
+ ...(userId && { userId }),
+ ...(userRole && { userRole }),
+ },
+ });
+
export const useAutoRouterModelGroups = (): ReadonlySet => {
const { accessToken, userId, userRole } = useAuthorized();
- const { data } = useQuery>({
- queryKey: autoRouterKeys.list({
- filters: {
- ...(userId && { userId }),
- ...(userRole && { userRole }),
- },
- }),
+ const { data } = useQuery>({
+ queryKey: autoRouterListKey(userId, userRole),
queryFn: async () => await fetchAllModelDeployments(accessToken!, userId!, userRole!),
enabled: Boolean(accessToken && userId && userRole),
select: selectAutoRouterModelGroups,
@@ -119,6 +170,23 @@ export const useAutoRouterModelGroups = (): ReadonlySet => {
return data ?? NO_AUTO_ROUTERS;
};
+export const useAutoRouters = (): UseQueryResult => {
+ const { accessToken, userId, userRole } = useAuthorized();
+ return useQuery({
+ queryKey: autoRouterListKey(userId, userRole),
+ queryFn: async () => await fetchAllModelDeployments(accessToken!, userId!, userRole!),
+ enabled: Boolean(accessToken && userId && userRole),
+ select: selectAutoRouterDeployments,
+ });
+};
+
+export const useInvalidateAutoRouters = (): (() => Promise) => {
+ const queryClient = useQueryClient();
+ return async () => {
+ await queryClient.invalidateQueries({ queryKey: modelKeys.lists() });
+ };
+};
+
export const useModelHub = () => {
const { accessToken } = useAuthorized();
return useQuery({
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx
index 1dc7736d5ac..1a65109eac8 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx
@@ -104,6 +104,9 @@ const AllModelsTab = ({
teamIdForQuery,
sortBy,
sortOrder,
+ // Auto-routers are routing constructs, not deployments; they are listed and managed on
+ // the Router Settings page. Excluded server-side so total_count stays honest.
+ true,
);
const isLoading = isLoadingModelsInfo || isLoadingModelCostMap;
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.test.tsx
new file mode 100644
index 00000000000..7d026f067fb
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.test.tsx
@@ -0,0 +1,252 @@
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+
+import { renderWithProviders, screen, testQueryClient, waitFor } from "@/../tests/test-utils";
+
+import { AutoRoutersPanel } from "./AutoRoutersPanel";
+
+const { modelInfoCall, modelDeleteCall } = vi.hoisted(() => ({
+ modelInfoCall: vi.fn(),
+ modelDeleteCall: vi.fn().mockResolvedValue({}),
+}));
+
+vi.mock("@/components/networking", () => ({
+ modelInfoCall,
+ modelDeleteCall,
+ modelHubCall: vi.fn(),
+ modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }),
+}));
+
+vi.mock("@/components/llm_calls/fetch_models", () => ({
+ fetchAvailableModels: vi.fn().mockResolvedValue([]),
+}));
+
+const { openModel } = vi.hoisted(() => ({ openModel: vi.fn() }));
+
+vi.mock("@/app/(dashboard)/models-and-endpoints/detailNavigation", () => ({
+ useModelDetailRouting: () => ({ openModel, modelId: null, teamId: null, openTeam: vi.fn(), close: vi.fn() }),
+}));
+
+vi.mock("@/components/edit_auto_router/edit_auto_router_modal", () => ({
+ __esModule: true,
+ default: ({ modelData }: { modelData: { model_name?: string; model_info?: { id?: string } } }) => (
+
+ edit:{modelData.model_name}:{modelData.model_info?.id}
+
+ ),
+}));
+
+vi.mock("@/components/add_model/add_auto_router_tab", () => ({
+ __esModule: true,
+ default: ({ handleOk }: { handleOk: () => void }) => (
+
+ Submit auto router
+
+ ),
+}));
+
+// A realistic /v2/model/info page: two auto-routers among ordinary deployments. The panel must
+// render exactly the auto_router/* rows; a view that renders page.data unfiltered passes a
+// "renders a table" assertion but fails this one.
+const DEPLOYMENTS = [
+ {
+ // DB-created adaptive router: no editor for its shape, but it must stay deletable, since
+ // auto-routers are excluded from Models + Endpoints and this tab is the only delete path.
+ model_name: "adaptive-router",
+ litellm_params: { model: "auto_router/adaptive_router" },
+ model_info: { id: "auto-3", db_model: true },
+ },
+ {
+ // config.yaml row: the API refuses both update and delete, so neither control may appear.
+ model_name: "config-router",
+ litellm_params: {
+ model: "auto_router/complexity_router",
+ complexity_router_config: { tiers: {}, classifier_type: "llm" },
+ },
+ model_info: { id: "auto-4", db_model: false },
+ },
+ {
+ model_name: "gpt-4o-mini",
+ litellm_params: { model: "openai/gpt-4o-mini" },
+ model_info: { id: "plain-1" },
+ },
+ {
+ model_name: "tri-tier-router",
+ litellm_params: {
+ model: "auto_router/complexity_router",
+ complexity_router_config: { tiers: { SIMPLE: ["gpt-4o-mini"] }, classifier_type: "heuristic" },
+ complexity_router_default_model: "gpt-4o-mini",
+ },
+ model_info: { id: "auto-1", db_model: true, created_at: "2026-07-28T21:40:09.900000+00:00" },
+ },
+ {
+ model_name: "anthropic-opus-4-6",
+ litellm_params: { model: "anthropic/claude-opus-4-6" },
+ model_info: { id: "plain-2" },
+ },
+ {
+ model_name: "support-router",
+ litellm_params: {
+ model: "auto_router/support-router",
+ auto_router_config: JSON.stringify({ routes: [{ name: "gpt-4o-mini" }] }),
+ auto_router_default_model: "gpt-4o-mini",
+ },
+ model_info: { id: "auto-2", db_model: true, created_at: "2026-07-27T10:00:00.000000+00:00" },
+ },
+];
+
+const pageOf = (data: typeof DEPLOYMENTS) => ({
+ data,
+ total_count: data.length,
+ current_page: 1,
+ total_pages: 1,
+ size: 1000,
+});
+
+const mockDeploymentsPage = () => {
+ modelInfoCall.mockResolvedValue(pageOf(DEPLOYMENTS));
+};
+
+const renderPanel = (canModify = true) =>
+ renderWithProviders( );
+
+describe("AutoRoutersPanel", () => {
+ beforeEach(() => {
+ // The shared test client caches with staleTime: Infinity and refetchOnMount: false, so
+ // without this every test after the first reads the previous test's deployment page.
+ testQueryClient.clear();
+ modelInfoCall.mockReset();
+ modelDeleteCall.mockClear();
+ openModel.mockClear();
+ mockDeploymentsPage();
+ });
+
+ it("lists only auto_router deployments, not every model on the proxy", async () => {
+ renderPanel();
+
+ expect(await screen.findByText("tri-tier-router")).toBeInTheDocument();
+ expect(await screen.findByText("support-router")).toBeInTheDocument();
+ expect(screen.queryByText("gpt-4o-mini", { selector: "span.text-sm.font-medium" })).not.toBeInTheDocument();
+ expect(screen.queryByText("anthropic-opus-4-6", { selector: "span.text-sm.font-medium" })).not.toBeInTheDocument();
+ });
+
+ it("labels Type by classifier rather than by router family", async () => {
+ renderPanel();
+
+ expect(await screen.findByText("Heuristic")).toBeInTheDocument();
+ expect(await screen.findByText("Semantic")).toBeInTheDocument();
+ });
+
+ // Reuses the models-page drill-in, so an auto router opens the full ModelInfoView with
+ // Model Settings and Edit Settings, not a parallel detail view that reimplements part of it.
+ it("opens the shared model detail view on row click", async () => {
+ const user = userEvent.setup();
+ renderPanel();
+
+ await user.click(await screen.findByRole("button", { name: "support-router" }));
+
+ expect(openModel).toHaveBeenCalledWith("auto-2");
+ });
+
+ it("opens the create form in a dialog and refetches the list after a create", async () => {
+ const user = userEvent.setup();
+ renderPanel();
+
+ await screen.findByText("tri-tier-router");
+ const callsBeforeCreate = modelInfoCall.mock.calls.length;
+
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: "Add Auto Router" }));
+
+ // A dialog, not a full-panel swap: the list stays mounted behind it.
+ const dialog = await screen.findByRole("dialog");
+ expect(dialog).toHaveTextContent("Add Auto Router");
+ expect(screen.getByText("tri-tier-router")).toBeInTheDocument();
+
+ await user.click(await screen.findByRole("button", { name: "Submit auto router" }));
+
+ // Back on the list, and the deployment query was invalidated so a new router shows up
+ // without a manual page reload.
+ expect(await screen.findByText("tri-tier-router")).toBeInTheDocument();
+ await waitFor(() => expect(modelInfoCall.mock.calls.length).toBeGreaterThan(callsBeforeCreate));
+ });
+
+ // The page decides who may write (proxy admin or team admin); the panel just has to make
+ // every write affordance absent when told no, rather than let a submit 403 later. Reading
+ // stays open: a read-only caller can still drill into the detail view.
+ it("shows the list but no write affordances when canModify is false", async () => {
+ renderPanel(false);
+
+ expect(await screen.findByText("tri-tier-router")).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Add Auto Router" })).not.toBeInTheDocument();
+ expect(screen.queryByTestId("auto-router-actions-auto-1")).not.toBeInTheDocument();
+ // Still navigable, because opening the detail view is a read.
+ expect(screen.getByRole("button", { name: "tri-tier-router" })).toBeInTheDocument();
+ });
+
+ // Auto-routers are hidden from Models + Endpoints, which used to be the only route to the
+ // delete action, so this tab is now the only place an auto router can be removed.
+ it("deletes the chosen router by its model id and refetches", async () => {
+ const user = userEvent.setup();
+ renderPanel();
+
+ await screen.findByText("support-router");
+ const callsBeforeDelete = modelInfoCall.mock.calls.length;
+
+ await user.click(screen.getByTestId("auto-router-actions-auto-2"));
+ await user.click(await screen.findByTestId("auto-router-action-delete"));
+ await user.click(await screen.findByRole("button", { name: /^delete$/i }));
+
+ await waitFor(() => expect(modelDeleteCall).toHaveBeenCalledWith("token", "auto-2"));
+ await waitFor(() => expect(modelInfoCall.mock.calls.length).toBeGreaterThan(callsBeforeDelete));
+ });
+
+ it("does not delete when the confirmation is dismissed", async () => {
+ const user = userEvent.setup();
+ renderPanel();
+
+ await screen.findByText("support-router");
+
+ await user.click(screen.getByTestId("auto-router-actions-auto-2"));
+ await user.click(await screen.findByTestId("auto-router-action-delete"));
+ await user.click(await screen.findByRole("button", { name: /cancel/i }));
+
+ expect(modelDeleteCall).not.toHaveBeenCalled();
+ });
+
+ it("gives a read-only caller no delete affordance", async () => {
+ renderPanel(false);
+
+ await screen.findByText("support-router");
+ expect(screen.queryByTestId("auto-router-actions-auto-2")).not.toBeInTheDocument();
+ });
+
+ it("renders an empty state when the proxy has models but no auto routers", async () => {
+ modelInfoCall.mockResolvedValue(
+ pageOf(DEPLOYMENTS.filter((d) => !d.litellm_params.model.startsWith("auto_router/"))),
+ );
+
+ renderPanel();
+
+ expect(await screen.findByText("No auto routers yet")).toBeInTheDocument();
+ });
+
+ it("keeps delete available on a DB-created adaptive router that has no editor", async () => {
+ const user = userEvent.setup();
+ renderPanel();
+
+ await screen.findByText("adaptive-router");
+ await user.click(screen.getByTestId("auto-router-actions-auto-3"));
+ await user.click(await screen.findByTestId("auto-router-action-delete"));
+ await user.click(await screen.findByRole("button", { name: /^delete$/i }));
+
+ await waitFor(() => expect(modelDeleteCall).toHaveBeenCalledWith("token", "auto-3"));
+ });
+
+ it("offers no delete on a config-defined router, which the API would refuse", async () => {
+ renderPanel();
+
+ await screen.findByText("config-router");
+ expect(screen.queryByTestId("auto-router-actions-auto-4")).not.toBeInTheDocument();
+ });
+});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.tsx
new file mode 100644
index 00000000000..3fbce1bd4a0
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.tsx
@@ -0,0 +1,117 @@
+"use client";
+
+import { Plus } from "lucide-react";
+import { useMemo, useState } from "react";
+
+import { useAutoRouters, useInvalidateAutoRouters } from "@/app/(dashboard)/hooks/models/useModels";
+import { useModelDetailRouting } from "@/app/(dashboard)/models-and-endpoints/detailNavigation";
+import AddAutoRouterTab from "@/components/add_model/add_auto_router_tab";
+import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
+import NotificationsManager from "@/components/molecules/notifications_manager";
+import { modelDeleteCall } from "@/components/networking";
+import { Button } from "@/components/ui/button";
+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+
+import { AutoRoutersTable } from "./AutoRoutersTable";
+import { AutoRouterRow, toAutoRouterRows } from "./autoRouterRows";
+
+interface AutoRoutersPanelProps {
+ accessToken: string;
+ userRole: string;
+ /** Owned by the page, which knows whether the caller may write. */
+ canModify: boolean;
+}
+
+export function AutoRoutersPanel({ accessToken, userRole, canModify }: AutoRoutersPanelProps) {
+ const { data: deployments, isLoading } = useAutoRouters();
+ const invalidateAutoRouters = useInvalidateAutoRouters();
+ // Clicking a router opens the same ?model= drill-in the All Models table uses, so an auto
+ // router gets the full ModelInfoView: Model Settings, Edit Settings, Edit Auto Router and
+ // Delete. A separate detail view here would be a worse copy of it.
+ const { openModel } = useModelDetailRouting();
+ const [isCreating, setIsCreating] = useState(false);
+ const [deletingRouter, setDeletingRouter] = useState(null);
+ const [isDeleting, setIsDeleting] = useState(false);
+
+ const routers = useMemo(() => toAutoRouterRows(deployments ?? []), [deployments]);
+
+ const handleCreated = () => {
+ setIsCreating(false);
+ void invalidateAutoRouters();
+ };
+
+ const handleConfirmDelete = async () => {
+ if (!deletingRouter) return;
+ setIsDeleting(true);
+ try {
+ await modelDeleteCall(accessToken, deletingRouter.id);
+ NotificationsManager.success(`Deleted auto router: ${deletingRouter.name}`);
+ setDeletingRouter(null);
+ await invalidateAutoRouters();
+ } catch (error) {
+ NotificationsManager.fromBackend(`Failed to delete auto router: ${error}`);
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ return (
+
+
+
+
Auto routers
+
+ Auto routers sit above your deployments and pick a model per request. They are called like any other model,
+ so clients keep using a single model name.
+
+
+ {canModify && (
+
setIsCreating(true)} className="shrink-0">
+
+ Add Auto Router
+
+ )}
+
+
+
openModel(row.id)}
+ onDeleteClick={setDeletingRouter}
+ />
+
+
+ {/* The form is long, so the dialog caps its height and scrolls its body rather than
+ growing past the viewport. */}
+
+
+ Add Auto Router
+
+ Routes each request to a model by classifying its complexity. Called like any other model, so clients keep
+ using a single model name.
+
+
+
+
+
+
+ {deletingRouter && (
+ setDeletingRouter(null)}
+ onOk={handleConfirmDelete}
+ confirmLoading={isDeleting}
+ />
+ )}
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersTable.tsx
new file mode 100644
index 00000000000..943388f8535
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersTable.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+import { SortingState } from "@tanstack/react-table";
+import { useMemo, useState } from "react";
+
+import { DataTable } from "@/components/shared/DataTable";
+import { AutoRouterIcon } from "@/components/shared/table_cells";
+
+import { getAutoRoutersTableColumns } from "./AutoRoutersTableColumns";
+import { AutoRouterRow } from "./autoRouterRows";
+
+interface AutoRoutersTableProps {
+ routers: AutoRouterRow[];
+ isLoading: boolean;
+ canModify: boolean;
+ onRouterClick: (row: AutoRouterRow) => void;
+ onDeleteClick: (row: AutoRouterRow) => void;
+}
+
+const PAGE_SIZE_OPTIONS = [10, 25, 50];
+
+function EmptyState({ canModify }: { canModify: boolean }) {
+ return (
+
+
+
No auto routers yet
+
+ {canModify
+ ? "Create an auto router to pick the right model per request instead of pinning one."
+ : "An auto router picks the right model per request instead of pinning one."}
+
+
+ );
+}
+
+export function AutoRoutersTable({
+ routers,
+ isLoading,
+ canModify,
+ onRouterClick,
+ onDeleteClick,
+}: AutoRoutersTableProps) {
+ const [sorting, setSorting] = useState([]);
+
+ const columns = useMemo(
+ () => getAutoRoutersTableColumns({ canModify, onRouterClick, onDeleteClick }),
+ [canModify, onRouterClick, onDeleteClick],
+ );
+
+ return (
+ router.id}
+ sortingMode="client"
+ sorting={sorting}
+ onSortingChange={setSorting}
+ paginationMode="client"
+ pageSizeOptions={PAGE_SIZE_OPTIONS}
+ isLoading={isLoading}
+ loadingMessage="Loading auto routers…"
+ noDataMessage={ }
+ size="compact"
+ />
+ );
+}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersTableColumns.tsx
new file mode 100644
index 00000000000..995ba634c34
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersTableColumns.tsx
@@ -0,0 +1,173 @@
+"use client";
+
+import { ColumnDef } from "@tanstack/react-table";
+import { useEffect, useMemo, useRef, useState } from "react";
+import { MoreHorizontal, Trash2 } from "lucide-react";
+
+import { DataTableSortHeader } from "@/components/shared/DataTable";
+import { DateCell, IdentityCell } from "@/components/shared/table_cells";
+import { Badge } from "@/components/ui/badge";
+import { buttonVariants } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { cn } from "@/lib/cva.config";
+
+import { AutoRouterRow } from "./autoRouterRows";
+import { fitPills } from "./fitPills";
+
+function TypeCell({ row }: { row: AutoRouterRow }) {
+ return (
+
+ {row.typeLabel}
+
+ );
+}
+
+function TargetsCell({ targets }: { targets: string[] }) {
+ const containerRef = useRef(null);
+ const [width, setWidth] = useState(0);
+
+ useEffect(() => {
+ const node = containerRef.current;
+ if (!node || typeof ResizeObserver === "undefined") return;
+ const observer = new ResizeObserver((entries) => {
+ const measured = entries[0]?.contentRect.width;
+ if (typeof measured === "number") setWidth(measured);
+ });
+ observer.observe(node);
+ return () => observer.disconnect();
+ }, []);
+
+ const { visible, overflow } = useMemo(() => fitPills(targets, width), [targets, width]);
+
+ if (targets.length === 0) {
+ return - ;
+ }
+
+ return (
+
+ {visible.map((target) => (
+
+ {target}
+
+ ))}
+ {overflow > 0 && (
+
+ +{overflow}
+
+ )}
+
+ );
+}
+
+function AutoRouterRowActions({
+ row,
+ onDeleteClick,
+}: {
+ row: AutoRouterRow;
+ onDeleteClick: (row: AutoRouterRow) => void;
+}) {
+ return (
+
+
+
+
+
+ onDeleteClick(row)}
+ >
+
+ Delete auto router
+
+
+
+ );
+}
+
+interface AutoRoutersTableColumnsDeps {
+ canModify: boolean;
+ onRouterClick: (row: AutoRouterRow) => void;
+ onDeleteClick: (row: AutoRouterRow) => void;
+}
+
+export const getAutoRoutersTableColumns = ({
+ canModify,
+ onRouterClick,
+ onDeleteClick,
+}: AutoRoutersTableColumnsDeps): ColumnDef[] => [
+ {
+ id: "name",
+ accessorKey: "name",
+ meta: { title: "Name" },
+ header: ({ column }) => ,
+ size: 260,
+ enableSorting: true,
+ cell: ({ row }) => onRouterClick(row.original)} />,
+ },
+ {
+ id: "kind",
+ accessorKey: "kind",
+ meta: { title: "Type" },
+ header: "Type",
+ size: 180,
+ enableSorting: false,
+ cell: ({ row }) => ,
+ },
+ {
+ id: "targets",
+ meta: { title: "Routes to" },
+ header: "Routes to",
+ size: 320,
+ enableSorting: false,
+ cell: ({ row }) => ,
+ },
+ {
+ id: "defaultModel",
+ accessorKey: "defaultModel",
+ meta: { title: "Default model" },
+ header: "Default model",
+ size: 200,
+ enableSorting: false,
+ cell: ({ row }) =>
+ row.original.defaultModel ? (
+
+ {row.original.defaultModel}
+
+ ) : (
+ -
+ ),
+ },
+ {
+ id: "createdAt",
+ accessorKey: "createdAt",
+ meta: { title: "Created" },
+ header: ({ column }) => ,
+ size: 150,
+ enableSorting: true,
+ sortingFn: "datetime",
+ cell: ({ row }) => ,
+ },
+ ...(canModify
+ ? [
+ {
+ id: "actions",
+ meta: { title: "" },
+ header: "",
+ size: 60,
+ enableSorting: false,
+ cell: ({ row }) =>
+ row.original.canDelete ? : null,
+ } satisfies ColumnDef,
+ ]
+ : []),
+];
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/autoRouterRows.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/autoRouterRows.test.ts
new file mode 100644
index 00000000000..924391d16fb
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/autoRouterRows.test.ts
@@ -0,0 +1,196 @@
+import { describe, expect, it } from "vitest";
+
+import { autoRouterStrategy, isComplexityRouter } from "@/components/add_model/auto_router_strategies";
+import { toAutoRouterRow, toAutoRouterRows } from "./autoRouterRows";
+
+const complexityDeployment = {
+ model_name: "tri-tier-router",
+ litellm_params: {
+ model: "auto_router/complexity_router",
+ complexity_router_config: {
+ tiers: {
+ SIMPLE: ["gpt-4o-mini"],
+ MEDIUM: ["anthropic-sonnet-4-6"],
+ COMPLEX: ["anthropic-opus-4-6", "gpt-4o-mini"],
+ REASONING: [],
+ },
+ classifier_type: "heuristic",
+ },
+ complexity_router_default_model: "gpt-4o-mini",
+ },
+ model_info: { id: "cid-1", db_model: true, created_at: "2026-07-28T21:40:09.900000+00:00" },
+};
+
+const semanticDeployment = {
+ model_name: "support-router",
+ litellm_params: {
+ model: "auto_router/support-router",
+ auto_router_config: JSON.stringify({
+ routes: [
+ { name: "gpt-4o-mini", utterances: ["reset my password"] },
+ { name: "anthropic-opus-4-6", utterances: ["design a distributed system"] },
+ ],
+ }),
+ auto_router_default_model: "gpt-4o-mini",
+ },
+ model_info: { id: "sid-1", db_model: true, created_at: "2026-07-27T10:00:00.000000+00:00" },
+};
+
+describe("autoRouterRows", () => {
+ it("classifies a complexity router and unions its tier models as targets", () => {
+ const row = toAutoRouterRow(complexityDeployment, 0);
+
+ expect(row.kind).toBe("complexity");
+ expect(row.typeLabel).toBe("Heuristic");
+ // Union across tiers, de-duplicated: gpt-4o-mini appears in both SIMPLE and COMPLEX.
+ expect(row.targets).toEqual(["gpt-4o-mini", "anthropic-sonnet-4-6", "anthropic-opus-4-6"]);
+ expect(row.defaultModel).toBe("gpt-4o-mini");
+ expect(row.id).toBe("cid-1");
+ });
+
+ it("parses a semantic router whose config arrives as a JSON string", () => {
+ const row = toAutoRouterRow(semanticDeployment, 0);
+
+ expect(row.kind).toBe("semantic");
+ expect(row.typeLabel).toBe("Semantic");
+ expect(row.targets).toEqual(["gpt-4o-mini", "anthropic-opus-4-6"]);
+ expect(row.defaultModel).toBe("gpt-4o-mini");
+ });
+
+ it("shows a tier pinned as a bare string, which the backend accepts as `str | list[str]`", () => {
+ const row = toAutoRouterRow(
+ {
+ ...complexityDeployment,
+ litellm_params: {
+ ...complexityDeployment.litellm_params,
+ complexity_router_config: {
+ tiers: { SIMPLE: "gpt-4o-mini", MEDIUM: ["anthropic-sonnet-4-6"], COMPLEX: "", REASONING: [] },
+ classifier_type: "heuristic",
+ },
+ },
+ },
+ 0,
+ );
+
+ expect(row.targets).toEqual(["gpt-4o-mini", "anthropic-sonnet-4-6"]);
+ });
+
+ it("labels a router using the LLM classifier", () => {
+ const row = toAutoRouterRow(
+ {
+ ...complexityDeployment,
+ litellm_params: {
+ ...complexityDeployment.litellm_params,
+ complexity_router_config: { tiers: {}, classifier_type: "llm", adaptive: true },
+ },
+ },
+ 0,
+ );
+
+ expect(row.typeLabel).toBe("LLM Classifier");
+ });
+
+ it("treats a deployment carrying complexity_router_config as complexity even off the canonical model string", () => {
+ expect(isComplexityRouter({ model: "auto_router/legacy", complexity_router_config: { tiers: {} } })).toBe(true);
+ });
+
+ it("survives an unparseable config instead of throwing", () => {
+ const row = toAutoRouterRow(
+ {
+ model_name: "broken",
+ litellm_params: { model: "auto_router/broken", auto_router_config: "{not json" },
+ model_info: { id: "bid-1" },
+ },
+ 0,
+ );
+
+ expect(row.kind).toBe("semantic");
+ expect(row.targets).toEqual([]);
+ });
+
+ it("falls back to a stable synthetic id when the deployment has no model_info id", () => {
+ const rows = toAutoRouterRows([
+ { model_name: "a", litellm_params: { model: "auto_router/a" } },
+ { model_name: "b", litellm_params: { model: "auto_router/b" } },
+ ]);
+
+ expect(rows.map((row) => row.id)).toEqual(["a-0", "b-1"]);
+ });
+ // Regression: adaptive and quality routers used to fall through to the semantic branch,
+ // which read the wrong config key and reported an empty route list and a null default.
+ it("classifies an adaptive router as adaptive, not semantic", () => {
+ const row = toAutoRouterRow(
+ {
+ model_name: "smart-router",
+ litellm_params: {
+ model: "auto_router/adaptive_router",
+ adaptive_router_default_model: "gpt-4o-mini",
+ adaptive_router_config: { available_models: ["gpt-4o", "gpt-4o-mini"] },
+ },
+ model_info: { id: "ad-1" },
+ },
+ 0,
+ );
+
+ expect(row.kind).toBe("adaptive");
+ expect(row.typeLabel).toBe("Adaptive");
+ expect(row.targets).toEqual(["gpt-4o", "gpt-4o-mini"]);
+ expect(row.defaultModel).toBe("gpt-4o-mini");
+ });
+
+ it("classifies a quality router as quality, not semantic", () => {
+ const row = toAutoRouterRow(
+ {
+ model_name: "quality-router",
+ litellm_params: {
+ model: "auto_router/quality_router",
+ quality_router_default_model: "gpt-4o",
+ quality_router_config: { available_models: ["gpt-4o"] },
+ },
+ model_info: { id: "q-1" },
+ },
+ 0,
+ );
+
+ expect(row.kind).toBe("quality");
+ expect(row.typeLabel).toBe("Quality");
+ expect(row.targets).toEqual(["gpt-4o"]);
+ });
+
+ it("mirrors the backend prefix ordering, so a named strategy never reads as semantic", () => {
+ const kindOf = (model: string) => autoRouterStrategy({ model }).kind;
+ expect(kindOf("auto_router/complexity_router")).toBe("complexity");
+ expect(kindOf("auto_router/adaptive_router")).toBe("adaptive");
+ expect(kindOf("auto_router/quality_router")).toBe("quality");
+ expect(kindOf("auto_router/my-own-router")).toBe("semantic");
+ });
+
+ // The capability matrix. Origin and strategy constrain DIFFERENT capabilities, and
+ // collapsing them into one "editable" flag is what stranded DB-created adaptive routers
+ // with no delete control. Live-verified: for a config row PATCH /model/{id}/update 404s
+ // and POST /model/delete 400s.
+ const rowFor = (model: string, dbModel: boolean) =>
+ toAutoRouterRow({ model_name: "r", litellm_params: { model }, model_info: { id: "x", db_model: dbModel } }, 0);
+
+ it.each([
+ { model: "auto_router/complexity_router", db: true, canEdit: true, canDelete: true, reason: null },
+ { model: "auto_router/my-semantic", db: true, canEdit: true, canDelete: true, reason: null },
+ // No editor for its shape, but deleting never reads the config, so delete stays.
+ { model: "auto_router/adaptive_router", db: true, canEdit: false, canDelete: true, reason: "no-editor" },
+ { model: "auto_router/quality_router", db: true, canEdit: false, canDelete: true, reason: "no-editor" },
+ // config.yaml rows: the API refuses both, whatever the strategy.
+ { model: "auto_router/complexity_router", db: false, canEdit: false, canDelete: false, reason: "config-managed" },
+ { model: "auto_router/adaptive_router", db: false, canEdit: false, canDelete: false, reason: "config-managed" },
+ ])("$model (db_model=$db) -> canEdit=$canEdit canDelete=$canDelete", (spec) => {
+ const row = rowFor(spec.model, spec.db);
+ expect(row.canEdit).toBe(spec.canEdit);
+ expect(row.canDelete).toBe(spec.canDelete);
+ expect(row.editBlockedReason).toBe(spec.reason);
+ });
+
+ it("treats a missing db_model as config-defined rather than assuming it is writable", () => {
+ const row = toAutoRouterRow({ ...complexityDeployment, model_info: { id: "unknown-1" } }, 0);
+ expect(row.canEdit).toBe(false);
+ expect(row.canDelete).toBe(false);
+ });
+});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/autoRouterRows.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/autoRouterRows.ts
new file mode 100644
index 00000000000..4817711f469
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/autoRouterRows.ts
@@ -0,0 +1,103 @@
+import { AutoRouterDeployment } from "@/app/(dashboard)/hooks/models/useModels";
+import {
+ AutoRouterKind,
+ EditBlockedReason,
+ autoRouterCapabilities,
+ autoRouterStrategy,
+} from "@/components/add_model/auto_router_strategies";
+import { normalizeTierModels } from "@/components/add_model/complexity_router_tiers";
+
+export type { AutoRouterKind };
+
+export interface AutoRouterRow {
+ id: string;
+ name: string;
+ kind: AutoRouterKind;
+ typeLabel: string;
+ /** Edit needs an API-created row AND a strategy the dashboard has a form for. */
+ canEdit: boolean;
+ /** Delete only needs an API-created row; removing by id never reads the config. */
+ canDelete: boolean;
+ editBlockedReason: EditBlockedReason | null;
+ targets: string[];
+ defaultModel: string | null;
+ createdAt: string | null;
+ deployment: AutoRouterDeployment;
+}
+
+const safeParse = (value: string): unknown => {
+ try {
+ return JSON.parse(value);
+ } catch {
+ return null;
+ }
+};
+
+const asRecord = (value: unknown): Record => {
+ const parsed: unknown = typeof value === "string" ? safeParse(value) : value;
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
+ ? (parsed as Record)
+ : {};
+};
+
+const asStringArray = (value: unknown): string[] =>
+ Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
+
+const dedupe = (models: string[]): string[] => Array.from(new Set(models));
+
+export const complexityTypeLabel = (config: Record): string =>
+ config.classifier_type === "llm" ? "LLM Classifier" : "Heuristic";
+
+interface Presentation {
+ typeLabel: string;
+ targets: string[];
+}
+
+// Adaptive and quality both declare a flat pool and have no editor here, so the row reports
+// what is configured rather than interpreting it.
+const configManaged = (label: string, config: Record): Presentation => ({
+ typeLabel: label,
+ targets: asStringArray(config.available_models),
+});
+
+/** How each strategy renders itself, given its own config object. */
+const PRESENTERS: Record) => Presentation> = {
+ complexity: (config) => ({
+ typeLabel: complexityTypeLabel(config),
+ targets: dedupe(Object.values(asRecord(config.tiers)).flatMap(normalizeTierModels)),
+ }),
+ semantic: (config) => {
+ const routes = dedupe(
+ (Array.isArray(config.routes) ? config.routes : [])
+ .map((route) => asRecord(route).name)
+ .filter((name): name is string => typeof name === "string" && name.length > 0),
+ );
+ return { typeLabel: "Semantic", targets: routes };
+ },
+ adaptive: (config) => configManaged("Adaptive", config),
+ quality: (config) => configManaged("Quality", config),
+};
+
+export const toAutoRouterRow = (deployment: AutoRouterDeployment, index: number): AutoRouterRow => {
+ const params = deployment.litellm_params ?? {};
+ const info = deployment.model_info ?? {};
+ const name = deployment.model_name ?? "";
+ const strategy = autoRouterStrategy(params);
+ const { canEdit, canDelete, editBlockedReason } = autoRouterCapabilities(params, info);
+
+ return {
+ id: info.id ?? `${name}-${index}`,
+ name,
+ kind: strategy.kind,
+ canEdit,
+ canDelete,
+ editBlockedReason,
+ createdAt: info.created_at ?? null,
+ defaultModel: (params[strategy.defaultModelKey] as string | null | undefined) ?? null,
+ deployment,
+ ...PRESENTERS[strategy.kind](asRecord(params[strategy.configKey])),
+ };
+};
+
+export const toAutoRouterRows = (deployments: AutoRouterDeployment[]): AutoRouterRow[] =>
+ deployments.map(toAutoRouterRow);
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/fitPills.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/fitPills.test.ts
new file mode 100644
index 00000000000..9d702810c35
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/fitPills.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from "vitest";
+
+import { fitPills, pillWidth } from "./fitPills";
+
+const TARGETS = ["anthropic-sonnet-4-6", "gpt-4o-mini", "anthropic-opus-4-6", "voyage-4-large"];
+
+describe("fitPills", () => {
+ it("keeps everything on one row when it all fits", () => {
+ const wide = TARGETS.reduce((total, label) => total + pillWidth(label) + 4, 0) + 40;
+ expect(fitPills(TARGETS, wide)).toEqual({ visible: TARGETS, overflow: 0 });
+ });
+
+ it("shows more pills as the column gets wider", () => {
+ const narrow = fitPills(TARGETS, 200);
+ const wider = fitPills(TARGETS, 420);
+
+ expect(narrow.visible.length).toBeLessThan(wider.visible.length);
+ expect(narrow.visible.length + narrow.overflow).toBe(TARGETS.length);
+ expect(wider.visible.length + wider.overflow).toBe(TARGETS.length);
+ });
+
+ it("reserves room for the +N counter so the row never overflows", () => {
+ const { visible } = fitPills(TARGETS, 220);
+ const used = visible.reduce((total, label, index) => total + pillWidth(label) + (index === 0 ? 0 : 4), 0);
+ // 28px counter + its 4px gap must still fit alongside the visible pills.
+ expect(used + 32).toBeLessThanOrEqual(220);
+ });
+
+ it("always shows at least one pill, even when a single name is wider than the column", () => {
+ expect(fitPills(["an-extremely-long-deployment-name-that-never-fits"], 40)).toEqual({
+ visible: ["an-extremely-long-deployment-name-that-never-fits"],
+ overflow: 0,
+ });
+ });
+
+ it("shows one pill before the first measurement rather than flashing every pill", () => {
+ expect(fitPills(TARGETS, 0)).toEqual({ visible: [TARGETS[0]], overflow: 3 });
+ });
+
+ it("handles an empty target list", () => {
+ expect(fitPills([], 300)).toEqual({ visible: [], overflow: 0 });
+ });
+});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/fitPills.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/fitPills.ts
new file mode 100644
index 00000000000..fba116af0fd
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/fitPills.ts
@@ -0,0 +1,52 @@
+/**
+ * How many pills fit on ONE row of a given width, leaving room for a "+N" counter.
+ *
+ * jsdom reports no layout and the shared ResizeObserver mock only fires inside chart
+ * subtrees, so this stays a pure width-in / count-out function: the component measures and
+ * this decides, which keeps the overflow rule unit-testable.
+ */
+
+const CHAR_WIDTH = 6.5;
+const PILL_PADDING = 18;
+const PILL_GAP = 4;
+const OVERFLOW_WIDTH = 28;
+
+export const pillWidth = (label: string): number => label.length * CHAR_WIDTH + PILL_PADDING;
+
+export interface FittedPills {
+ visible: string[];
+ overflow: number;
+}
+
+export const fitPills = (labels: string[], availableWidth: number): FittedPills => {
+ if (labels.length === 0) return { visible: [], overflow: 0 };
+
+ // Unmeasured (0 or negative) means the first paint before ResizeObserver reports. Show one
+ // pill rather than all of them, so the row never flashes multi-line and then collapses.
+ if (availableWidth <= 0) {
+ return { visible: labels.slice(0, 1), overflow: labels.length - 1 };
+ }
+
+ const fitted: string[] = [];
+ let used = 0;
+
+ for (const [index, label] of labels.entries()) {
+ const remaining = labels.length - index - 1;
+ const gap = fitted.length === 0 ? 0 : PILL_GAP;
+ // Anything still queued after this pill needs room for the "+N" counter beside it.
+ const reserve = remaining > 0 ? PILL_GAP + OVERFLOW_WIDTH : 0;
+
+ if (used + gap + pillWidth(label) + reserve > availableWidth) break;
+
+ used += gap + pillWidth(label);
+ fitted.push(label);
+ }
+
+ // Always show at least one pill; a single over-long name truncates via CSS instead of
+ // collapsing the cell to a bare "+N".
+ if (fitted.length === 0) {
+ return { visible: labels.slice(0, 1), overflow: labels.length - 1 };
+ }
+
+ return { visible: fitted, overflow: labels.length - fitted.length };
+};
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx
index b9dd09d5a71..222a2e04117 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx
@@ -7,6 +7,7 @@ import ModelsAndEndpointsPage from "./page";
vi.mock("./panels/AllModelsPanel", () => ({ default: () =>
}));
vi.mock("./panels/AddModelPanel", () => ({ default: () =>
}));
+vi.mock("./panels/AutoRoutersTabPanel", () => ({ default: () =>
}));
vi.mock("./panels/LlmCredentialsPanel", () => ({ default: () =>
}));
vi.mock("./panels/PassThroughPanel", () => ({ default: () =>
}));
vi.mock("./panels/HealthStatusPanel", () => ({ default: () =>
}));
@@ -97,4 +98,34 @@ describe("ModelsAndEndpointsPage", () => {
expect(queryByRole("tab", { name: "LLM Credentials" })).toBeNull();
expect(queryByRole("tab", { name: "Health Status" })).toBeNull();
});
+
+ // Auto-routers are excluded from the All Models table, so this tab is their home: the only
+ // place in the product to list, create, edit or delete one.
+ describe("Auto-Routers tab", () => {
+ it("sits third, after All Models and Add Model", () => {
+ const { getAllByRole } = renderPage();
+
+ const tabs = getAllByRole("tab").map((tab) => tab.textContent);
+ expect(tabs[0]).toContain("All Models");
+ expect(tabs[1]).toBe("Add Model");
+ expect(tabs[2]).toContain("Auto-Routers");
+ // Badged Beta while the tab settles; BetaBadge renders the label text.
+ expect(tabs[2]).toContain("Beta");
+ });
+
+ it("renders its panel when selected", async () => {
+ const user = userEvent.setup();
+ const { getByRole, getByTestId } = renderPage();
+
+ await user.click(getByRole("tab", { name: /Auto-Routers/ }));
+ expect(getByTestId("panel-auto-routers")).toBeInTheDocument();
+ });
+
+ it("is hidden from non-admins, who cannot write models", () => {
+ mockUseAuthorized.mockReturnValue(NON_ADMIN);
+ const { queryByRole } = renderPage();
+
+ expect(queryByRole("tab", { name: /Auto-Routers/ })).toBeNull();
+ });
+ });
});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx
index cb173367459..b7b16caf2ad 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx
@@ -8,12 +8,14 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import { all_admin_roles, internalUserRoles, isProxyAdminRole, isUserTeamAdminForAnyTeam } from "@/utils/roles";
+import BetaBadge from "@/components/BetaBadge";
import CostOptimizationFeedbackBanner from "@/components/molecules/cost_optimization_feedback_banner";
import ModelInfoView from "@/components/model_info_view";
import TeamInfoView from "@/components/team/TeamInfo";
import { useModelDetailRouting } from "@/app/(dashboard)/models-and-endpoints/detailNavigation";
import { useModelDashboardData } from "@/app/(dashboard)/models-and-endpoints/useModelDashboardData";
import AllModelsPanel from "@/app/(dashboard)/models-and-endpoints/panels/AllModelsPanel";
+import AutoRoutersTabPanel from "@/app/(dashboard)/models-and-endpoints/panels/AutoRoutersTabPanel";
import AddModelPanel from "@/app/(dashboard)/models-and-endpoints/panels/AddModelPanel";
import LlmCredentialsPanel from "@/app/(dashboard)/models-and-endpoints/panels/LlmCredentialsPanel";
import PassThroughPanel from "@/app/(dashboard)/models-and-endpoints/panels/PassThroughPanel";
@@ -24,6 +26,7 @@ import PriceDataPanel from "@/app/(dashboard)/models-and-endpoints/panels/PriceD
type ModelTabSlug =
| "add"
+ | "auto-routers"
| "llm-credentials"
| "pass-through"
| "health"
@@ -35,6 +38,7 @@ const BASE_TAB_KEY = "all-models";
const TAB_LABELS: Record = {
add: "Add Model",
+ "auto-routers": "Auto-Routers",
"llm-credentials": "LLM Credentials",
"pass-through": "Pass-Through Endpoints",
health: "Health Status",
@@ -47,6 +51,8 @@ const renderPanel = (key: string) => {
switch (key) {
case BASE_TAB_KEY:
return ;
+ case "auto-routers":
+ return ;
case "add":
return ;
case "llm-credentials":
@@ -89,6 +95,7 @@ export default function ModelsAndEndpointsPage() {
() => [
"",
...(shouldHideAddModelTab ? [] : (["add"] as const)),
+ ...(isAdmin ? (["auto-routers"] as const) : []),
...(isAdmin
? (["llm-credentials", "pass-through", "health", "retry-settings", "model-group-alias", "price-data"] as const)
: []),
@@ -97,11 +104,24 @@ export default function ModelsAndEndpointsPage() {
);
const allModelsLabel = isAdmin ? "All Models" : "Your Models";
+ // Auto-Routers carries a Beta badge; BetaBadge honours the admin setting that hides these.
+ const tabLabel = (slug: "" | ModelTabSlug): React.ReactNode => {
+ if (!slug) return allModelsLabel;
+ if (slug === "auto-routers") {
+ return (
+
+ {TAB_LABELS[slug]}
+
+ );
+ }
+ return TAB_LABELS[slug];
+ };
+
const tabItems = visibleSlugs.map((slug) => {
const key = slug || BASE_TAB_KEY;
return {
key,
- label: slug ? TAB_LABELS[slug] : allModelsLabel,
+ label: tabLabel(slug),
children: key === activeKey ? renderPanel(key) : null,
};
});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx
index 26dcc60d717..4d7bcc921af 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx
@@ -3,7 +3,7 @@
import { Form } from "antd";
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
-import AddModelTab from "@/components/add_model/add_model_tab";
+import AddModelForm from "@/components/add_model/AddModelForm";
import { handleAddModelSubmit } from "@/components/add_model/handle_add_model_submit";
import { Providers, getPlaceholder, getProviderModels } from "@/components/provider_info_helpers";
import NotificationsManager from "@/components/molecules/notifications_manager";
@@ -14,7 +14,7 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { vertexCredentialsUploadProps } from "@/app/(dashboard)/models-and-endpoints/vertexCredentialsUpload";
export default function AddModelPanel() {
- const { accessToken, userRole } = useAuthorized();
+ const { accessToken } = useAuthorized();
const [form] = Form.useForm();
const queryClient = useQueryClient();
const { data: modelCostMapData } = useModelCostMap();
@@ -39,7 +39,7 @@ export default function AddModelPanel() {
};
return (
-
);
}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AutoRoutersTabPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AutoRoutersTabPanel.tsx
new file mode 100644
index 00000000000..8b620e22bf9
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AutoRoutersTabPanel.tsx
@@ -0,0 +1,23 @@
+"use client";
+
+import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
+import { isProxyAdminRole } from "@/utils/roles";
+
+import { AutoRoutersPanel } from "../components/AutoRouters/AutoRoutersPanel";
+
+/**
+ * Owns the permission decision for the Auto-Routers tab so the panel stays a renderer.
+ * Creating or editing an auto router is a POST /model/new or PATCH /model/{id}/update, both
+ * proxy-admin gated, so viewer roles read the list without write affordances.
+ */
+export default function AutoRoutersTabPanel() {
+ const { accessToken, userRole } = useAuthorized();
+
+ return (
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.test.tsx
index c4cfa98b2a1..9ffcbfc9975 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.test.tsx
@@ -13,7 +13,6 @@ vi.mock("@/components/networking", () => ({
vi.mock("@/components/router_settings", () => ({ default: () => null }));
vi.mock("@/components/Settings/RouterSettings/Fallbacks/Fallbacks", () => ({ default: () => null }));
vi.mock("@/components/routing_groups", () => ({ default: () => null }));
-
// Mirrors the /config/list ordering: the two prompt-caching rows sit between the
// General-tab rows in the unfiltered response but are filtered out of the General
// tab's table, so any index-based lookup into the unfiltered array reads the wrong
@@ -99,3 +98,19 @@ describe("GeneralSettings General tab", () => {
expect(within(row).getByRole("spinbutton")).toHaveValue("1.00");
});
});
+
+// The five tabs here are proxy-wide settings. Auto-routers moved to Models + Endpoints.
+describe("GeneralSettings tabs", () => {
+ beforeEach(() => {
+ vi.mocked(getGeneralSettingsCall).mockResolvedValue([]);
+ });
+
+ it("renders the proxy-wide tabs and no auto-router tab", async () => {
+ renderWithProviders( );
+
+ for (const name of ["Loadbalancing", "Routing Groups", "Fallbacks", "Prompt Caching", "General"]) {
+ expect(await screen.findByRole("tab", { name })).toBeInTheDocument();
+ }
+ expect(screen.queryByRole("tab", { name: /auto.?router/i })).not.toBeInTheDocument();
+ });
+});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx
index fa3447e0cbf..ed7b17067d5 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx
@@ -13,7 +13,7 @@ import {
Icon,
Switch,
} from "@tremor/react";
-import { TabPanel, TabPanels, TabGroup, TabList, Tab } from "@tremor/react";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { getGeneralSettingsCall, updateConfigFieldSetting, deleteConfigFieldSetting } from "@/components/networking";
import { InputNumber, Select as AntdSelect } from "antd";
import { TrashIcon } from "@heroicons/react/outline";
@@ -232,82 +232,80 @@ const GeneralSettings: React.FC = ({ accessToken, user
return (
-
-
- Loadbalancing
- Routing Groups
- Fallbacks
- Prompt Caching
- General
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Setting
- Value
- Status
- Action
-
-
-
- {generalSettings
- .filter((value) => value.field_type !== "TypedDictionary" && value.field_tab !== PROMPT_CACHING_TAB)
- .map((value, index) => (
-
-
- {value.field_name}
-
- {value.field_description}
-
-
-
-
-
-
- {value.stored_in_db == true ? (
-
- ) : value.stored_in_db == false ? (
-
- ) : (
-
- )}
-
-
- handleUpdateField(value.field_name)}>Update
- handleResetField(value.field_name)}>
- Reset
-
-
-
- ))}
-
-
-
-
-
-
+
+
+ Loadbalancing
+ Routing Groups
+ Fallbacks
+ Prompt Caching
+ General
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Setting
+ Value
+ Status
+ Action
+
+
+
+ {generalSettings
+ .filter((value) => value.field_type !== "TypedDictionary" && value.field_tab !== PROMPT_CACHING_TAB)
+ .map((value, index) => (
+
+
+ {value.field_name}
+
+ {value.field_description}
+
+
+
+
+
+
+ {value.stored_in_db == true ? (
+
+ ) : value.stored_in_db == false ? (
+
+ ) : (
+
+ )}
+
+
+ handleUpdateField(value.field_name)}>Update
+ handleResetField(value.field_name)}>
+ Reset
+
+
+
+ ))}
+
+
+
+
+
);
};
diff --git a/ui/litellm-dashboard/src/components/add_model/AddModelForm.test.tsx b/ui/litellm-dashboard/src/components/add_model/AddModelForm.test.tsx
index b4c22a834a4..040ea2cb440 100644
--- a/ui/litellm-dashboard/src/components/add_model/AddModelForm.test.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/AddModelForm.test.tsx
@@ -297,4 +297,17 @@ describe("AddModelForm", () => {
expect(screen.queryByRole("switch")).not.toBeInTheDocument();
});
+
+ it("should display the provider field and the Test Connect / Add Model buttons", async () => {
+ const mockUseAuthorized = vi.mocked(await import("@/app/(dashboard)/hooks/useAuthorized"));
+ mockUseAuthorized.default.mockReturnValue(mockAuthorizedUser("proxy_admin", "user-1", true));
+
+ const props = createTestProps();
+
+ renderWithProviders( );
+
+ expect(await screen.findByText("Provider")).toBeInTheDocument();
+ expect((await screen.findAllByRole("button", { name: "Test Connect" })).length).toBeGreaterThan(0);
+ expect(await screen.findByRole("button", { name: "Add Model" })).toBeInTheDocument();
+ });
});
diff --git a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx
index 4713f8c6869..3328c1703a8 100644
--- a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx
@@ -1,7 +1,6 @@
import { renderWithProviders, screen } from "../../../tests/test-utils";
import userEvent from "@testing-library/user-event";
import { vi } from "vitest";
-import { Form } from "antd";
import AddAutoRouterTab from "./add_auto_router_tab";
import NotificationManager from "../molecules/notifications_manager";
@@ -21,10 +20,7 @@ vi.mock("../molecules/notifications_manager", () => ({
default: { fromBackend: vi.fn() },
}));
-const Harness = () => {
- const [form] = Form.useForm();
- return ;
-};
+const Harness = () => ;
describe("AddAutoRouterTab", () => {
it("flags every mandatory field when Add Auto Router is clicked with nothing filled", async () => {
diff --git a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx
index e7826e09dce..6e946bf9106 100644
--- a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx
@@ -1,13 +1,10 @@
import React, { useEffect, useState } from "react";
-import { Card, Form, Button, Tooltip, Typography, Select as AntdSelect, Radio, Badge, Space, Modal } from "antd";
-import type { FormInstance } from "antd";
-import { ThunderboltOutlined, BranchesOutlined } from "@ant-design/icons";
-import { Text, TextInput } from "@tremor/react";
+import { Card, Form, Button, Tooltip, Typography, Select as AntdSelect, Modal } from "antd";
+import { TextInput } from "@tremor/react";
import { modelAvailableCall } from "../networking";
import { all_admin_roles } from "@/utils/roles";
import { handleAddAutoRouterSubmit } from "./handle_add_auto_router_submit";
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
-import RouterConfigBuilder from "./RouterConfigBuilder";
import ComplexityRouterConfig, {
ComplexityRouterConfigValue,
DEFAULT_ADAPTIVE_WEIGHTS,
@@ -22,27 +19,22 @@ import {
getSemanticConfigError,
} from "./build_complexity_router_config";
import { buildAutoRouterTestTargets, AutoRouterTestTarget } from "./build_auto_router_test_targets";
-import { getSemanticRouterError } from "./build_semantic_router_validation";
import AutoRouterConnectionTest from "./auto_router_connection_test";
import NotificationManager from "../molecules/notifications_manager";
interface AddAutoRouterTabProps {
- form: FormInstance;
handleOk: () => void;
accessToken: string;
userRole: string;
}
-type RouterType = "recommended" | "semantic";
-
const { Title } = Typography;
-const AddAutoRouterTab: React.FC = ({ form, handleOk, accessToken, userRole }) => {
+const AddAutoRouterTab: React.FC = ({ handleOk, accessToken, userRole }) => {
+ const [form] = Form.useForm();
const [modelAccessGroups, setModelAccessGroups] = useState([]);
const [modelInfo, setModelInfo] = useState([]);
- const [routerType, setRouterType] = useState("recommended");
-
const [complexityRouterConfig, setComplexityRouterConfig] = useState({
tiers: { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] },
classifier_type: "heuristic",
@@ -56,9 +48,6 @@ const AddAutoRouterTab: React.FC = ({ form, handleOk, acc
const [escalationKeywords, setEscalationKeywords] = useState(DEFAULT_ESCALATION_KEYWORDS);
const [showValidationErrors, setShowValidationErrors] = useState(false);
- // Semantic router config (existing)
- const [routerConfig, setRouterConfig] = useState(null);
-
const [isTestModalVisible, setIsTestModalVisible] = useState(false);
const [isTestingConnection, setIsTestingConnection] = useState(false);
const [connectionTestId, setConnectionTestId] = useState(0);
@@ -169,40 +158,6 @@ const AddAutoRouterTab: React.FC = ({ form, handleOk, acc
});
};
- const submitSemanticRouter = (name: string) => {
- const validationError = getSemanticRouterError({
- defaultModel: form.getFieldValue("auto_router_default_model"),
- embeddingModel: form.getFieldValue("auto_router_embedding_model"),
- routerConfig,
- });
- if (validationError) {
- NotificationManager.fromBackend(validationError);
- return;
- }
-
- form.setFieldsValue({
- custom_llm_provider: "auto_router",
- model: name,
- api_key: "not_required_for_auto_router",
- });
-
- form
- .validateFields()
- .then((values) => {
- const submitValues = {
- ...values,
- auto_router_name: name,
- auto_router_config: routerConfig,
- model_type: "semantic_router",
- };
- handleAddAutoRouterSubmit(submitValues, accessToken, form, handleOk);
- })
- .catch((error) => {
- console.error("Validation failed:", error);
- NotificationManager.fromBackend("Please fill in all required fields");
- });
- };
-
const handleAutoRouterSubmit = () => {
const name = form.getFieldValue("auto_router_name");
if (!name) {
@@ -212,11 +167,7 @@ const AddAutoRouterTab: React.FC = ({ form, handleOk, acc
return;
}
- if (routerType === "recommended") {
- submitRecommendedRouter(name);
- } else {
- submitSemanticRouter(name);
- }
+ submitRecommendedRouter(name);
};
const handleTestConnection = () => {
@@ -239,53 +190,6 @@ const AddAutoRouterTab: React.FC = ({ form, handleOk, acc
return (
<>
- Add Auto Router
-
- Create an auto router that automatically selects the best model based on request complexity or semantic
- matching. Use in place of a single default model.
-
-
-
-
-
Router Type
-
{
- setRouterType(e.target.value);
- setShowValidationErrors(false);
- }}
- className="w-full"
- >
-
-
-
-
- Auto-Router v2
-
-
-
- Routes by request complexity across four tiers, with optional keyword-to-tier overrides and semantic
- keyword matching. No training data needed.
-
-
-
-
-
- Semantic Router [to be deprecated]
-
-
- Routes based on semantic similarity to example utterances. Requires an embedding model and example
- utterances.
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
- )}
+
+
+
@@ -408,7 +265,7 @@ const AddAutoRouterTab: React.FC
= ({ form, handleOk, acc
Need Help?
- {routerType === "recommended" && (
+ {
= ({ form, handleOk, acc
>
Test Connection
- )}
+ }
{
diff --git a/ui/litellm-dashboard/src/components/add_model/add_model_tab.test.tsx b/ui/litellm-dashboard/src/components/add_model/add_model_tab.test.tsx
deleted file mode 100644
index 816cd1bf97b..00000000000
--- a/ui/litellm-dashboard/src/components/add_model/add_model_tab.test.tsx
+++ /dev/null
@@ -1,318 +0,0 @@
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { render, renderHook, screen, waitFor, within } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { Form } from "antd";
-import type { UploadProps } from "antd/es/upload";
-import { describe, expect, it, vi } from "vitest";
-import type { Team } from "../key_team_helpers/key_list";
-import type { CredentialItem } from "../networking";
-import { Providers } from "../provider_info_helpers";
-import AddModelTab from "./add_model_tab";
-
-vi.mock("../molecules/models/ProviderLogo", () => ({
- ProviderLogo: ({ provider, className }: { provider: string; className?: string }) => (
-
- {provider}
-
- ),
-}));
-
-vi.mock("../networking", async () => {
- const actual = await vi.importActual("../networking");
- return {
- ...actual,
- getGuardrailsList: vi.fn().mockResolvedValue({
- guardrails: [{ guardrail_name: "test-guardrail-1" }, { guardrail_name: "test-guardrail-2" }],
- }),
- tagListCall: vi.fn().mockResolvedValue({}),
- modelAvailableCall: vi.fn().mockResolvedValue({
- data: [{ id: "model-group-1" }, { id: "model-group-2" }],
- }),
- modelHubCall: vi.fn().mockResolvedValue({
- data: [
- { model_group: "gpt-4", mode: "chat" },
- { model_group: "gpt-3.5-turbo", mode: "chat" },
- ],
- }),
- getProviderCreateMetadata: vi.fn().mockResolvedValue([
- {
- provider: "OpenAI",
- provider_display_name: "OpenAI",
- litellm_provider: "openai",
- default_model_placeholder: "gpt-3.5-turbo",
- credential_fields: [],
- },
- ]),
- };
-});
-
-vi.mock("@/app/(dashboard)/hooks/providers/useProviderFields", () => ({
- useProviderFields: vi.fn().mockReturnValue({
- data: [
- {
- provider: "OpenAI",
- provider_display_name: "OpenAI",
- litellm_provider: "openai",
- default_model_placeholder: "gpt-3.5-turbo",
- credential_fields: [],
- },
- ],
- isLoading: false,
- error: null,
- }),
-}));
-
-vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
- default: vi.fn().mockReturnValue({
- accessToken: "test-access-token",
- userRole: "Admin",
- premiumUser: true,
- }),
-}));
-
-const createQueryClient = () =>
- new QueryClient({
- defaultOptions: {
- queries: {
- retry: false,
- staleTime: Infinity,
- gcTime: Infinity,
- refetchOnWindowFocus: false,
- refetchOnReconnect: false,
- refetchOnMount: false,
- },
- },
- });
-
-const createTestProps = () => {
- const { result } = renderHook(() => Form.useForm());
- const [form] = result.current;
-
- const handleOk = vi.fn();
- const setSelectedProvider = vi.fn();
- const setProviderModelsFn = vi.fn();
- const getPlaceholder = vi.fn((provider: Providers) => `Enter ${provider} model name`);
- const setShowAdvancedSettings = vi.fn();
-
- const selectedProvider = Providers.OpenAI;
- const providerModels = ["gpt-4", "gpt-3.5-turbo"];
- const showAdvancedSettings = false;
-
- const teams: Team[] = [
- {
- team_id: "team-1",
- team_alias: "Test Team",
- models: ["gpt-4"],
- max_budget: 100,
- budget_duration: "monthly",
- tpm_limit: null,
- rpm_limit: null,
- organization_id: "org-1",
- created_at: "2024-01-01T00:00:00Z",
- keys: [],
- members_with_roles: [],
- },
- ];
-
- const credentials: CredentialItem[] = [
- {
- credential_name: "test-credential",
- credential_values: {},
- credential_info: {
- custom_llm_provider: "openai",
- description: "Test credential",
- },
- },
- ];
-
- const uploadProps: UploadProps = {
- beforeUpload: () => false,
- showUploadList: false,
- };
-
- return {
- form,
- handleOk,
- setSelectedProvider,
- setProviderModelsFn,
- getPlaceholder,
- setShowAdvancedSettings,
- selectedProvider,
- providerModels,
- showAdvancedSettings,
- teams,
- credentials,
- uploadProps,
- accessToken: "test-access-token",
- userRole: "Admin",
- };
-};
-
-describe("Add Model Tab", () => {
- it("should render", async () => {
- const props = createTestProps();
- const queryClient = createQueryClient();
-
- render(
-
-
- ,
- );
-
- expect(await screen.findByRole("tab", { name: "Add Model" })).toBeInTheDocument();
- });
-
- it("should display both Add Model and Add Auto Router tabs", async () => {
- const props = createTestProps();
- const queryClient = createQueryClient();
-
- render(
-
-
- ,
- );
-
- expect(await screen.findByRole("tab", { name: "Add Model" })).toBeInTheDocument();
- expect(await screen.findByRole("tab", { name: "Add Auto Router" })).toBeInTheDocument();
- });
-
- it("should display provider selection field", async () => {
- const props = createTestProps();
- const queryClient = createQueryClient();
-
- render(
-
-
- ,
- );
-
- expect(await screen.findByText("Provider")).toBeInTheDocument();
- });
-
- it("should display Test Connect and Add Model buttons", async () => {
- const props = createTestProps();
- const queryClient = createQueryClient();
-
- render(
-
-
- ,
- );
-
- // Wait for async operations to complete and buttons to appear
- await waitFor(
- async () => {
- const testConnectButtons = await screen.findAllByRole("button", { name: "Test Connect" });
- expect(testConnectButtons.length).toBeGreaterThan(0);
- const addModelButton = await screen.findByRole("button", { name: "Add Model" });
- expect(addModelButton).toBeInTheDocument();
- },
- { timeout: 10000 },
- );
- });
-
- it("should show team selection when team-only switch is enabled", async () => {
- const props = createTestProps();
- const queryClient = createQueryClient();
-
- render(
-
-
- ,
- );
-
- // Wait for component to load
- await screen.findByText("Provider");
-
- // Scope to the Team-BYOK Model Form.Item: the Add Auto Router tab, mounted alongside
- // this one, also renders a "Semantic keyword matching" switch, so a bare
- // getByRole("switch") would match more than one element.
- const teamByokFormItem = screen.getByText("Team-BYOK Model").closest(".ant-form-item") as HTMLElement;
- const teamSwitch = within(teamByokFormItem).getByRole("switch");
- expect(teamSwitch).toBeInTheDocument();
-
- // Initially, team selection should not be visible
- expect(screen.queryByText("Select Team")).not.toBeInTheDocument();
-
- // Click the switch to enable team-only mode
- await userEvent.click(teamSwitch!);
-
- // Now team selection should be visible
- expect(await screen.findByText("Select Team")).toBeInTheDocument();
- });
-});
diff --git a/ui/litellm-dashboard/src/components/add_model/add_model_tab.tsx b/ui/litellm-dashboard/src/components/add_model/add_model_tab.tsx
deleted file mode 100644
index f9b6533ac60..00000000000
--- a/ui/litellm-dashboard/src/components/add_model/add_model_tab.tsx
+++ /dev/null
@@ -1,98 +0,0 @@
-import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react";
-import type { FormInstance } from "antd";
-import { Form } from "antd";
-import type { UploadProps } from "antd/es/upload";
-import React from "react";
-import type { Team } from "../key_team_helpers/key_list";
-import { type CredentialItem } from "../networking";
-import { Providers } from "../provider_info_helpers";
-import AddAutoRouterTab from "./add_auto_router_tab";
-import AddModelForm from "./AddModelForm";
-import { handleAddAutoRouterSubmit } from "./handle_add_auto_router_submit";
-
-interface AddModelTabProps {
- form: FormInstance; // For the Add Model tab
- handleOk: (values?: any) => Promise;
- selectedProvider: Providers;
- setSelectedProvider: (provider: Providers) => void;
- providerModels: string[];
- setProviderModelsFn: (provider: Providers) => void;
- getPlaceholder: (provider: Providers) => string;
- uploadProps: UploadProps;
- showAdvancedSettings: boolean;
- setShowAdvancedSettings: (show: boolean) => void;
- teams: Team[] | null;
- credentials: CredentialItem[];
- accessToken: string;
- userRole: string;
-}
-
-const AddModelTab: React.FC = ({
- form,
- handleOk,
- selectedProvider,
- setSelectedProvider,
- providerModels,
- setProviderModelsFn,
- getPlaceholder,
- uploadProps,
- showAdvancedSettings,
- setShowAdvancedSettings,
- teams,
- credentials,
- accessToken,
- userRole,
-}) => {
- // Create separate form instance for auto router
- const [autoRouterForm] = Form.useForm();
-
- const handleAutoRouterOk = () => {
- autoRouterForm
- .validateFields()
- .then((values) => {
- handleAddAutoRouterSubmit(values, accessToken, autoRouterForm, handleOk);
- })
- .catch((error) => {
- console.error("Validation failed:", error);
- });
- };
-
- return (
- <>
-
-
- Add Model
- Add Auto Router
-
-
-
-
-
-
-
-
-
-
- >
- );
-};
-
-export default AddModelTab;
diff --git a/ui/litellm-dashboard/src/components/add_model/auto_router_strategies.ts b/ui/litellm-dashboard/src/components/add_model/auto_router_strategies.ts
new file mode 100644
index 00000000000..e7880969d1c
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/add_model/auto_router_strategies.ts
@@ -0,0 +1,143 @@
+/**
+ * Single owner of "which auto-router strategy is this deployment, and what can we do with it".
+ *
+ * Two independent axes decide whether a row is writable, and both must hold:
+ * 1. STRATEGY - the dashboard only has a form for complexity and semantic routers. Adaptive
+ * and quality store their settings under their own config keys, so opening
+ * one in the complexity/semantic editor would write the wrong shape onto it.
+ * 2. ORIGIN - a deployment defined in config.yaml reports `db_model: false`, and the API
+ * refuses it regardless of strategy (PATCH /model/{id}/update 404s,
+ * POST /model/delete 400s). Only rows created through the API are writable.
+ *
+ * Strategy order mirrors Router._is_auto_router_deployment (router.py:7589-7594): the named
+ * prefixes are matched first, and only a bare `auto_router/` is the semantic router.
+ */
+
+export type AutoRouterKind = "complexity" | "adaptive" | "quality" | "semantic";
+
+export interface AutoRouterParams {
+ model?: string | null;
+ complexity_router_config?: unknown;
+ complexity_router_default_model?: string | null;
+ auto_router_config?: unknown;
+ auto_router_default_model?: string | null;
+ adaptive_router_config?: unknown;
+ adaptive_router_default_model?: string | null;
+ quality_router_config?: unknown;
+ quality_router_default_model?: string | null;
+}
+
+export interface AutoRouterStrategy {
+ kind: AutoRouterKind;
+ /** Type-pill label. The complexity router overrides this with its classifier. */
+ label: string;
+ configKey: keyof AutoRouterParams;
+ defaultModelKey: keyof AutoRouterParams;
+ /** Whether the dashboard has a form that understands this strategy's config shape. */
+ hasEditor: boolean;
+ matches: (params: AutoRouterParams) => boolean;
+}
+
+const startsWith = (params: AutoRouterParams, prefix: string): boolean => params.model?.startsWith(prefix) === true;
+
+/** Ordered; the semantic entry matches anything left and must stay last. */
+export const AUTO_ROUTER_STRATEGIES: readonly AutoRouterStrategy[] = [
+ {
+ kind: "complexity",
+ label: "Complexity",
+ configKey: "complexity_router_config",
+ defaultModelKey: "complexity_router_default_model",
+ hasEditor: true,
+ // Also matched by config presence: rows predating the canonical model string carry the
+ // config without the prefix.
+ matches: (p) => startsWith(p, "auto_router/complexity_router") || p.complexity_router_config != null,
+ },
+ {
+ kind: "adaptive",
+ label: "Adaptive",
+ configKey: "adaptive_router_config",
+ defaultModelKey: "adaptive_router_default_model",
+ hasEditor: false,
+ matches: (p) => startsWith(p, "auto_router/adaptive_router"),
+ },
+ {
+ kind: "quality",
+ label: "Quality",
+ configKey: "quality_router_config",
+ defaultModelKey: "quality_router_default_model",
+ hasEditor: false,
+ matches: (p) => startsWith(p, "auto_router/quality_router"),
+ },
+ {
+ kind: "semantic",
+ label: "Semantic",
+ configKey: "auto_router_config",
+ defaultModelKey: "auto_router_default_model",
+ hasEditor: true,
+ matches: () => true,
+ },
+] as const;
+
+export const autoRouterStrategy = (params: AutoRouterParams | null | undefined): AutoRouterStrategy =>
+ AUTO_ROUTER_STRATEGIES.find((strategy) => strategy.matches(params ?? {}))!;
+
+export const isComplexityRouter = (params: AutoRouterParams | null | undefined): boolean =>
+ autoRouterStrategy(params).kind === "complexity";
+
+/** Any `auto_router/*` deployment, whatever its strategy. Use for listing and filtering. */
+export const isAutoRouterDeployment = (params: AutoRouterParams | null | undefined): boolean =>
+ params?.model?.startsWith("auto_router/") === true ||
+ params?.complexity_router_config != null ||
+ params?.auto_router_config != null;
+
+/**
+ * Whether EditAutoRouterModal understands this deployment. It only speaks complexity and
+ * semantic, so offering it for an adaptive or quality router lets a save write
+ * `auto_router_config` onto a row that stores its settings elsewhere. Gate every edit
+ * affordance on this, never on `isAutoRouterDeployment`.
+ */
+export const hasAutoRouterEditor = (params: AutoRouterParams | null | undefined): boolean =>
+ isAutoRouterDeployment(params) && autoRouterStrategy(params).hasEditor;
+
+export interface AutoRouterDeploymentInfo {
+ db_model?: boolean | null;
+}
+
+/** Why the dashboard cannot offer an edit form, or null when it can. */
+export type EditBlockedReason = "config-managed" | "no-editor";
+
+export interface AutoRouterCapabilities {
+ /** Defined in config.yaml; the API refuses both update and delete for it. */
+ isConfigManaged: boolean;
+ canEdit: boolean;
+ /** Deleting removes a row by id and never reads its config, so strategy is irrelevant. */
+ canDelete: boolean;
+ editBlockedReason: EditBlockedReason | null;
+}
+
+/**
+ * What could be done to this deployment by anyone with permission. Deliberately excludes the
+ * caller's role: the page ANDs that in, so resource capability and actor permission stay
+ * separable. Derive per capability rather than exposing one "editable" boolean, because the
+ * constraints differ (edit needs an editor, delete does not) and the explanation differs again.
+ */
+const editBlockedReasonFor = (isConfigManaged: boolean, hasEditor: boolean): EditBlockedReason | null => {
+ if (isConfigManaged) return "config-managed";
+ if (!hasEditor) return "no-editor";
+ return null;
+};
+
+export const autoRouterCapabilities = (
+ params: AutoRouterParams | null | undefined,
+ modelInfo: AutoRouterDeploymentInfo | null | undefined,
+): AutoRouterCapabilities => {
+ const isConfigManaged = modelInfo?.db_model !== true;
+ const hasEditor = autoRouterStrategy(params).hasEditor;
+
+ return {
+ isConfigManaged,
+ canEdit: !isConfigManaged && hasEditor,
+ canDelete: !isConfigManaged,
+ editBlockedReason: editBlockedReasonFor(isConfigManaged, hasEditor),
+ };
+};
diff --git a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts
index 3b41b916611..02be9b280aa 100644
--- a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts
+++ b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts
@@ -1,4 +1,5 @@
import { KeywordTierRule } from "./KeywordTierRules";
+import { serializeKeywordTierRules } from "./complexity_router_keywords";
import {
AdaptiveEligible,
AdaptiveRouterWeights,
@@ -82,12 +83,7 @@ export const buildComplexityRouterConfig = ({
}: BuildComplexityRouterConfigParams): ComplexityRouterConfigPayload => {
const cleanedEscalationKeywords = escalationKeywords.map((keyword) => keyword.trim()).filter(Boolean);
// Trim keywords and drop empty ones; drop any rule left with no keywords. Clicking
- // "Add keyword rule" seeds a rule with an empty keywords list, so without this an
- // unfilled row (common in the heuristic flow, where getSemanticConfigError doesn't run)
- // would ship keyword_tier_rules the backend validator rejects with a 400.
- const cleanedKeywordTierRules = keywordTierRules
- .map((rule) => ({ keywords: rule.keywords.map((k) => k.trim()).filter(Boolean), tier: rule.tier }))
- .filter((rule) => rule.keywords.length > 0);
+ const cleanedKeywordTierRules = serializeKeywordTierRules(keywordTierRules);
return {
tiers,
diff --git a/ui/litellm-dashboard/src/components/add_model/build_semantic_router_validation.test.ts b/ui/litellm-dashboard/src/components/add_model/build_semantic_router_validation.test.ts
deleted file mode 100644
index a5556813cf4..00000000000
--- a/ui/litellm-dashboard/src/components/add_model/build_semantic_router_validation.test.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import { getSemanticRouterError, SemanticRouterConfig } from "./build_semantic_router_validation";
-
-const validRouterConfig: SemanticRouterConfig = {
- routes: [{ name: "gpt-4o", description: "general chat", utterances: ["hello there"] }],
-};
-
-describe("getSemanticRouterError", () => {
- it("requires an embedding model once the default model and routes are configured", () => {
- expect(
- getSemanticRouterError({
- defaultModel: "gpt-4o",
- embeddingModel: undefined,
- routerConfig: validRouterConfig,
- }),
- ).toBe("Please select an Embedding Model");
- });
-
- it("treats an empty embedding model string as missing", () => {
- expect(
- getSemanticRouterError({
- defaultModel: "gpt-4o",
- embeddingModel: "",
- routerConfig: validRouterConfig,
- }),
- ).toBe("Please select an Embedding Model");
- });
-
- it("passes when an embedding model is selected", () => {
- expect(
- getSemanticRouterError({
- defaultModel: "gpt-4o",
- embeddingModel: "text-embedding-3-large",
- routerConfig: validRouterConfig,
- }),
- ).toBeNull();
- });
-
- it("flags a missing default model before checking the embedding model", () => {
- expect(
- getSemanticRouterError({
- defaultModel: undefined,
- embeddingModel: undefined,
- routerConfig: validRouterConfig,
- }),
- ).toBe("Please select a Default Model");
- });
-
- it("flags missing routes before checking the embedding model", () => {
- expect(
- getSemanticRouterError({
- defaultModel: "gpt-4o",
- embeddingModel: undefined,
- routerConfig: { routes: [] },
- }),
- ).toBe("Please configure at least one route for the auto router");
- });
-
- it("validates route completeness after the embedding model is set", () => {
- expect(
- getSemanticRouterError({
- defaultModel: "gpt-4o",
- embeddingModel: "text-embedding-3-large",
- routerConfig: { routes: [{ name: "gpt-4o", description: "", utterances: [] }] },
- }),
- ).toBe("Please ensure all routes have a target model, description, and at least one utterance");
- });
-});
diff --git a/ui/litellm-dashboard/src/components/add_model/build_semantic_router_validation.ts b/ui/litellm-dashboard/src/components/add_model/build_semantic_router_validation.ts
deleted file mode 100644
index 847ddee9ae1..00000000000
--- a/ui/litellm-dashboard/src/components/add_model/build_semantic_router_validation.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-export interface SemanticRouterRoute {
- name?: string;
- description?: string;
- utterances?: unknown[];
-}
-
-export interface SemanticRouterConfig {
- routes?: SemanticRouterRoute[];
-}
-
-export interface SemanticRouterValidationParams {
- defaultModel: string | undefined;
- embeddingModel: string | undefined;
- routerConfig: SemanticRouterConfig | null | undefined;
-}
-
-export const getSemanticRouterError = ({
- defaultModel,
- embeddingModel,
- routerConfig,
-}: SemanticRouterValidationParams): string | null => {
- if (!defaultModel) return "Please select a Default Model";
- if (!routerConfig?.routes || routerConfig.routes.length === 0)
- return "Please configure at least one route for the auto router";
- if (!embeddingModel) return "Please select an Embedding Model";
- if (routerConfig.routes.some((route) => !route.name || !route.description || (route.utterances?.length ?? 0) === 0))
- return "Please ensure all routes have a target model, description, and at least one utterance";
- return null;
-};
diff --git a/ui/litellm-dashboard/src/components/add_model/complexity_router_keywords.ts b/ui/litellm-dashboard/src/components/add_model/complexity_router_keywords.ts
new file mode 100644
index 00000000000..9cfdaed4e23
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/add_model/complexity_router_keywords.ts
@@ -0,0 +1,40 @@
+import { ComplexityTier, KeywordTierRule } from "./KeywordTierRules";
+
+/**
+ * Stored shape of a keyword tier rule inside `complexity_router_config`. The UI's
+ * KeywordTierRule carries an extra `id` used only as a React key, so it is stripped on the
+ * way out and synthesized on the way back in. Both the create form and the edit modal go
+ * through here so the two directions cannot drift.
+ */
+export interface StoredKeywordTierRule {
+ keywords: string[];
+ tier: ComplexityTier;
+}
+
+const TIERS: ReadonlySet = new Set(["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"]);
+
+const asKeywords = (value: unknown): string[] =>
+ Array.isArray(value)
+ ? value.filter((keyword): keyword is string => typeof keyword === "string").map((keyword) => keyword.trim())
+ : [];
+
+/**
+ * Drop the React-only id, trim keywords, and discard rules left empty. "Add keyword rule"
+ * seeds a row with no keywords, and the backend validator rejects those with a 400.
+ */
+export const serializeKeywordTierRules = (rules: KeywordTierRule[]): StoredKeywordTierRule[] =>
+ rules
+ .map((rule) => ({ keywords: asKeywords(rule.keywords).filter(Boolean), tier: rule.tier }))
+ .filter((rule) => rule.keywords.length > 0);
+
+export const hydrateKeywordTierRules = (value: unknown): KeywordTierRule[] => {
+ if (!Array.isArray(value)) return [];
+ return value.flatMap((entry, index) => {
+ if (typeof entry !== "object" || entry === null) return [];
+ const record = entry as Record;
+ const keywords = asKeywords(record.keywords).filter(Boolean);
+ const tier = record.tier;
+ if (keywords.length === 0 || typeof tier !== "string" || !TIERS.has(tier)) return [];
+ return [{ id: `stored-${index}`, keywords, tier: tier as ComplexityTier }];
+ });
+};
diff --git a/ui/litellm-dashboard/src/components/add_model/complexity_router_tiers.test.ts b/ui/litellm-dashboard/src/components/add_model/complexity_router_tiers.test.ts
new file mode 100644
index 00000000000..d4604265d3a
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/add_model/complexity_router_tiers.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from "vitest";
+
+import { normalizeTierModels } from "./complexity_router_tiers";
+
+// The backend types a tier as `str | list[str]` and widens with
+// `models if isinstance(models, list) else [models]`
+// (litellm/router_strategy/complexity_router/config.py:255, :441). These cases assert the
+// expected verdict per input rather than just agreement between call sites, so the test still
+// has teeth if every reader were changed at once.
+describe("normalizeTierModels", () => {
+ it("widens a pinned single model to a one-element pool", () => {
+ expect(normalizeTierModels("gpt-4o-mini")).toEqual(["gpt-4o-mini"]);
+ });
+
+ it("passes a pool through in order", () => {
+ expect(normalizeTierModels(["a", "b"])).toEqual(["a", "b"]);
+ });
+
+ it("treats an empty string as no models, not a pool containing an empty name", () => {
+ expect(normalizeTierModels("")).toEqual([]);
+ });
+
+ it("drops non-string entries rather than typing them as models", () => {
+ expect(normalizeTierModels(["a", 3, null, "b"])).toEqual(["a", "b"]);
+ });
+
+ it.each([[undefined], [null], [{}], [42]])("returns no models for %s", (value) => {
+ expect(normalizeTierModels(value)).toEqual([]);
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/add_model/complexity_router_tiers.ts b/ui/litellm-dashboard/src/components/add_model/complexity_router_tiers.ts
new file mode 100644
index 00000000000..bb34d221b2d
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/add_model/complexity_router_tiers.ts
@@ -0,0 +1,14 @@
+/**
+ * A complexity tier maps to `str | list[str]` on the backend
+ * (litellm/router_strategy/complexity_router/config.py: "string = pin; list = random pick"),
+ * and the router widens the bare string with `models if isinstance(models, list) else [models]`.
+ *
+ * Every UI reader of a STORED complexity_router_config must widen the same way, so this is the
+ * single owner of that rule. Readers of in-memory ComplexityTiers state are already string[]
+ * and do not need it.
+ */
+export const normalizeTierModels = (value: unknown): string[] => {
+ if (Array.isArray(value)) return value.filter((model): model is string => typeof model === "string");
+ if (typeof value === "string" && value) return [value];
+ return [];
+};
diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts b/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts
new file mode 100644
index 00000000000..e39a7b6e444
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts
@@ -0,0 +1,88 @@
+import { describe, expect, it } from "vitest";
+
+import { buildUpdatedComplexityRouterConfig, type KeywordMatchingState } from "./edit_auto_router_modal";
+
+const STORED = {
+ tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] },
+ classifier_type: "heuristic",
+ keyword_tier_rules: [{ keywords: ["invoice", "refund"], tier: "MEDIUM" }],
+ escalation_keywords: ["urgent", "outage"],
+ semantic_keyword_matching: true,
+ embedding_model: "voyage-4-large",
+ match_threshold: 0.72,
+ // A key no UI control owns; it must survive every save untouched.
+ some_future_backend_key: { nested: true },
+};
+
+const FORM_VALUE = {
+ tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] },
+ classifier_type: "heuristic" as const,
+};
+
+const hydratedState: KeywordMatchingState = {
+ keywordTierRules: [{ id: "stored-0", keywords: ["invoice", "refund"], tier: "MEDIUM" }],
+ escalationKeywords: ["urgent", "outage"],
+ semanticMatchingEnabled: true,
+ embeddingModel: "voyage-4-large",
+ matchThreshold: 0.72,
+};
+
+describe("buildUpdatedComplexityRouterConfig keyword matching", () => {
+ it("round-trips an untouched edit without changing any keyword-matching value", () => {
+ // Opening the modal hydrates state from STORED; saving with nothing changed must be a
+ // no-op. These keys are now MANAGED, so a hydration bug silently wipes them.
+ const result = buildUpdatedComplexityRouterConfig(STORED, FORM_VALUE, undefined, hydratedState);
+
+ expect(result.keyword_tier_rules).toEqual([{ keywords: ["invoice", "refund"], tier: "MEDIUM" }]);
+ expect(result.escalation_keywords).toEqual(["urgent", "outage"]);
+ expect(result.semantic_keyword_matching).toBe(true);
+ expect(result.embedding_model).toBe("voyage-4-large");
+ expect(result.match_threshold).toBe(0.72);
+ });
+
+ it("preserves keys no control owns", () => {
+ const result = buildUpdatedComplexityRouterConfig(STORED, FORM_VALUE, undefined, hydratedState);
+ expect(result.some_future_backend_key).toEqual({ nested: true });
+ });
+
+ it("persists an edited keyword rule", () => {
+ const result = buildUpdatedComplexityRouterConfig(STORED, FORM_VALUE, undefined, {
+ ...hydratedState,
+ keywordTierRules: [{ id: "stored-0", keywords: ["chargeback"], tier: "COMPLEX" }],
+ });
+
+ expect(result.keyword_tier_rules).toEqual([{ keywords: ["chargeback"], tier: "COMPLEX" }]);
+ });
+
+ it("drops a rule left empty rather than shipping one the backend 400s on", () => {
+ const result = buildUpdatedComplexityRouterConfig(STORED, FORM_VALUE, undefined, {
+ ...hydratedState,
+ keywordTierRules: [{ id: "new-1", keywords: [" "], tier: "SIMPLE" }],
+ });
+
+ expect(result.keyword_tier_rules).toBeUndefined();
+ });
+
+ it("removes the semantic trio when the toggle is turned off", () => {
+ const result = buildUpdatedComplexityRouterConfig(STORED, FORM_VALUE, undefined, {
+ ...hydratedState,
+ semanticMatchingEnabled: false,
+ });
+
+ expect(result.semantic_keyword_matching).toBeUndefined();
+ expect(result.embedding_model).toBeUndefined();
+ expect(result.match_threshold).toBeUndefined();
+ });
+
+ it("carries stored keyword matching through untouched when the caller owns no such state", () => {
+ // Any caller that does not render these controls must not have its values dropped just
+ // because the keys are listed as managed.
+ const result = buildUpdatedComplexityRouterConfig(STORED, FORM_VALUE);
+
+ expect(result.keyword_tier_rules).toEqual([{ keywords: ["invoice", "refund"], tier: "MEDIUM" }]);
+ expect(result.escalation_keywords).toEqual(["urgent", "outage"]);
+ expect(result.semantic_keyword_matching).toBe(true);
+ expect(result.embedding_model).toBe("voyage-4-large");
+ expect(result.match_threshold).toBe(0.72);
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.tsx b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.tsx
new file mode 100644
index 00000000000..e47bbecf8bd
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.tsx
@@ -0,0 +1,121 @@
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+
+import { renderWithProviders, screen, waitFor } from "@/../tests/test-utils";
+
+import NotificationsManager from "@/components/molecules/notifications_manager";
+import EditAutoRouterModal from "./edit_auto_router_modal";
+
+const { modelPatchUpdateCall, modelAvailableCall } = vi.hoisted(() => ({
+ modelPatchUpdateCall: vi.fn().mockResolvedValue({}),
+ modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }),
+}));
+
+vi.mock("../networking", () => ({ modelPatchUpdateCall, modelAvailableCall }));
+
+vi.mock("@/components/llm_calls/fetch_models", () => ({
+ fetchAvailableModels: vi.fn().mockResolvedValue([{ model_group: "gpt-4o-mini" }]),
+}));
+
+const STORED_CONFIG = {
+ tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: ["gpt-4o-mini"], COMPLEX: ["gpt-4o-mini"], REASONING: ["gpt-4o-mini"] },
+ classifier_type: "heuristic",
+ keyword_tier_rules: [{ keywords: ["invoice", "refund"], tier: "MEDIUM" }],
+ escalation_keywords: ["urgent", "outage"],
+ semantic_keyword_matching: true,
+ embedding_model: "voyage-4-large",
+ match_threshold: 0.72,
+};
+
+const MODEL_DATA = {
+ model_name: "tri-tier-router",
+ litellm_params: {
+ model: "auto_router/complexity_router",
+ complexity_router_config: STORED_CONFIG,
+ },
+ model_info: { id: "auto-1", access_groups: [] },
+};
+
+const renderModal = () =>
+ renderWithProviders(
+ ,
+ );
+
+const savedConfig = () => {
+ const [, payload] = modelPatchUpdateCall.mock.calls.at(-1) ?? [];
+ return payload?.litellm_params?.complexity_router_config;
+};
+
+describe("EditAutoRouterModal keyword matching", () => {
+ beforeEach(() => {
+ modelPatchUpdateCall.mockClear();
+ });
+
+ it("renders the advanced sections the create form offers", async () => {
+ renderModal();
+
+ expect(await screen.findByText(/Escalation Keywords/i)).toBeInTheDocument();
+ expect(await screen.findByText(/Keyword\/Semantic Matching/i)).toBeInTheDocument();
+ });
+
+ // These keys are rewritten from form state on save, so if the modal renders the controls
+ // without hydrating them, an untouched save silently wipes the stored configuration. This
+ // drives the real component; a test of the payload builder alone cannot see that bug.
+ it("preserves stored keyword matching through an untouched open-and-save", async () => {
+ const user = userEvent.setup();
+ renderModal();
+
+ await screen.findByText(/Escalation Keywords/i);
+ await user.click(screen.getByRole("button", { name: /save changes/i }));
+
+ await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
+
+ const config = savedConfig();
+ expect(config.keyword_tier_rules).toEqual([{ keywords: ["invoice", "refund"], tier: "MEDIUM" }]);
+ expect(config.escalation_keywords).toEqual(["urgent", "outage"]);
+ expect(config.semantic_keyword_matching).toBe(true);
+ expect(config.embedding_model).toBe("voyage-4-large");
+ expect(config.match_threshold).toBe(0.72);
+ });
+
+ // The create form blocks this; the edit modal renders the same controls, so it must block it
+ // too. The backend raises on semantic_keyword_matching without an embedding model or keyword
+ // rules, so skipping the guard turns a friendly inline message into a raw 400.
+ it("blocks a save that enables semantic matching with no embedding model", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(
+ ,
+ );
+
+ await screen.findByText(/Escalation Keywords/i);
+ await user.click(screen.getByRole("button", { name: /save changes/i }));
+
+ await waitFor(() => expect(NotificationsManager.fromBackend).toHaveBeenCalled());
+ expect(modelPatchUpdateCall).not.toHaveBeenCalled();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx
index 46d7d41d9b3..8fbca822165 100644
--- a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx
+++ b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx
@@ -4,6 +4,12 @@ import { Text, TextInput } from "@tremor/react";
import { modelAvailableCall, modelPatchUpdateCall } from "../networking";
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
import RouterConfigBuilder from "../add_model/RouterConfigBuilder";
+import { normalizeTierModels } from "../add_model/complexity_router_tiers";
+import { isComplexityRouter } from "../add_model/auto_router_strategies";
+import { getSemanticConfigError } from "../add_model/build_complexity_router_config";
+import { KeywordTierRule } from "../add_model/KeywordTierRules";
+import { DEFAULT_MATCH_THRESHOLD } from "../add_model/SemanticKeywordMatching";
+import { hydrateKeywordTierRules, serializeKeywordTierRules } from "../add_model/complexity_router_keywords";
import ComplexityRouterConfig, {
ComplexityRouterConfigValue,
DEFAULT_ADAPTIVE_WEIGHTS,
@@ -11,16 +17,6 @@ import ComplexityRouterConfig, {
} from "../add_model/ComplexityRouterConfig";
import NotificationsManager from "../molecules/notifications_manager";
-const isComplexityRouterModel = (modelData: any): boolean =>
- modelData?.litellm_params?.model?.startsWith("auto_router/complexity_router") ||
- modelData?.litellm_params?.complexity_router_config != null;
-
-const normalizeTierModels = (value: unknown): string[] => {
- if (Array.isArray(value)) return value;
- if (typeof value === "string" && value) return [value];
- return [];
-};
-
interface EditAutoRouterModalProps {
isVisible: boolean;
onCancel: () => void;
@@ -30,6 +26,9 @@ interface EditAutoRouterModalProps {
userRole: string;
}
+// Keys this modal rewrites from its own form state on save. Anything absent from this set is
+// carried through untouched from the stored config, so a key only belongs here once the modal
+// actually renders a control that can set it.
const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([
"tiers",
"classifier_type",
@@ -41,6 +40,16 @@ const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([
"return_raw_model_name",
]);
+// Managed only when the caller passes the corresponding state. A caller that does not render
+// these controls must carry the stored values through untouched instead of dropping them.
+const KEYWORD_MATCHING_KEYS = new Set([
+ "keyword_tier_rules",
+ "escalation_keywords",
+ "semantic_keyword_matching",
+ "embedding_model",
+ "match_threshold",
+]);
+
const toRecord = (value: unknown): Record => {
const parsed: unknown = typeof value === "string" ? JSON.parse(value) : value;
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
@@ -48,19 +57,29 @@ const toRecord = (value: unknown): Record => {
: {};
};
+export interface KeywordMatchingState {
+ keywordTierRules: KeywordTierRule[];
+ escalationKeywords: string[];
+ semanticMatchingEnabled: boolean;
+ embeddingModel: string | undefined;
+ matchThreshold: number;
+}
+
export const buildUpdatedComplexityRouterConfig = (
storedConfig: unknown,
value: ComplexityRouterConfigValue,
customTechnicalKeywords?: string[],
+ keywordMatching?: KeywordMatchingState,
): Record => {
- const preservedConfig = Object.fromEntries(
- Object.entries(toRecord(storedConfig)).filter(
- ([key]) =>
- !MANAGED_COMPLEXITY_ROUTER_KEYS.has(key) &&
- (customTechnicalKeywords === undefined || key !== "custom_technical_keywords"),
- ),
- );
+ const isManaged = (key: string): boolean => {
+ if (MANAGED_COMPLEXITY_ROUTER_KEYS.has(key)) return true;
+ if (keywordMatching !== undefined && KEYWORD_MATCHING_KEYS.has(key)) return true;
+ return customTechnicalKeywords !== undefined && key === "custom_technical_keywords";
+ };
+
+ const preservedConfig = Object.fromEntries(Object.entries(toRecord(storedConfig)).filter(([key]) => !isManaged(key)));
const adaptiveEligible = value.adaptive_eligible ?? "all";
+ const storedKeywordRules = keywordMatching ? serializeKeywordTierRules(keywordMatching.keywordTierRules) : [];
return {
...preservedConfig,
@@ -80,6 +99,17 @@ export const buildUpdatedComplexityRouterConfig = (
adaptive_eligible: adaptiveEligible,
}),
...(value.return_raw_model_name && { return_raw_model_name: true }),
+ ...(keywordMatching && {
+ // Mirrors buildComplexityRouterConfig: rules only when non-empty (the backend rejects
+ // an empty rule with a 400), escalation keywords always, semantic trio only when on.
+ ...(storedKeywordRules.length > 0 && { keyword_tier_rules: storedKeywordRules }),
+ escalation_keywords: keywordMatching.escalationKeywords.map((k) => k.trim()).filter(Boolean),
+ ...(keywordMatching.semanticMatchingEnabled && {
+ semantic_keyword_matching: true,
+ embedding_model: keywordMatching.embeddingModel,
+ match_threshold: keywordMatching.matchThreshold,
+ }),
+ }),
};
};
@@ -99,11 +129,16 @@ const EditAutoRouterModal: React.FC = ({
const [showCustomEmbeddingModel, setShowCustomEmbeddingModel] = useState(false);
const [routerConfig, setRouterConfig] = useState(null);
const [customTechnicalKeywords, setCustomTechnicalKeywords] = useState([]);
+ const [keywordTierRules, setKeywordTierRules] = useState([]);
+ const [escalationKeywords, setEscalationKeywords] = useState([]);
+ const [semanticMatchingEnabled, setSemanticMatchingEnabled] = useState(false);
+ const [embeddingModel, setEmbeddingModel] = useState(undefined);
+ const [matchThreshold, setMatchThreshold] = useState(DEFAULT_MATCH_THRESHOLD);
const [complexityRouterConfig, setComplexityRouterConfig] = useState({
tiers: { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] },
classifier_type: "heuristic",
});
- const isComplexityRouter = isComplexityRouterModel(modelData);
+ const isComplexityRouterModel = isComplexityRouter(modelData?.litellm_params);
useEffect(() => {
if (isVisible && modelData) {
@@ -140,7 +175,7 @@ const EditAutoRouterModal: React.FC = ({
const initializeForm = () => {
try {
- if (isComplexityRouterModel(modelData)) {
+ if (isComplexityRouterModel) {
// Parse the complexity_router_config if it exists and is a string
let parsedConfig = modelData.litellm_params?.complexity_router_config || {};
if (typeof parsedConfig === "string") {
@@ -165,6 +200,20 @@ const EditAutoRouterModal: React.FC = ({
setCustomTechnicalKeywords(
Array.isArray(parsedConfig.custom_technical_keywords) ? parsedConfig.custom_technical_keywords : [],
);
+ // Hydrated from the stored config, never from create-form defaults: these keys are now
+ // rewritten on save, so seeding a default here would inject it into a config that never
+ // had it.
+ setKeywordTierRules(hydrateKeywordTierRules(parsedConfig.keyword_tier_rules));
+ setEscalationKeywords(
+ Array.isArray(parsedConfig.escalation_keywords)
+ ? parsedConfig.escalation_keywords.filter((k: unknown): k is string => typeof k === "string")
+ : [],
+ );
+ setSemanticMatchingEnabled(parsedConfig.semantic_keyword_matching === true);
+ setEmbeddingModel(typeof parsedConfig.embedding_model === "string" ? parsedConfig.embedding_model : undefined);
+ setMatchThreshold(
+ typeof parsedConfig.match_threshold === "number" ? parsedConfig.match_threshold : DEFAULT_MATCH_THRESHOLD,
+ );
form.setFieldsValue({
auto_router_name: modelData.model_name,
@@ -208,7 +257,7 @@ const EditAutoRouterModal: React.FC = ({
setLoading(true);
const values = await form.validateFields();
- if (isComplexityRouter) {
+ if (isComplexityRouterModel) {
const { tiers, classifier_type, classifier_llm_config } = complexityRouterConfig;
if (Object.values(tiers).every((models) => models.length === 0)) {
NotificationsManager.fromBackend("Please select at least one model for a complexity tier");
@@ -218,6 +267,20 @@ const EditAutoRouterModal: React.FC = ({
NotificationsManager.fromBackend("Please select a classifier model, or switch back to Heuristic");
return;
}
+ // Same guard the create form applies (add_auto_router_tab.tsx). The backend rejects
+ // semantic_keyword_matching without an embedding model or keyword rules
+ // (complexity_router/config.py), so without this a save fails as a raw 400 instead of
+ // an inline message.
+
+ // Same guard the create form applies (add_auto_router_tab.tsx). The backend rejects
+ // semantic_keyword_matching without an embedding model or keyword rules
+ // (complexity_router/config.py), so without this a save fails as a raw 400 instead of
+ // an inline message.
+ const semanticError = getSemanticConfigError({ semanticMatchingEnabled, embeddingModel, keywordTierRules });
+ if (semanticError) {
+ NotificationsManager.fromBackend(semanticError);
+ return;
+ }
const defaultModel = tiers.MEDIUM[0] || tiers.SIMPLE[0] || tiers.COMPLEX[0] || tiers.REASONING[0];
const updatedLitellmParams = {
@@ -226,6 +289,13 @@ const EditAutoRouterModal: React.FC = ({
modelData.litellm_params?.complexity_router_config,
complexityRouterConfig,
customTechnicalKeywords,
+ {
+ keywordTierRules,
+ escalationKeywords,
+ semanticMatchingEnabled,
+ embeddingModel,
+ matchThreshold,
+ },
),
complexity_router_default_model: defaultModel,
};
@@ -327,7 +397,7 @@ const EditAutoRouterModal: React.FC = ({
- {isComplexityRouter ? (
+ {isComplexityRouterModel ? (
/* Complexity Router Configuration */
= ({
}}
customTechnicalKeywords={customTechnicalKeywords}
onCustomTechnicalKeywordsChange={setCustomTechnicalKeywords}
+ keywordTierRules={keywordTierRules}
+ onKeywordTierRulesChange={setKeywordTierRules}
+ semanticMatchingEnabled={semanticMatchingEnabled}
+ onSemanticMatchingEnabledChange={setSemanticMatchingEnabled}
+ embeddingModel={embeddingModel}
+ onEmbeddingModelChange={setEmbeddingModel}
+ matchThreshold={matchThreshold}
+ onMatchThresholdChange={setMatchThreshold}
+ escalationKeywords={escalationKeywords}
+ onEscalationKeywordsChange={setEscalationKeywords}
/>
) : (
diff --git a/ui/litellm-dashboard/src/components/leftnav.test.tsx b/ui/litellm-dashboard/src/components/leftnav.test.tsx
index 87c830e69de..dc893643559 100644
--- a/ui/litellm-dashboard/src/components/leftnav.test.tsx
+++ b/ui/litellm-dashboard/src/components/leftnav.test.tsx
@@ -73,6 +73,16 @@ vi.mock("@/app/(dashboard)/hooks/useLogout", () => ({
const collectNavKeys = (): string[] =>
menuGroups.flatMap((group) => group.items.flatMap((item) => [item.key, ...(item.children ?? []).map((c) => c.key)]));
+// Every place a page id appears in the nav, as "GROUP" for a top-level item or
+// "GROUP > parentKey" for a child.
+const placementsOf = (page: string): string[] =>
+ menuGroups.flatMap((group) => [
+ ...group.items.filter((item) => item.page === page).map(() => group.groupLabel),
+ ...group.items.flatMap((item) =>
+ (item.children ?? []).filter((child) => child.page === page).map(() => `${group.groupLabel} > ${item.key}`),
+ ),
+ ]);
+
describe("Sidebar (leftnav)", () => {
const defaultProps = {
setPage: vi.fn(),
@@ -129,6 +139,13 @@ describe("Sidebar (leftnav)", () => {
expect(screen.getByText("Search Tools")).toBeInTheDocument();
});
});
+ it("keeps Router Settings as a single Settings child", () => {
+ // Router Settings is admin-only, so getAvailablePages() filters it out entirely and the
+ // page_utils duplicate-key guard cannot see it. Walk menuGroups directly, otherwise a
+ // stray duplicate placement ships silently.
+ expect(placementsOf("router-settings")).toEqual(["SETTINGS > settings"]);
+ });
+
it("has no duplicate keys among all menu items and their children", () => {
// React keys must be unique across the whole nav config, otherwise the
// active-item highlight and group expansion collide.
@@ -273,6 +290,10 @@ describe("getBreadcrumb", () => {
expect(getBreadcrumb("search-tools")).toEqual({ section: "AI Gateway", title: "Search Tools" });
});
+ it("resolves router-settings under the Settings section", () => {
+ expect(getBreadcrumb("router-settings")).toEqual({ section: "Settings", title: "Router Settings" });
+ });
+
it("falls back to a prettified title with no section for unknown pages", () => {
expect(getBreadcrumb("some-unknown-page")).toEqual({ section: null, title: "Some Unknown Page" });
});
diff --git a/ui/litellm-dashboard/src/components/model_info_view.test.tsx b/ui/litellm-dashboard/src/components/model_info_view.test.tsx
index 6cf06d759f2..326b49ff896 100644
--- a/ui/litellm-dashboard/src/components/model_info_view.test.tsx
+++ b/ui/litellm-dashboard/src/components/model_info_view.test.tsx
@@ -949,4 +949,89 @@ describe("ModelInfoView", () => {
expect(screen.queryByAltText("zzz-internal logo")).not.toBeInTheDocument();
expect(screen.getByText("z")).toBeInTheDocument();
});
+
+ // EditAutoRouterModal only speaks complexity and semantic. Offering it for an adaptive or
+ // quality router lets a save write auto_router_config onto a row that stores its settings
+ // elsewhere. These rows stay reachable from Health Status and direct ?model= links even
+ // though the Models table now excludes auto-routers, so the button itself has to be gated.
+ describe("Edit Auto Router affordance", () => {
+ const withRouter = (litellmParams: Record) => {
+ mockUseModelsInfo.mockReturnValue({
+ data: { data: [{ ...defaultModelData, litellm_params: { ...litellmParams } }] },
+ isLoading: false,
+ error: null,
+ });
+ };
+
+ it.each([
+ ["auto_router/adaptive_router", "adaptive"],
+ ["auto_router/quality_router", "quality"],
+ ])("is absent for a %s router", async (model) => {
+ withRouter({ model });
+ render( , { wrapper });
+
+ expect(await screen.findByText("GPT-4")).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /edit auto router/i })).not.toBeInTheDocument();
+ });
+
+ it("is present for a complexity router, which the modal does understand", async () => {
+ withRouter({ model: "auto_router/complexity_router", complexity_router_config: { tiers: {} } });
+ render( , { wrapper });
+
+ expect(await screen.findByRole("button", { name: /edit auto router/i })).toBeInTheDocument();
+ });
+ });
+
+ // An auto router has no upstream credential, so the credential actions are meaningless for
+ // every strategy, and the destructive action should name what it actually removes.
+ describe("auto-router header actions", () => {
+ const withParams = (litellmParams: Record) => {
+ mockUseModelsInfo.mockReturnValue({
+ data: { data: [{ ...defaultModelData, litellm_params: { ...litellmParams } }] },
+ isLoading: false,
+ error: null,
+ });
+ };
+
+ it.each([
+ ["auto_router/complexity_router"],
+ ["auto_router/adaptive_router"],
+ ["auto_router/quality_router"],
+ ["auto_router/my-semantic"],
+ ])("hides the credential actions and renames delete for %s", async (model) => {
+ withParams({ model });
+ render( , { wrapper });
+
+ expect(await screen.findByTestId("delete-model-button")).toHaveTextContent("Delete Auto-Router");
+ expect(screen.queryByTestId("update-api-key-button")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("reuse-credentials-button")).not.toBeInTheDocument();
+ });
+
+ it("keeps both credential actions and the Delete Model label for an ordinary model", async () => {
+ withParams({ model: "gpt-4", api_base: "https://api.openai.com/v1" });
+ render( , { wrapper });
+
+ expect(await screen.findByTestId("delete-model-button")).toHaveTextContent("Delete Model");
+ expect(screen.getByTestId("update-api-key-button")).toBeInTheDocument();
+ expect(screen.getByTestId("reuse-credentials-button")).toBeInTheDocument();
+ });
+
+ it.each([["auto_router/adaptive_router"], ["auto_router/quality_router"]])(
+ "offers no Test Connection for %s, whose targets it cannot build",
+ async (model) => {
+ withParams({ model });
+ render( , { wrapper });
+
+ await screen.findByTestId("delete-model-button");
+ expect(screen.queryByTestId("test-connection-button")).not.toBeInTheDocument();
+ },
+ );
+
+ it("keeps Test Connection for a complexity router", async () => {
+ withParams({ model: "auto_router/complexity_router", complexity_router_config: { tiers: {} } });
+ render( , { wrapper });
+
+ expect(await screen.findByTestId("test-connection-button")).toBeInTheDocument();
+ });
+ });
});
diff --git a/ui/litellm-dashboard/src/components/model_info_view.tsx b/ui/litellm-dashboard/src/components/model_info_view.tsx
index fe28e0fb40c..094b145de37 100644
--- a/ui/litellm-dashboard/src/components/model_info_view.tsx
+++ b/ui/litellm-dashboard/src/components/model_info_view.tsx
@@ -26,6 +26,12 @@ import { isMaskedSecret, stripMaskedSecrets } from "../utils/maskedSecretUtils";
import { formItemValidateJSON, truncateString } from "../utils/textUtils";
import AutoRouterConnectionTest from "./add_model/auto_router_connection_test";
import { AutoRouterTestTarget, buildAutoRouterTestTargets } from "./add_model/build_auto_router_test_targets";
+import { normalizeTierModels } from "./add_model/complexity_router_tiers";
+import {
+ hasAutoRouterEditor,
+ isAutoRouterDeployment,
+ isComplexityRouter as isComplexityRouterParams,
+} from "./add_model/auto_router_strategies";
import CacheControlSettings from "./add_model/cache_control_settings";
import DeleteResourceModal from "./common_components/DeleteResourceModal";
import EditAutoRouterModal from "./edit_auto_router/edit_auto_router_modal";
@@ -59,12 +65,6 @@ interface ModelInfoViewProps {
modelAccessGroups: string[] | null;
}
-const normalizeTierModels = (value: unknown): string[] => {
- if (Array.isArray(value)) return value;
- if (typeof value === "string" && value) return [value];
- return [];
-};
-
interface ComplexityRouterTierConfig {
tiers?: {
SIMPLE?: unknown;
@@ -178,13 +178,13 @@ export default function ModelInfoView({
const canEditModel =
(userRole === "Admin" || modelData?.model_info?.created_by === userID) && modelData?.model_info?.db_model;
const isAdmin = userRole === "Admin";
- const isAutoRouter =
- modelData?.litellm_params?.auto_router_config != null ||
- modelData?.litellm_params?.complexity_router_config != null ||
- modelData?.litellm_params?.model?.startsWith("auto_router/complexity_router");
- const isComplexityRouter =
- modelData?.litellm_params?.complexity_router_config != null ||
- modelData?.litellm_params?.model?.startsWith("auto_router/complexity_router");
+ // Editor-aware on purpose: an adaptive or quality router must not offer Edit Auto Router.
+ const isAutoRouterModel = hasAutoRouterEditor(modelData?.litellm_params);
+ // Broader than the editor check: adaptive and quality routers equally have no upstream
+ // credential, so the credential actions are meaningless for every auto-router strategy.
+ const isAnyAutoRouter = isAutoRouterDeployment(modelData?.litellm_params);
+ const deleteLabel = isAnyAutoRouter ? "Delete Auto-Router" : "Delete Model";
+ const isComplexityRouterModel = isComplexityRouterParams(modelData?.litellm_params);
const usingExistingCredential =
modelData?.litellm_params?.litellm_credential_name != null &&
@@ -492,7 +492,7 @@ export default function ModelInfoView({
const handleTestConnection = async () => {
if (!accessToken) return;
- if (isComplexityRouter) {
+ if (isComplexityRouterModel) {
const targets = buildComplexityRouterTestTargets(localModelData ?? modelData);
if (targets.length === 0) {
NotificationsManager.warning("No complexity tiers are configured yet, so there is nothing to test.");
@@ -604,7 +604,7 @@ export default function ModelInfoView({
- {(!isAutoRouter || isComplexityRouter) && (
+ {(!isAnyAutoRouter || isComplexityRouterModel) && (
}
onClick={handleTestConnection}
@@ -615,25 +615,29 @@ export default function ModelInfoView({
)}
- }
- onClick={() => setIsUpdateCredentialsModalOpen(true)}
- className="flex items-center"
- disabled={!canEditModel}
- data-testid="update-api-key-button"
- >
- Update API Key
-
+ {!isAnyAutoRouter && (
+ <>
+ }
+ onClick={() => setIsUpdateCredentialsModalOpen(true)}
+ className="flex items-center"
+ disabled={!canEditModel}
+ data-testid="update-api-key-button"
+ >
+ Update API Key
+
- }
- onClick={() => setIsCredentialModalOpen(true)}
- className="flex items-center"
- disabled={!isAdmin}
- data-testid="reuse-credentials-button"
- >
- Re-use Credentials
-
+ }
+ onClick={() => setIsCredentialModalOpen(true)}
+ className="flex items-center"
+ disabled={!isAdmin}
+ data-testid="reuse-credentials-button"
+ >
+ Re-use Credentials
+
+ >
+ )}
}
@@ -642,7 +646,7 @@ export default function ModelInfoView({
disabled={!canEditModel}
data-testid="delete-model-button"
>
- Delete Model
+ {deleteLabel}
@@ -721,7 +725,7 @@ export default function ModelInfoView({
Model Settings
- {isAutoRouter && canEditModel && !isEditing && (
+ {isAutoRouterModel && canEditModel && !isEditing && (
setIsAutoRouterModalOpen(true)} className="flex items-center">
Edit Auto Router
@@ -1414,9 +1418,9 @@ export default function ModelInfoView({
{
/**
* Get all models on proxy
@@ -1595,6 +1596,9 @@ export const modelInfoCall = async (
if (sortOrder && sortOrder.trim()) {
params.append("sortOrder", sortOrder.trim());
}
+ if (excludeAutoRouters) {
+ params.append("exclude_auto_routers", "true");
+ }
if (params.toString()) {
url += `?${params.toString()}`;
}
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index ed975c6be0a..121cd79eccb 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -58089,6 +58089,8 @@ export interface operations {
sortBy?: string | null;
/** @description Sort order. Options: asc, desc */
sortOrder?: string | null;
+ /** @description Omit auto-router deployments (litellm model prefixed `auto_router/`). They are routing constructs rather than deployments, and are managed on the Router Settings page. Defaults to false, so existing callers are unaffected */
+ exclude_auto_routers?: boolean | null;
};
header?: never;
path?: never;
From 692a812f13183b92f9d175abe8dcd5fad33f33ab Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Wed, 29 Jul 2026 20:17:39 -0700
Subject: [PATCH 06/33] fix(batches): calculate cost and usage for completed
Vertex AI batches
---
litellm/batches/batch_utils.py | 3 -
.../test_litellm/batches/test_batch_utils.py | 114 +++++++++++++++++-
2 files changed, 111 insertions(+), 6 deletions(-)
diff --git a/litellm/batches/batch_utils.py b/litellm/batches/batch_utils.py
index 2fcb8455e90..36076d59a35 100644
--- a/litellm/batches/batch_utils.py
+++ b/litellm/batches/batch_utils.py
@@ -212,9 +212,6 @@ async def _get_batch_output_file_content_as_dictionary(
_is_base64_encoded_unified_file_id,
)
- if custom_llm_provider == "vertex_ai":
- raise ValueError("Vertex AI does not support file content retrieval")
-
if batch.output_file_id is None:
raise ValueError("Output file id is None cannot retrieve file content")
diff --git a/tests/test_litellm/batches/test_batch_utils.py b/tests/test_litellm/batches/test_batch_utils.py
index c7aecac477e..fea897e9372 100644
--- a/tests/test_litellm/batches/test_batch_utils.py
+++ b/tests/test_litellm/batches/test_batch_utils.py
@@ -14,6 +14,7 @@ maps (litellm.completion_cost, batch_cost_calculator), the tokenizer
deterministic stand-ins so the arithmetic under test is the only variable.
"""
+import json
import os
import sys
@@ -615,10 +616,117 @@ def _batch(output_file_id):
)
+def _vertex_openai_row(custom_id, model, prompt_tokens, completion_tokens):
+ return {
+ "id": f"batch_req_{custom_id}",
+ "custom_id": custom_id,
+ "response": {
+ "status_code": 200,
+ "request_id": custom_id,
+ "body": {
+ "id": f"chatcmpl-{custom_id}",
+ "object": "chat.completion",
+ "model": model,
+ "choices": [
+ {
+ "index": 0,
+ "message": {"role": "assistant", "content": "ok"},
+ "finish_reason": "stop",
+ }
+ ],
+ "usage": _usage(prompt_tokens, completion_tokens),
+ },
+ },
+ "error": None,
+ }
+
+
+def _vertex_jsonl(rows):
+ return "\n".join(json.dumps(row) for row in rows).encode()
+
+
@pytest.mark.asyncio
-async def test_output_file_content_vertex_raises():
- with pytest.raises(ValueError, match="Vertex AI does not support"):
- await bu._get_batch_output_file_content_as_dictionary(_batch("of"), custom_llm_provider="vertex_ai")
+async def test_output_file_content_vertex_fetches_via_afile_content(monkeypatch):
+ import litellm.files.main as files_main
+
+ rows = [_vertex_openai_row("request-1", "gemini-3.6-flash", 10, 5)]
+ captured: dict = {}
+
+ async def fake_afile_content(**kw):
+ captured.update(kw)
+ return type("R", (), {"content": _vertex_jsonl(rows)})()
+
+ monkeypatch.setattr(files_main, "afile_content", fake_afile_content)
+
+ result = await bu._get_batch_output_file_content_as_dictionary(
+ _batch("gs://litellm-bucket/output/predictions.jsonl"),
+ custom_llm_provider="vertex_ai",
+ litellm_params={
+ "vertex_project": "proj-1",
+ "vertex_location": "us-central1",
+ "vertex_credentials": "/path/to/creds.json",
+ "model": "vertex_ai/gemini-3.6-flash",
+ },
+ )
+
+ assert result == rows
+ assert captured["file_id"] == "gs://litellm-bucket/output/predictions.jsonl"
+ assert captured["custom_llm_provider"] == "vertex_ai"
+ assert captured["vertex_project"] == "proj-1"
+ assert captured["vertex_location"] == "us-central1"
+ assert captured["vertex_credentials"] == "/path/to/creds.json"
+ assert "model" not in captured
+
+
+@pytest.mark.asyncio
+async def test_output_file_content_vertex_unified_file_id_extracts_gcs_uri(monkeypatch):
+ import base64
+
+ import litellm.files.main as files_main
+
+ captured: dict = {}
+
+ async def fake_afile_content(**kw):
+ captured.update(kw)
+ return type("R", (), {"content": b'{"a": 1}'})()
+
+ monkeypatch.setattr(files_main, "afile_content", fake_afile_content)
+ unified_id = (
+ "litellm_proxy:application/jsonl;unified_id,uuid-1;target_model_names,vertex-model;"
+ "llm_output_file_id,gs://litellm-bucket/output/predictions.jsonl;llm_output_file_model_id,model-1"
+ )
+ encoded_id = base64.urlsafe_b64encode(unified_id.encode()).decode().rstrip("=")
+
+ await bu._get_batch_output_file_content_as_dictionary(_batch(encoded_id), custom_llm_provider="vertex_ai")
+
+ assert captured["file_id"] == "gs://litellm-bucket/output/predictions.jsonl"
+ assert captured["custom_llm_provider"] == "vertex_ai"
+
+
+@pytest.mark.asyncio
+async def test_handle_completed_vertex_batch_computes_cost_usage_and_models(monkeypatch):
+ import litellm.files.main as files_main
+
+ rows = [
+ _vertex_openai_row("request-1", "gemini-3.6-flash", 10, 5),
+ _vertex_openai_row("request-2", "gemini-3.6-flash", 20, 10),
+ ]
+
+ async def fake_afile_content(**kw):
+ return type("R", (), {"content": _vertex_jsonl(rows)})()
+
+ monkeypatch.setattr(files_main, "afile_content", fake_afile_content)
+
+ cost, usage, models = await bu._handle_completed_batch(
+ _batch("gs://litellm-bucket/output/predictions.jsonl"),
+ custom_llm_provider="vertex_ai",
+ litellm_params={"vertex_project": "proj-1", "vertex_location": "us-central1"},
+ )
+
+ assert cost > 0
+ assert cost == pytest.approx(30 * 7.5e-07 + 15 * 3.75e-06)
+ assert (usage.prompt_tokens, usage.completion_tokens, usage.total_tokens) == (30, 15, 45)
+ assert models == ["gemini-3.6-flash", "gemini-3.6-flash"]
@pytest.mark.asyncio
From 27ccc444715ff9c8e98ddc2d102ff92c4e703962 Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Wed, 29 Jul 2026 20:44:31 -0700
Subject: [PATCH 07/33] fix(batches): forward gcs_bucket_name so vertex batch
cost logging can read the output file
---
litellm/batches/batch_utils.py | 2 ++
tests/test_litellm/batches/test_batch_utils.py | 8 ++++++++
2 files changed, 10 insertions(+)
diff --git a/litellm/batches/batch_utils.py b/litellm/batches/batch_utils.py
index 36076d59a35..b62331b904b 100644
--- a/litellm/batches/batch_utils.py
+++ b/litellm/batches/batch_utils.py
@@ -267,6 +267,8 @@ def _extract_file_access_credentials(litellm_params: Optional[dict]) -> dict:
"vertex_project",
"vertex_location",
"vertex_credentials",
+ "gcs_bucket_name",
+ "bucket_name",
"timeout",
"max_retries",
]
diff --git a/tests/test_litellm/batches/test_batch_utils.py b/tests/test_litellm/batches/test_batch_utils.py
index fea897e9372..70c89f70ceb 100644
--- a/tests/test_litellm/batches/test_batch_utils.py
+++ b/tests/test_litellm/batches/test_batch_utils.py
@@ -236,6 +236,8 @@ def test_extract_credentials_only_known_keys():
"api_key": "sk-1",
"api_base": "https://b",
"vertex_project": "proj",
+ "gcs_bucket_name": "my-bucket",
+ "bucket_name": "my-alias-bucket",
"model": "gpt-4o", # not a credential key
"unrelated": "x",
}
@@ -243,6 +245,8 @@ def test_extract_credentials_only_known_keys():
"api_key": "sk-1",
"api_base": "https://b",
"vertex_project": "proj",
+ "gcs_bucket_name": "my-bucket",
+ "bucket_name": "my-alias-bucket",
}
@@ -262,6 +266,8 @@ def test_extract_credentials_all_supported_keys():
"vertex_project",
"vertex_location",
"vertex_credentials",
+ "gcs_bucket_name",
+ "bucket_name",
"timeout",
"max_retries",
}
@@ -665,6 +671,7 @@ async def test_output_file_content_vertex_fetches_via_afile_content(monkeypatch)
"vertex_project": "proj-1",
"vertex_location": "us-central1",
"vertex_credentials": "/path/to/creds.json",
+ "gcs_bucket_name": "litellm-bucket",
"model": "vertex_ai/gemini-3.6-flash",
},
)
@@ -675,6 +682,7 @@ async def test_output_file_content_vertex_fetches_via_afile_content(monkeypatch)
assert captured["vertex_project"] == "proj-1"
assert captured["vertex_location"] == "us-central1"
assert captured["vertex_credentials"] == "/path/to/creds.json"
+ assert captured["gcs_bucket_name"] == "litellm-bucket"
assert "model" not in captured
From 0b09588685d8bc4b227e1dcdb11d43f4d379adab Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Wed, 29 Jul 2026 22:00:01 -0700
Subject: [PATCH 08/33] refactor(batches): aggregate batch output cost, usage,
and models in a single pass
Completed-batch cost tracking parsed the whole output file into a list of
dicts, pretty-printed it into debug strings even with debug logging off, and
walked the list three times (cost, usage, models), so a large batch output
could pin a worker's memory. The output is now folded line by line into small
per-line stats records via _aggregate_batch_cost_usage_models, the eager
json.dumps debug calls are gone, and the raw-vertex path computes cost and
usage in one call instead of two. _get_batch_output_file_content_as_dictionary
becomes _fetch_batch_output_file_content (returns bytes); the superseded
three-pass helpers are deleted and their tests migrated
---
basedpyright-code-budget.json | 14 +-
litellm/batches/batch_utils.py | 294 +++++++-----------
ruff-strict-budget.json | 12 +-
.../test_batch_custom_pricing.py | 27 +-
tests/batches_tests/test_batch_rate_limits.py | 6 +-
.../test_batches_logging_unit_tests.py | 22 +-
.../test_litellm/batches/test_batch_utils.py | 223 +++++++------
.../test_vertex_ai_batch_passthrough.py | 38 +--
type-discipline-budget.json | 2 +-
9 files changed, 294 insertions(+), 344 deletions(-)
diff --git a/basedpyright-code-budget.json b/basedpyright-code-budget.json
index db3c2502e94..3259f519f66 100644
--- a/basedpyright-code-budget.json
+++ b/basedpyright-code-budget.json
@@ -1,6 +1,6 @@
{
"reportAny": {
- "limit": 33216
+ "limit": 33210
},
"reportArgumentType": {
"limit": 2648
@@ -57,7 +57,7 @@
"limit": 5893
},
"reportMissingTypeArgument": {
- "limit": 15886
+ "limit": 15883
},
"reportMissingTypeStubs": {
"limit": 41
@@ -105,19 +105,19 @@
"limit": 113
},
"reportUnknownMemberType": {
- "limit": 40525
+ "limit": 40523
},
"reportUnknownParameterType": {
- "limit": 20384
+ "limit": 20381
},
"reportUnknownVariableType": {
- "limit": 32099
+ "limit": 32095
},
"reportUnnecessaryCast": {
"limit": 177
},
"reportUnnecessaryComparison": {
- "limit": 1023
+ "limit": 1022
},
"reportUnnecessaryContains": {
"limit": 7
@@ -135,7 +135,7 @@
"limit": 33
},
"reportUnusedFunction": {
- "limit": 206
+ "limit": 205
},
"reportUnusedImport": {
"limit": 1005
diff --git a/litellm/batches/batch_utils.py b/litellm/batches/batch_utils.py
index b62331b904b..eef4cf8d87f 100644
--- a/litellm/batches/batch_utils.py
+++ b/litellm/batches/batch_utils.py
@@ -1,5 +1,6 @@
import json
-from typing import Any, Iterator, List, Literal, Optional, Tuple
+from dataclasses import dataclass
+from typing import Any, Iterable, Iterator, List, Literal, Optional, Tuple
import litellm
from litellm._logging import verbose_logger
@@ -24,20 +25,20 @@ async def calculate_batch_cost_and_usage(
deployment-specific pricing (e.g. input_cost_per_token_batches)
is used instead of the global cost map.
"""
- batch_cost = _batch_cost_calculator(
+ if (
+ custom_llm_provider == "vertex_ai"
+ and model_name
+ and getattr(litellm, "disable_vertex_batch_output_transformation", False)
+ ):
+ batch_cost, batch_usage = calculate_vertex_ai_batch_cost_and_usage(file_content_dictionary, model_name)
+ return batch_cost, batch_usage, [model_name]
+
+ return _aggregate_batch_cost_usage_models(
+ entries=file_content_dictionary,
custom_llm_provider=custom_llm_provider,
- file_content_dictionary=file_content_dictionary,
model_name=model_name,
model_info=model_info,
)
- batch_usage = _get_batch_job_total_usage_from_file_content(
- file_content_dictionary=file_content_dictionary,
- custom_llm_provider=custom_llm_provider,
- model_name=model_name,
- )
- batch_models = _get_batch_models_from_file_content(file_content_dictionary, model_name, custom_llm_provider)
-
- return batch_cost, batch_usage, batch_models
async def _handle_completed_batch(
@@ -46,7 +47,9 @@ async def _handle_completed_batch(
model_name: Optional[str] = None,
litellm_params: Optional[dict] = None,
) -> Tuple[float, Usage, List[str]]:
- """Helper function to process a completed batch and handle logging
+ """Fetch a completed batch's output file and aggregate its cost, usage, and
+ models in a single pass over the JSONL lines, so the parsed file content is
+ never materialized in memory.
Args:
batch: The batch object
@@ -54,75 +57,109 @@ async def _handle_completed_batch(
model_name: Optional model name
litellm_params: Optional litellm parameters containing credentials (api_key, api_base, etc.)
"""
- # Get batch results
- file_content_dictionary = await _get_batch_output_file_content_as_dictionary(
- batch, custom_llm_provider, litellm_params=litellm_params
- )
+ file_content = await _fetch_batch_output_file_content(batch, custom_llm_provider, litellm_params=litellm_params)
- # Calculate costs and usage
- batch_cost = _batch_cost_calculator(
- custom_llm_provider=custom_llm_provider,
- file_content_dictionary=file_content_dictionary,
- model_name=model_name,
- )
- batch_usage = _get_batch_job_total_usage_from_file_content(
- file_content_dictionary=file_content_dictionary,
- custom_llm_provider=custom_llm_provider,
- model_name=model_name,
- )
-
- batch_models = _get_batch_models_from_file_content(file_content_dictionary, model_name, custom_llm_provider)
-
- return batch_cost, batch_usage, batch_models
-
-
-def _get_batch_models_from_file_content(
- file_content_dictionary: List[dict],
- model_name: Optional[str] = None,
- custom_llm_provider: str = "openai",
-) -> List[str]:
- """
- Get the models from the file content
- """
- if model_name:
- return [model_name]
- batch_models = []
- for _item in file_content_dictionary:
- if _batch_response_was_successful(_item, custom_llm_provider):
- _response_body = _get_response_from_batch_job_output_file(_item, custom_llm_provider)
- _model = _response_body.get("model")
- if _model:
- batch_models.append(_model)
- return batch_models
-
-
-def _batch_cost_calculator(
- file_content_dictionary: List[dict],
- custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic"] = "openai",
- model_name: Optional[str] = None,
- model_info: Optional[ModelInfo] = None,
-) -> float:
- """
- Calculate the cost of a batch based on the output file id
- """
if (
custom_llm_provider == "vertex_ai"
and model_name
and getattr(litellm, "disable_vertex_batch_output_transformation", False)
):
- batch_cost, _ = calculate_vertex_ai_batch_cost_and_usage(file_content_dictionary, model_name)
- verbose_logger.debug("vertex_ai_total_cost=%s", batch_cost)
- return batch_cost
+ batch_cost, batch_usage = calculate_vertex_ai_batch_cost_and_usage(
+ _get_file_content_as_dictionary(file_content), model_name
+ )
+ return batch_cost, batch_usage, [model_name]
- # For other providers, use the existing logic
- total_cost = _get_batch_job_cost_from_file_content(
- file_content_dictionary=file_content_dictionary,
+ return _aggregate_batch_cost_usage_models(
+ entries=_iter_batch_input_entries(file_content),
custom_llm_provider=custom_llm_provider,
model_name=model_name,
- model_info=model_info,
)
- verbose_logger.debug("total_cost=%s", total_cost)
- return total_cost
+
+
+@dataclass(frozen=True, slots=True)
+class _BatchOutputLineStats:
+ cost: float
+ prompt_tokens: int
+ completion_tokens: int
+ total_tokens: int
+ cache_read_tokens: int
+ cache_creation_tokens: int
+ model: Optional[str]
+
+
+def _iter_successful_output_line_stats(
+ entries: Iterable[dict],
+ custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic", "bedrock"],
+ model_name: Optional[str],
+ model_info: Optional[ModelInfo],
+) -> Iterator[_BatchOutputLineStats]:
+ from litellm.cost_calculator import batch_cost_calculator
+
+ for entry in entries:
+ if not _batch_response_was_successful(entry, custom_llm_provider):
+ continue
+ response_body = _get_response_from_batch_job_output_file(entry, custom_llm_provider)
+ usage = _get_batch_job_usage_from_response_body(response_body, custom_llm_provider)
+ prompt_details = _parse_prompt_tokens_details(usage)
+ raw_model = response_body.get("model")
+ response_model = raw_model if isinstance(raw_model, str) and raw_model else None
+ if model_info is not None or custom_llm_provider in ("anthropic", "bedrock"):
+ if custom_llm_provider == "bedrock" and model_name:
+ cost_model = model_name
+ else:
+ cost_model = response_model or model_name or ""
+ prompt_cost, completion_cost = batch_cost_calculator(
+ usage=usage,
+ model=cost_model,
+ custom_llm_provider=custom_llm_provider,
+ model_info=model_info,
+ )
+ line_cost = prompt_cost + completion_cost
+ else:
+ line_cost = litellm.completion_cost(
+ completion_response=response_body,
+ custom_llm_provider=custom_llm_provider,
+ call_type=CallTypes.aretrieve_batch.value,
+ )
+ yield _BatchOutputLineStats(
+ cost=line_cost,
+ prompt_tokens=usage.prompt_tokens,
+ completion_tokens=usage.completion_tokens,
+ total_tokens=usage.total_tokens,
+ cache_read_tokens=prompt_details["cache_hit_tokens"],
+ cache_creation_tokens=prompt_details["cache_creation_tokens"],
+ model=response_model,
+ )
+
+
+def _aggregate_batch_cost_usage_models(
+ entries: Iterable[dict],
+ custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic", "bedrock"],
+ model_name: Optional[str] = None,
+ model_info: Optional[ModelInfo] = None,
+) -> Tuple[float, Usage, List[str]]:
+ """Aggregate cost, usage, and models from batch output entries in a single
+ pass, holding one small stats record per line instead of the parsed file."""
+ line_stats = tuple(_iter_successful_output_line_stats(entries, custom_llm_provider, model_name, model_info))
+
+ cache_token_params = {
+ key: tokens
+ for key, tokens in (
+ ("cache_read_input_tokens", sum(stats.cache_read_tokens for stats in line_stats)),
+ ("cache_creation_input_tokens", sum(stats.cache_creation_tokens for stats in line_stats)),
+ )
+ if tokens > 0
+ }
+ batch_usage = Usage(
+ total_tokens=sum(stats.total_tokens for stats in line_stats),
+ prompt_tokens=sum(stats.prompt_tokens for stats in line_stats),
+ completion_tokens=sum(stats.completion_tokens for stats in line_stats),
+ **cache_token_params,
+ )
+ batch_models = [model_name] if model_name else [stats.model for stats in line_stats if stats.model]
+ total_cost = sum((stats.cost for stats in line_stats), 0.0)
+ verbose_logger.debug("batch output aggregate: cost=%s usage=%s models=%s", total_cost, batch_usage, batch_models)
+ return total_cost, batch_usage, batch_models
def calculate_vertex_ai_batch_cost_and_usage(
@@ -193,13 +230,13 @@ def calculate_vertex_ai_batch_cost_and_usage(
)
-async def _get_batch_output_file_content_as_dictionary(
+async def _fetch_batch_output_file_content(
batch: Batch,
custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic"] = "openai",
litellm_params: Optional[dict] = None,
-) -> List[dict]:
+) -> bytes:
"""
- Get the batch output file content as a list of dictionaries
+ Fetch the batch output file and return its raw JSONL bytes
Args:
batch: The batch object
@@ -237,7 +274,7 @@ async def _get_batch_output_file_content_as_dictionary(
file_content_kwargs.update(credentials)
_file_content = await afile_content(**file_content_kwargs) # type: ignore[reportArgumentType]
- return _get_file_content_as_dictionary(_file_content.content)
+ return _file_content.content
def _extract_file_access_credentials(litellm_params: Optional[dict]) -> dict:
@@ -283,17 +320,7 @@ def _get_file_content_as_dictionary(file_content: bytes) -> List[dict]:
"""
Get the file content as a list of dictionaries from JSON Lines format
"""
- try:
- _file_content_str = file_content.decode("utf-8")
- # Split by newlines and parse each line as a separate JSON object
- json_objects = []
- for line in _file_content_str.strip().split("\n"):
- if line: # Skip empty lines
- json_objects.append(json.loads(line))
- verbose_logger.debug("json_objects=%s", json.dumps(json_objects, indent=4))
- return json_objects
- except Exception as e:
- raise e
+ return list(_iter_batch_input_entries(file_content))
def _iter_batch_input_lines(file_content: bytes) -> Iterator[bytes]:
@@ -360,101 +387,6 @@ def _count_entry_tokens(
return 0
-def _get_batch_job_cost_from_file_content(
- file_content_dictionary: List[dict],
- custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic"] = "openai",
- model_name: Optional[str] = None,
- model_info: Optional[ModelInfo] = None,
-) -> float:
- """
- Get the cost of a batch job from the file content
- """
- from litellm.cost_calculator import batch_cost_calculator
-
- try:
- total_cost: float = 0.0
- # parse the file content as json
- verbose_logger.debug("file_content_dictionary=%s", json.dumps(file_content_dictionary, indent=4))
- for _item in file_content_dictionary:
- if _batch_response_was_successful(_item, custom_llm_provider):
- _response_body = _get_response_from_batch_job_output_file(_item, custom_llm_provider)
- if model_info is not None or custom_llm_provider in ("anthropic", "bedrock"):
- usage = _get_batch_job_usage_from_response_body(_response_body, custom_llm_provider)
- # Bedrock batch output lines report a short internal model id
- # (e.g. "claude-sonnet-4-6") that is not in the cost map; use the
- # deployment model name for pricing when available.
- if custom_llm_provider == "bedrock" and model_name:
- model = model_name
- else:
- model = _response_body.get("model") or model_name or ""
- prompt_cost, completion_cost = batch_cost_calculator(
- usage=usage,
- model=model,
- custom_llm_provider=custom_llm_provider,
- model_info=model_info,
- )
- total_cost += prompt_cost + completion_cost
- else:
- total_cost += litellm.completion_cost(
- completion_response=_response_body,
- custom_llm_provider=custom_llm_provider,
- call_type=CallTypes.aretrieve_batch.value,
- )
- verbose_logger.debug("total_cost=%s", total_cost)
- return total_cost
- except Exception as e:
- verbose_logger.error("error in _get_batch_job_cost_from_file_content", e)
- raise e
-
-
-def _get_batch_job_total_usage_from_file_content(
- file_content_dictionary: List[dict],
- custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic"] = "openai",
- model_name: Optional[str] = None,
-) -> Usage:
- """
- Get the tokens of a batch job from the file content
- """
- if (
- custom_llm_provider == "vertex_ai"
- and model_name
- and getattr(litellm, "disable_vertex_batch_output_transformation", False)
- ):
- _, batch_usage = calculate_vertex_ai_batch_cost_and_usage(file_content_dictionary, model_name)
- return batch_usage
-
- # For other providers, use the existing logic
- total_tokens: int = 0
- prompt_tokens: int = 0
- completion_tokens: int = 0
- cache_read_tokens: int = 0
- cache_creation_tokens: int = 0
- for _item in file_content_dictionary:
- if _batch_response_was_successful(_item, custom_llm_provider):
- _response_body = _get_response_from_batch_job_output_file(_item, custom_llm_provider)
- usage: Usage = _get_batch_job_usage_from_response_body(_response_body, custom_llm_provider)
- total_tokens += usage.total_tokens
- prompt_tokens += usage.prompt_tokens
- completion_tokens += usage.completion_tokens
- prompt_details = _parse_prompt_tokens_details(usage)
- cache_read_tokens += prompt_details["cache_hit_tokens"]
- cache_creation_tokens += prompt_details["cache_creation_tokens"]
- cache_token_params = {
- key: tokens
- for key, tokens in (
- ("cache_read_input_tokens", cache_read_tokens),
- ("cache_creation_input_tokens", cache_creation_tokens),
- )
- if tokens > 0
- }
- return Usage(
- total_tokens=total_tokens,
- prompt_tokens=prompt_tokens,
- completion_tokens=completion_tokens,
- **cache_token_params,
- )
-
-
def _count_prompt_or_input_tokens(model: str, value: Any) -> int:
"""Token-count a ``prompt`` / ``input`` field that the OpenAI batch
schema allows in four shapes:
diff --git a/ruff-strict-budget.json b/ruff-strict-budget.json
index f3b4fce97d3..6323dde9a9f 100644
--- a/ruff-strict-budget.json
+++ b/ruff-strict-budget.json
@@ -135,7 +135,7 @@
"limit": 30
},
"PERF401": {
- "limit": 144
+ "limit": 143
},
"PERF402": {
"limit": 9
@@ -315,16 +315,16 @@
"limit": 98
},
"TRY201": {
- "limit": 424
+ "limit": 422
},
"TRY203": {
- "limit": 123
+ "limit": 122
},
"TRY300": {
- "limit": 883
+ "limit": 881
},
"UP006": {
- "limit": 12147
+ "limit": 12143
},
"UP007": {
"limit": 2526
@@ -363,6 +363,6 @@
"limit": 105
},
"UP045": {
- "limit": 17824
+ "limit": 17823
}
}
diff --git a/tests/batches_tests/test_batch_custom_pricing.py b/tests/batches_tests/test_batch_custom_pricing.py
index 3dc1d116e8d..c2159b564a8 100644
--- a/tests/batches_tests/test_batch_custom_pricing.py
+++ b/tests/batches_tests/test_batch_custom_pricing.py
@@ -12,8 +12,7 @@ import litellm
import pytest
from litellm.batches.batch_utils import (
- _batch_cost_calculator,
- _get_batch_job_cost_from_file_content,
+ _aggregate_batch_cost_usage_models,
calculate_batch_cost_and_usage,
)
from litellm.cost_calculator import batch_cost_calculator
@@ -113,28 +112,12 @@ def test_batch_cost_calculator_uses_custom_model_info():
), f"Expected completion cost {expected_completion}, got {completion_cost}"
-def test_get_batch_job_cost_from_file_content_uses_custom_model_info():
- """_get_batch_job_cost_from_file_content should thread model_info to completion_cost."""
+def test_aggregate_batch_cost_uses_custom_model_info():
+ """_aggregate_batch_cost_usage_models should thread model_info to batch_cost_calculator."""
file_content = [_make_batch_output_line(prompt_tokens=10, completion_tokens=5)]
- cost = _get_batch_job_cost_from_file_content(
- file_content_dictionary=file_content,
- custom_llm_provider="openai",
- model_info=CUSTOM_MODEL_INFO,
- )
-
- expected = (10 * 0.00125) + (5 * 0.005)
- assert cost == pytest.approx(
- expected
- ), f"Expected total cost {expected}, got {cost}"
-
-
-def test_batch_cost_calculator_func_uses_custom_model_info():
- """_batch_cost_calculator should thread model_info."""
- file_content = [_make_batch_output_line(prompt_tokens=10, completion_tokens=5)]
-
- cost = _batch_cost_calculator(
- file_content_dictionary=file_content,
+ cost, _, _ = _aggregate_batch_cost_usage_models(
+ entries=file_content,
custom_llm_provider="openai",
model_info=CUSTOM_MODEL_INFO,
)
diff --git a/tests/batches_tests/test_batch_rate_limits.py b/tests/batches_tests/test_batch_rate_limits.py
index e1fe8782ef9..2c804d21ace 100644
--- a/tests/batches_tests/test_batch_rate_limits.py
+++ b/tests/batches_tests/test_batch_rate_limits.py
@@ -913,7 +913,7 @@ async def test_batch_logging_azure_credentials_regression():
from unittest.mock import AsyncMock, MagicMock, patch
from litellm.batches.batch_utils import (
_extract_file_access_credentials,
- _get_batch_output_file_content_as_dictionary,
+ _fetch_batch_output_file_content,
_handle_completed_batch,
)
from litellm.types.llms.openai import Batch, HttpxBinaryResponseContent
@@ -996,7 +996,7 @@ async def test_batch_logging_azure_credentials_regression():
with patch(
"litellm.files.main.afile_content", side_effect=mock_afile_content_tracker
):
- result = await _get_batch_output_file_content_as_dictionary(
+ result = await _fetch_batch_output_file_content(
batch=mock_batch,
custom_llm_provider="azure",
litellm_params=azure_credentials,
@@ -1092,7 +1092,7 @@ async def test_batch_logging_azure_credentials_regression():
)
# Call without litellm_params (should still work for OpenAI)
- result = await _get_batch_output_file_content_as_dictionary(
+ result = await _fetch_batch_output_file_content(
batch=mock_batch,
custom_llm_provider="openai",
litellm_params=None,
diff --git a/tests/batches_tests/test_batches_logging_unit_tests.py b/tests/batches_tests/test_batches_logging_unit_tests.py
index 33a0a87dd92..62b6f5b08e4 100644
--- a/tests/batches_tests/test_batches_logging_unit_tests.py
+++ b/tests/batches_tests/test_batches_logging_unit_tests.py
@@ -19,10 +19,8 @@ import litellm
from litellm import create_batch, create_file
from litellm._logging import verbose_logger
from litellm.batches.batch_utils import (
- _batch_cost_calculator,
+ _aggregate_batch_cost_usage_models,
_get_file_content_as_dictionary,
- _get_batch_job_cost_from_file_content,
- _get_batch_job_total_usage_from_file_content,
_get_batch_job_usage_from_response_body,
_get_response_from_batch_job_output_file,
_batch_response_was_successful,
@@ -139,9 +137,10 @@ def test_get_file_content_as_dictionary(sample_file_content):
def test_get_batch_job_total_usage_from_file_content(sample_file_content_dict):
- usage = _get_batch_job_total_usage_from_file_content(
- sample_file_content_dict, custom_llm_provider="openai"
- )
+ with patch("litellm.completion_cost", return_value=0.0):
+ _, usage, _ = _aggregate_batch_cost_usage_models(
+ entries=sample_file_content_dict, custom_llm_provider="openai"
+ )
assert usage.total_tokens == 62 # 30 + 32
assert usage.prompt_tokens == 42 # 20 + 22
assert usage.completion_tokens == 20 # 10 + 10
@@ -157,8 +156,8 @@ async def test_batch_cost_calculator(sample_file_content_dict):
so we expect the cost to be 0.5 * 2 = 1.0
"""
with patch("litellm.completion_cost", return_value=0.5):
- cost = _batch_cost_calculator(
- file_content_dictionary=sample_file_content_dict,
+ cost, _, _ = _aggregate_batch_cost_usage_models(
+ entries=sample_file_content_dict,
custom_llm_provider="openai",
)
assert cost == 1.0 # 0.5 * 2 successful responses
@@ -278,9 +277,12 @@ async def test_handle_completed_batch_computes_real_cost_from_output_file(
created_at=1234567890,
)
+ sample_file_content_bytes = "\n".join(
+ json.dumps(row) for row in sample_file_content_dict
+ ).encode()
with patch(
- "litellm.batches.batch_utils._get_batch_output_file_content_as_dictionary",
- new=AsyncMock(return_value=sample_file_content_dict),
+ "litellm.batches.batch_utils._fetch_batch_output_file_content",
+ new=AsyncMock(return_value=sample_file_content_bytes),
):
cost, usage, models = await _handle_completed_batch(
batch=batch, custom_llm_provider="openai"
diff --git a/tests/test_litellm/batches/test_batch_utils.py b/tests/test_litellm/batches/test_batch_utils.py
index 70c89f70ceb..fb9a9a9d040 100644
--- a/tests/test_litellm/batches/test_batch_utils.py
+++ b/tests/test_litellm/batches/test_batch_utils.py
@@ -201,29 +201,34 @@ def test_estimate_tokens_never_zero_for_short_rows():
# =========================================================================== #
-# _get_batch_models_from_file_content (output file)
+# _aggregate_batch_cost_usage_models: models (output file)
# =========================================================================== #
-def test_output_models_uses_model_name_override():
- # model_name short-circuits: content is ignored entirely.
- assert bu._get_batch_models_from_file_content([_success_row(model="ignored")], model_name="forced-model") == [
- "forced-model"
- ]
+def test_output_models_uses_model_name_override(monkeypatch):
+ monkeypatch.setattr(litellm, "completion_cost", lambda **kw: 0.0)
+ _, _, models = bu._aggregate_batch_cost_usage_models(
+ entries=[_success_row(model="ignored")], custom_llm_provider="openai", model_name="forced-model"
+ )
+ assert models == ["forced-model"]
-def test_output_models_collects_from_successful_only():
+def test_output_models_collects_from_successful_only(monkeypatch):
+ monkeypatch.setattr(litellm, "completion_cost", lambda **kw: 0.0)
rows = [
_success_row(model="gpt-4o"),
_failed_row(model="should-be-skipped"),
_success_row(model="claude-3"),
]
- assert bu._get_batch_models_from_file_content(rows) == ["gpt-4o", "claude-3"]
+ _, _, models = bu._aggregate_batch_cost_usage_models(entries=rows, custom_llm_provider="openai")
+ assert models == ["gpt-4o", "claude-3"]
-def test_output_models_skips_successful_without_model():
+def test_output_models_skips_successful_without_model(monkeypatch):
+ monkeypatch.setattr(litellm, "completion_cost", lambda **kw: 0.0)
rows = [{"response": {"status_code": 200, "body": {}}}]
- assert bu._get_batch_models_from_file_content(rows) == []
+ _, _, models = bu._aggregate_batch_cost_usage_models(entries=rows, custom_llm_provider="openai")
+ assert models == []
# =========================================================================== #
@@ -379,17 +384,18 @@ def test_count_entry_uses_model_name_fallback(monkeypatch):
# =========================================================================== #
-# _get_batch_job_total_usage_from_file_content (output usage aggregation)
+# _aggregate_batch_cost_usage_models: usage (output usage aggregation)
# =========================================================================== #
-def test_total_usage_sums_successful_only():
+def test_total_usage_sums_successful_only(monkeypatch):
+ monkeypatch.setattr(litellm, "completion_cost", lambda **kw: 0.0)
rows = [
_success_row(usage=_usage(10, 5)), # 15
_failed_row(), # excluded
_success_row(usage=_usage(20, 10)), # 30
]
- usage = bu._get_batch_job_total_usage_from_file_content(rows)
+ _, usage, _ = bu._aggregate_batch_cost_usage_models(entries=rows, custom_llm_provider="openai")
assert (usage.prompt_tokens, usage.completion_tokens, usage.total_tokens) == (
30,
15,
@@ -398,7 +404,9 @@ def test_total_usage_sums_successful_only():
def test_total_usage_empty_is_zero():
- usage = bu._get_batch_job_total_usage_from_file_content([])
+ cost, usage, models = bu._aggregate_batch_cost_usage_models(entries=[], custom_llm_provider="openai")
+ assert cost == 0.0
+ assert models == []
assert (usage.prompt_tokens, usage.completion_tokens, usage.total_tokens) == (
0,
0,
@@ -407,7 +415,7 @@ def test_total_usage_empty_is_zero():
# =========================================================================== #
-# _get_batch_job_cost_from_file_content (cost maps mocked)
+# _aggregate_batch_cost_usage_models: cost (cost maps mocked)
# =========================================================================== #
@@ -426,7 +434,7 @@ def test_cost_from_content_completion_cost_path(monkeypatch):
_success_row(usage=_usage(20, 10)),
]
- total = bu._get_batch_job_cost_from_file_content(rows, custom_llm_provider="openai")
+ total, _, _ = bu._aggregate_batch_cost_usage_models(entries=rows, custom_llm_provider="openai")
assert total == 1.0 # 2 successful * 0.5
assert len(calls) == 2 # failed row not costed
@@ -442,8 +450,8 @@ def test_cost_from_content_model_info_path(monkeypatch):
_success_row(usage=_usage(20, 10)),
]
- total = bu._get_batch_job_cost_from_file_content(
- rows,
+ total, _, _ = bu._aggregate_batch_cost_usage_models(
+ entries=rows,
custom_llm_provider="openai",
model_info={"input_cost_per_token": 0.0}, # type: ignore[arg-type] # truthy -> model_info path
)
@@ -451,32 +459,65 @@ def test_cost_from_content_model_info_path(monkeypatch):
assert total == pytest.approx(0.6) # 2 * (0.1 + 0.2)
+def test_aggregate_consumes_entries_in_a_single_pass(monkeypatch):
+ """A one-shot generator: any implementation that iterates the entries twice
+ (e.g. separate cost and usage passes) sees nothing on the second pass and
+ returns wrong totals for at least one of cost/usage/models."""
+ monkeypatch.setattr(litellm, "completion_cost", lambda **kw: 0.5)
+ one_shot = (row for row in [_success_row(usage=_usage(10, 5)), _failed_row(), _success_row(usage=_usage(20, 10))])
+
+ cost, usage, models = bu._aggregate_batch_cost_usage_models(entries=one_shot, custom_llm_provider="openai")
+
+ assert cost == 1.0
+ assert (usage.prompt_tokens, usage.completion_tokens, usage.total_tokens) == (30, 15, 45)
+ assert models == ["gpt-4o", "gpt-4o"]
+
+
# =========================================================================== #
-# _batch_cost_calculator (dispatch: vertex-disable-transform vs generic)
+# calculate_batch_cost_and_usage (dispatch: vertex-disable-transform vs generic)
# =========================================================================== #
-def test_batch_cost_calculator_generic_path(monkeypatch):
- monkeypatch.setattr(bu, "_get_batch_job_cost_from_file_content", lambda **kw: 4.2)
- assert bu._batch_cost_calculator([], custom_llm_provider="openai", model_name="gpt-4o") == 4.2
-
-
-def test_batch_cost_calculator_vertex_disable_transform_path(monkeypatch):
+@pytest.mark.asyncio
+async def test_calculate_vertex_disable_transform_path(monkeypatch):
monkeypatch.setattr(litellm, "disable_vertex_batch_output_transformation", True, raising=False)
monkeypatch.setattr(
bu,
"calculate_vertex_ai_batch_cost_and_usage",
- lambda content, model: (9.9, Usage()),
+ lambda content, model: (9.9, Usage(prompt_tokens=1, completion_tokens=2, total_tokens=3)),
)
# generic path must NOT be taken
monkeypatch.setattr(
bu,
- "_get_batch_job_cost_from_file_content",
+ "_aggregate_batch_cost_usage_models",
lambda **kw: pytest.fail("generic path should not run"),
)
- cost = bu._batch_cost_calculator([], custom_llm_provider="vertex_ai", model_name="gemini-2.0-flash-001")
+ cost, usage, models = await bu.calculate_batch_cost_and_usage(
+ file_content_dictionary=[], custom_llm_provider="vertex_ai", model_name="gemini-2.0-flash-001"
+ )
assert cost == 9.9
+ assert usage.total_tokens == 3
+ assert models == ["gemini-2.0-flash-001"]
+
+
+@pytest.mark.asyncio
+async def test_calculate_vertex_disable_transform_needs_model_name(monkeypatch):
+ """Without a model_name the raw-vertex path cannot price lines; the generic
+ aggregation path must run even with the disable flag set."""
+ monkeypatch.setattr(litellm, "disable_vertex_batch_output_transformation", True, raising=False)
+ monkeypatch.setattr(
+ bu,
+ "calculate_vertex_ai_batch_cost_and_usage",
+ lambda content, model: pytest.fail("raw vertex path should not run"),
+ )
+
+ cost, usage, models = await bu.calculate_batch_cost_and_usage(
+ file_content_dictionary=[], custom_llm_provider="vertex_ai"
+ )
+ assert cost == 0.0
+ assert usage.total_tokens == 0
+ assert models == []
# =========================================================================== #
@@ -586,24 +627,19 @@ def test_vertex_cost_error_in_line_is_swallowed(monkeypatch):
@pytest.mark.asyncio
async def test_calculate_batch_cost_and_usage_orchestration(monkeypatch):
rows = [_success_row(model="gpt-4o", usage=_usage(10, 5))]
- monkeypatch.setattr(bu, "_batch_cost_calculator", lambda **kw: 2.5)
- monkeypatch.setattr(
- bu,
- "_get_batch_job_total_usage_from_file_content",
- lambda **kw: Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15),
- )
+ monkeypatch.setattr(litellm, "completion_cost", lambda **kw: 2.5)
cost, usage, models = await bu.calculate_batch_cost_and_usage(
file_content_dictionary=rows, custom_llm_provider="openai"
)
assert cost == 2.5
- assert usage.total_tokens == 15
- assert models == ["gpt-4o"] # real _get_batch_models_from_file_content
+ assert (usage.prompt_tokens, usage.completion_tokens, usage.total_tokens) == (10, 5, 15)
+ assert models == ["gpt-4o"]
# =========================================================================== #
-# _get_batch_output_file_content_as_dictionary (file fetch + credential merge)
+# _fetch_batch_output_file_content (file fetch + credential merge)
# =========================================================================== #
@@ -664,7 +700,7 @@ async def test_output_file_content_vertex_fetches_via_afile_content(monkeypatch)
monkeypatch.setattr(files_main, "afile_content", fake_afile_content)
- result = await bu._get_batch_output_file_content_as_dictionary(
+ result = await bu._fetch_batch_output_file_content(
_batch("gs://litellm-bucket/output/predictions.jsonl"),
custom_llm_provider="vertex_ai",
litellm_params={
@@ -676,7 +712,7 @@ async def test_output_file_content_vertex_fetches_via_afile_content(monkeypatch)
},
)
- assert result == rows
+ assert bu._get_file_content_as_dictionary(result) == rows
assert captured["file_id"] == "gs://litellm-bucket/output/predictions.jsonl"
assert captured["custom_llm_provider"] == "vertex_ai"
assert captured["vertex_project"] == "proj-1"
@@ -705,7 +741,7 @@ async def test_output_file_content_vertex_unified_file_id_extracts_gcs_uri(monke
)
encoded_id = base64.urlsafe_b64encode(unified_id.encode()).decode().rstrip("=")
- await bu._get_batch_output_file_content_as_dictionary(_batch(encoded_id), custom_llm_provider="vertex_ai")
+ await bu._fetch_batch_output_file_content(_batch(encoded_id), custom_llm_provider="vertex_ai")
assert captured["file_id"] == "gs://litellm-bucket/output/predictions.jsonl"
assert captured["custom_llm_provider"] == "vertex_ai"
@@ -740,7 +776,7 @@ async def test_handle_completed_vertex_batch_computes_cost_usage_and_models(monk
@pytest.mark.asyncio
async def test_output_file_content_no_output_file_id_raises():
with pytest.raises(ValueError, match="Output file id is None"):
- await bu._get_batch_output_file_content_as_dictionary(_batch(None), custom_llm_provider="openai")
+ await bu._fetch_batch_output_file_content(_batch(None), custom_llm_provider="openai")
@pytest.mark.asyncio
@@ -757,13 +793,13 @@ async def test_output_file_content_fetches_and_parses(monkeypatch):
monkeypatch.setattr(files_main, "afile_content", fake_afile_content)
monkeypatch.setattr(cu, "_is_base64_encoded_unified_file_id", lambda fid: False)
- result = await bu._get_batch_output_file_content_as_dictionary(
+ result = await bu._fetch_batch_output_file_content(
_batch("file-out"),
custom_llm_provider="azure",
litellm_params={"api_key": "sk-az", "api_base": "https://az", "model": "x"},
)
- assert result == [{"a": 1}, {"b": 2}]
+ assert result == b'{"a": 1}\n{"b": 2}'
# afile_content received the file id + extracted credentials (not "model").
assert captured["file_id"] == "file-out"
assert captured["custom_llm_provider"] == "azure"
@@ -792,13 +828,13 @@ async def test_output_file_content_unified_file_id_extraction(monkeypatch):
lambda fid: "litellm_proxy;llm_output_file_id,real-file-99;rest",
)
- await bu._get_batch_output_file_content_as_dictionary(_batch("encoded-blob"), custom_llm_provider="openai")
+ await bu._fetch_batch_output_file_content(_batch("encoded-blob"), custom_llm_provider="openai")
assert captured["file_id"] == "real-file-99"
# =========================================================================== #
-# _handle_completed_batch (async orchestrator: fetch -> cost/usage/models)
+# _handle_completed_batch (async orchestrator: fetch -> single-pass aggregate)
# =========================================================================== #
@@ -806,49 +842,48 @@ async def test_output_file_content_unified_file_id_extraction(monkeypatch):
async def test_handle_completed_batch_orchestration(monkeypatch):
rows = [_success_row(model="gpt-4o", usage=_usage(10, 5))]
- async def fake_get_content(batch, custom_llm_provider, litellm_params=None):
- return rows
+ async def fake_fetch(batch, custom_llm_provider, litellm_params=None):
+ return _vertex_jsonl(rows)
- monkeypatch.setattr(bu, "_get_batch_output_file_content_as_dictionary", fake_get_content)
- monkeypatch.setattr(bu, "_batch_cost_calculator", lambda **kw: 3.3)
- monkeypatch.setattr(
- bu,
- "_get_batch_job_total_usage_from_file_content",
- lambda **kw: Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15),
- )
+ monkeypatch.setattr(bu, "_fetch_batch_output_file_content", fake_fetch)
+ monkeypatch.setattr(litellm, "completion_cost", lambda **kw: 3.3)
cost, usage, models = await bu._handle_completed_batch(_batch("of"), custom_llm_provider="openai")
assert cost == 3.3
- assert usage.total_tokens == 15
+ assert (usage.prompt_tokens, usage.completion_tokens, usage.total_tokens) == (10, 5, 15)
assert models == ["gpt-4o"]
-# =========================================================================== #
-# Remaining branch: vertex usage disable-transform path.
-#
-# NOTE: the error path of _get_batch_job_cost_from_file_content (its `raise e`)
-# is intentionally NOT tested: the preceding line logs via
-# `verbose_logger.error("...", e)`, which passes the exception as a logging
-# format-arg with no placeholder and itself raises TypeError under
-# logging.raiseExceptions, masking the original error. Asserting that masked
-# behavior would lock a source bug; left uncovered on purpose.
-# =========================================================================== #
+@pytest.mark.asyncio
+async def test_handle_completed_batch_vertex_disable_transform_path(monkeypatch):
+ raw_rows = [{"response": {"usageMetadata": {"promptTokenCount": 1, "candidatesTokenCount": 2}}}]
+ async def fake_fetch(batch, custom_llm_provider, litellm_params=None):
+ return _vertex_jsonl(raw_rows)
-def test_total_usage_vertex_disable_transform_path(monkeypatch):
+ monkeypatch.setattr(bu, "_fetch_batch_output_file_content", fake_fetch)
monkeypatch.setattr(litellm, "disable_vertex_batch_output_transformation", True, raising=False)
- monkeypatch.setattr(
- bu,
- "calculate_vertex_ai_batch_cost_and_usage",
- lambda content, model: (
- 0.0,
- Usage(prompt_tokens=1, completion_tokens=2, total_tokens=3),
- ),
+ seen: dict = {}
+
+ def fake_vertex_calc(content, model):
+ seen["content"] = content
+ seen["model"] = model
+ return 7.7, Usage(prompt_tokens=1, completion_tokens=2, total_tokens=3)
+
+ monkeypatch.setattr(bu, "calculate_vertex_ai_batch_cost_and_usage", fake_vertex_calc)
+
+ cost, usage, models = await bu._handle_completed_batch(
+ _batch("gs://litellm-bucket/output/predictions.jsonl"),
+ custom_llm_provider="vertex_ai",
+ model_name="gemini-x",
)
- usage = bu._get_batch_job_total_usage_from_file_content([], custom_llm_provider="vertex_ai", model_name="gemini-x")
+ assert cost == 7.7
assert usage.total_tokens == 3
+ assert models == ["gemini-x"]
+ assert seen["content"] == raw_rows
+ assert seen["model"] == "gemini-x"
def _anthropic_usage(input_tokens, output_tokens, cache_creation=0, cache_read=0):
@@ -948,46 +983,54 @@ def test_bedrock_cost_uses_deployment_model_name():
"recordId": "1",
"modelOutput": {"model": "claude-sonnet-4-6", "usage": {"input_tokens": 13, "output_tokens": 5}},
}
- cost = bu._get_batch_job_cost_from_file_content(
- file_content_dictionary=[row],
+ cost, _, models = bu._aggregate_batch_cost_usage_models(
+ entries=[row],
custom_llm_provider="bedrock",
model_name="us.anthropic.claude-sonnet-4-6",
model_info={},
)
assert cost > 0
+ assert models == ["us.anthropic.claude-sonnet-4-6"]
-def test_anthropic_total_usage_sums_succeeded_only():
+def test_anthropic_total_usage_sums_succeeded_only(monkeypatch):
+ import litellm.cost_calculator as cc
+
+ monkeypatch.setattr(cc, "batch_cost_calculator", lambda **kw: (0.0, 0.0))
rows = [
_anthropic_succeeded_row(usage=_anthropic_usage(10, 5)),
_anthropic_errored_row(),
_anthropic_succeeded_row(usage=_anthropic_usage(20, 10, cache_read=100)),
]
- usage = bu._get_batch_job_total_usage_from_file_content(rows, custom_llm_provider="anthropic")
+ _, usage, _ = bu._aggregate_batch_cost_usage_models(entries=rows, custom_llm_provider="anthropic")
assert (usage.prompt_tokens, usage.completion_tokens, usage.total_tokens) == (130, 15, 145)
-def test_anthropic_total_usage_aggregates_cache_token_details():
+def test_anthropic_total_usage_aggregates_cache_token_details(monkeypatch):
+ import litellm.cost_calculator as cc
+
+ monkeypatch.setattr(cc, "batch_cost_calculator", lambda **kw: (0.0, 0.0))
rows = [
_anthropic_succeeded_row(usage=_anthropic_usage(1000, 200, cache_creation=2000, cache_read=8000)),
_anthropic_errored_row(),
_anthropic_succeeded_row(usage=_anthropic_usage(50, 20, cache_creation=300, cache_read=700)),
]
- usage = bu._get_batch_job_total_usage_from_file_content(rows, custom_llm_provider="anthropic")
+ _, usage, _ = bu._aggregate_batch_cost_usage_models(entries=rows, custom_llm_provider="anthropic")
assert usage.prompt_tokens_details.cached_tokens == 8700
assert usage.prompt_tokens_details.cache_creation_tokens == 2300
assert usage.cache_read_input_tokens == 8700
assert usage.cache_creation_input_tokens == 2300
-def test_total_usage_without_cache_tokens_has_no_prompt_details():
+def test_total_usage_without_cache_tokens_has_no_prompt_details(monkeypatch):
+ monkeypatch.setattr(litellm, "completion_cost", lambda **kw: 0.0)
rows = [
{
"custom_id": "req-1",
"response": {"status_code": 200, "body": {"model": "gpt-5.2", "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}}},
}
]
- usage = bu._get_batch_job_total_usage_from_file_content(rows, custom_llm_provider="openai")
+ _, usage, _ = bu._aggregate_batch_cost_usage_models(entries=rows, custom_llm_provider="openai")
assert (usage.prompt_tokens, usage.completion_tokens, usage.total_tokens) == (10, 5, 15)
assert usage.prompt_tokens_details is None
@@ -1000,8 +1043,8 @@ def test_anthropic_cost_applies_batch_discount_and_cache_pricing():
_anthropic_errored_row(),
]
- total = bu._get_batch_job_cost_from_file_content(
- rows,
+ total, _, _ = bu._aggregate_batch_cost_usage_models(
+ entries=rows,
custom_llm_provider="anthropic",
model_info=_ANTHROPIC_MODEL_INFO, # type: ignore[arg-type]
)
@@ -1026,8 +1069,8 @@ def test_anthropic_cost_without_model_info_uses_batch_cost_calculator(monkeypatc
lambda **kw: pytest.fail("anthropic rows must not go through completion_cost"),
)
- total = bu._get_batch_job_cost_from_file_content(
- [_anthropic_succeeded_row()], custom_llm_provider="anthropic"
+ total, _, _ = bu._aggregate_batch_cost_usage_models(
+ entries=[_anthropic_succeeded_row()], custom_llm_provider="anthropic"
)
assert total == pytest.approx(0.3)
@@ -1036,12 +1079,16 @@ def test_anthropic_cost_without_model_info_uses_batch_cost_calculator(monkeypatc
assert seen[0]["usage"].prompt_tokens == 10
-def test_anthropic_batch_models_collected_from_succeeded_rows():
+def test_anthropic_batch_models_collected_from_succeeded_rows(monkeypatch):
+ import litellm.cost_calculator as cc
+
+ monkeypatch.setattr(cc, "batch_cost_calculator", lambda **kw: (0.0, 0.0))
rows = [
_anthropic_succeeded_row(model="claude-sonnet-4-5-20250929"),
_anthropic_errored_row(),
]
- assert bu._get_batch_models_from_file_content(rows, None, "anthropic") == ["claude-sonnet-4-5-20250929"]
+ _, _, models = bu._aggregate_batch_cost_usage_models(entries=rows, custom_llm_provider="anthropic")
+ assert models == ["claude-sonnet-4-5-20250929"]
@pytest.mark.asyncio
diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_vertex_ai_batch_passthrough.py b/tests/test_litellm/proxy/pass_through_endpoints/test_vertex_ai_batch_passthrough.py
index 5de682ec8a0..52da7a4a81d 100644
--- a/tests/test_litellm/proxy/pass_through_endpoints/test_vertex_ai_batch_passthrough.py
+++ b/tests/test_litellm/proxy/pass_through_endpoints/test_vertex_ai_batch_passthrough.py
@@ -590,22 +590,20 @@ class TestVertexAIBatchCostCalculation:
assert usage.completion_tokens == 0
assert usage.total_tokens == 0
- def test_openai_shaped_output_records_nonzero_cost_and_usage(self):
+ @pytest.mark.asyncio
+ async def test_openai_shaped_output_records_nonzero_cost_and_usage(self):
"""
Regression test for the bug where Vertex batch cost/usage was always 0.
After PR #25627 (transform_file_content_response), the GCS predictions.jsonl
is rewritten into OpenAI batch shape before the cost-tracking path sees it.
- With disable_vertex_batch_output_transformation=False (default), the content
- is OpenAI-shaped, so _batch_cost_calculator must fall through to the generic
- path rather than calling calculate_vertex_ai_batch_cost_and_usage (which only
- reads raw usageMetadata fields).
+ With disable_vertex_batch_output_transformation=False (default), the cost
+ dispatch must fall through to the generic aggregation path rather than
+ calling calculate_vertex_ai_batch_cost_and_usage (which only reads raw
+ usageMetadata fields).
"""
import litellm
- from litellm.batches.batch_utils import (
- _batch_cost_calculator,
- _get_batch_job_total_usage_from_file_content,
- )
+ from litellm.batches.batch_utils import calculate_batch_cost_and_usage
openai_shaped_responses = [
{
@@ -668,12 +666,7 @@ class TestVertexAIBatchCostCalculation:
try:
litellm.disable_vertex_batch_output_transformation = False
- cost = _batch_cost_calculator(
- file_content_dictionary=openai_shaped_responses,
- custom_llm_provider="vertex_ai",
- model_name="gemini-2.0-flash-001",
- )
- usage = _get_batch_job_total_usage_from_file_content(
+ cost, usage, _ = await calculate_batch_cost_and_usage(
file_content_dictionary=openai_shaped_responses,
custom_llm_provider="vertex_ai",
model_name="gemini-2.0-flash-001",
@@ -694,16 +687,14 @@ class TestVertexAIBatchCostCalculation:
cost > 0
), f"expected non-zero cost for completed Vertex batch, got {cost}"
- def test_raw_vertex_output_still_works_when_transformation_disabled(self):
+ @pytest.mark.asyncio
+ async def test_raw_vertex_output_still_works_when_transformation_disabled(self):
"""
When disable_vertex_batch_output_transformation=True the GCS file is returned
as raw Vertex predictions.jsonl; the specialized reader must be used.
"""
import litellm
- from litellm.batches.batch_utils import (
- _batch_cost_calculator,
- _get_batch_job_total_usage_from_file_content,
- )
+ from litellm.batches.batch_utils import calculate_batch_cost_and_usage
raw_vertex_responses = [
{
@@ -727,12 +718,7 @@ class TestVertexAIBatchCostCalculation:
try:
litellm.disable_vertex_batch_output_transformation = True
- cost = _batch_cost_calculator(
- file_content_dictionary=raw_vertex_responses,
- custom_llm_provider="vertex_ai",
- model_name="gemini-2.0-flash-001",
- )
- usage = _get_batch_job_total_usage_from_file_content(
+ cost, usage, _ = await calculate_batch_cost_and_usage(
file_content_dictionary=raw_vertex_responses,
custom_llm_provider="vertex_ai",
model_name="gemini-2.0-flash-001",
diff --git a/type-discipline-budget.json b/type-discipline-budget.json
index d56d5a6e305..a7ee2c261c3 100644
--- a/type-discipline-budget.json
+++ b/type-discipline-budget.json
@@ -1,6 +1,6 @@
{
"LIT001": {
- "limit": 23287
+ "limit": 23279
},
"LIT002": {
"limit": 27473
From 8a300929614cce9b31f6caaa748049590b63ffff Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Wed, 29 Jul 2026 23:53:15 -0700
Subject: [PATCH 09/33] test(batches): exercise real GCS validation for vertex
batch output reads
---
.../test_litellm/batches/test_batch_utils.py | 93 +++++++++++++++++++
1 file changed, 93 insertions(+)
diff --git a/tests/test_litellm/batches/test_batch_utils.py b/tests/test_litellm/batches/test_batch_utils.py
index 70c89f70ceb..bf871623a94 100644
--- a/tests/test_litellm/batches/test_batch_utils.py
+++ b/tests/test_litellm/batches/test_batch_utils.py
@@ -18,7 +18,9 @@ import json
import os
import sys
+import httpx
import pytest
+import respx
sys.path.insert(0, os.path.abspath("../../../.."))
@@ -711,6 +713,97 @@ async def test_output_file_content_vertex_unified_file_id_extracts_gcs_uri(monke
assert captured["custom_llm_provider"] == "vertex_ai"
+def _vertex_predictions_row(custom_id, prompt_tokens, completion_tokens):
+ return {
+ "request": {
+ "contents": [{"role": "user", "parts": [{"text": "hi"}]}],
+ "labels": {"litellm_custom_id": custom_id},
+ },
+ "status": "",
+ "response": {
+ "candidates": [
+ {
+ "content": {"role": "model", "parts": [{"text": "ok"}]},
+ "finishReason": "STOP",
+ }
+ ],
+ "usageMetadata": {
+ "promptTokenCount": prompt_tokens,
+ "candidatesTokenCount": completion_tokens,
+ "totalTokenCount": prompt_tokens + completion_tokens,
+ },
+ "modelVersion": "gemini-3.6-flash",
+ },
+ "processed_time": "2026-07-30T00:00:00.000000+00:00",
+ }
+
+
+@pytest.fixture
+def respx_interceptable_httpx_client(monkeypatch):
+ monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
+ litellm.in_memory_llm_clients_cache.flush_cache()
+ yield
+ litellm.in_memory_llm_clients_cache.flush_cache()
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_output_file_content_vertex_managed_uri_accepted_by_real_validation(respx_interceptable_httpx_client):
+ managed_output_uri = (
+ "gs://litellm-bucket/litellm-vertex-files/publishers/google/models/"
+ "gemini-3.6-flash/abc-123/prediction-model/predictions.jsonl"
+ )
+ rows = [
+ _vertex_predictions_row("request-1", 10, 5),
+ _vertex_predictions_row("request-2", 20, 10),
+ ]
+ route = respx.get(url__regex=r"https://storage\.googleapis\.com/storage/v1/b/litellm-bucket/o/.*").mock(
+ return_value=httpx.Response(200, content=_vertex_jsonl(rows))
+ )
+
+ result = await bu._get_batch_output_file_content_as_dictionary(
+ _batch(managed_output_uri),
+ custom_llm_provider="vertex_ai",
+ litellm_params={
+ "api_key": "test-token",
+ "vertex_project": "proj-1",
+ "vertex_location": "us-central1",
+ "gcs_bucket_name": "litellm-bucket",
+ },
+ )
+
+ assert route.call_count == 1
+ request = route.calls.last.request
+ assert request.url.raw_path == (
+ b"/storage/v1/b/litellm-bucket/o/"
+ b"litellm-vertex-files%2Fpublishers%2Fgoogle%2Fmodels%2Fgemini-3.6-flash"
+ b"%2Fabc-123%2Fprediction-model%2Fpredictions.jsonl?alt=media"
+ )
+ assert [row["custom_id"] for row in result] == ["request-1", "request-2"]
+ assert all(row["response"]["status_code"] == 200 for row in result)
+ assert all(row["response"]["body"]["model"] == "gemini-3.6-flash" for row in result)
+ assert [row["response"]["body"]["usage"]["prompt_tokens"] for row in result] == [10, 20]
+ assert [row["response"]["body"]["usage"]["completion_tokens"] for row in result] == [5, 10]
+
+
+@pytest.mark.asyncio
+@respx.mock
+async def test_output_file_content_vertex_foreign_bucket_rejected_by_real_validation():
+ with pytest.raises(Exception, match="does not match the configured storage bucket"):
+ await bu._get_batch_output_file_content_as_dictionary(
+ _batch("gs://attacker-bucket/litellm-vertex-files/x/predictions.jsonl"),
+ custom_llm_provider="vertex_ai",
+ litellm_params={
+ "api_key": "test-token",
+ "vertex_project": "proj-1",
+ "vertex_location": "us-central1",
+ "gcs_bucket_name": "litellm-bucket",
+ },
+ )
+
+ assert respx.mock.calls.call_count == 0
+
+
@pytest.mark.asyncio
async def test_handle_completed_vertex_batch_computes_cost_usage_and_models(monkeypatch):
import litellm.files.main as files_main
From 516953b073abbbeadfa0f6be635cf1e87e65a9a4 Mon Sep 17 00:00:00 2001
From: Tin Chi Lo
Date: Thu, 30 Jul 2026 00:18:45 -0700
Subject: [PATCH 10/33] feat(ui): let team admins create auto-routers;
authorize models by team, not created_by
The Auto-Routers tab was proxy-admin only, while Add Model on the same page already
admits team admins. The asymmetry was not a policy decision; the auto-router create form
simply never mounted a team selector, so a team admin's submit was unscoped and POST
/model/new rejects an unscoped create from any non-proxy-admin. Mounting the shared
TeamDropdown closes it, and the tab now takes the same audience as its sibling.
Fixing that surfaced a second, larger problem. The dashboard decided who may edit or
delete a deployment with `(userRole === "Admin" || created_by === userID) && db_model`,
but `created_by` is written at creation and never read by any backend auth check. The API
authorizes on team-admin membership of model_info.team_id, so the dashboard was wrong in
both directions: it hid controls from team admins the API accepts, and offered them to
former team admins the API rejects. Verified against a live proxy; a model created by the
proxy admin was PATCHed and DELETEd 200 by a team admin who did not create it, while the
same key got 403 on another team's row and on an unscoped row.
Both questions now have one owner in utils/modelPermissions.ts, deliberately shaped as a
mirror of ModelManagementAuthChecks. Creation returns a tagged union rather than a pair of
booleans, so "may not create" and "may create unscoped" cannot be confused, and the five
places that had each invented their own spelling (the models page, the auto-routers tab
and panel, the auto-router form, and both branches of AddModelForm) call it instead.
Row affordances are now per row rather than per tab, because opening the tab to team
admins puts routers they cannot act on in the same list.
Note for reviewers: collapsing AddModelForm onto the shared owner changes behaviour for
org_admin and Admin Viewer who also admin a team. They previously got the optional team
selector, because all_admin_roles counts them as admins, and could submit an unscoped
create that the API always 403s; they now get the required selector.
Also corrects stale copy left by the auto-router move. The exclude_auto_routers API
description named a dashboard page, which went stale inside a single PR; it now describes
the concept so it cannot drift with the UI again.
The eslint-suppressions prune includes one entry for caching/_components/cache_dashboard.tsx,
which this branch does not touch. Its baseline was already stale; the gate measures the whole
tree, so it could not be left behind.
---
litellm/proxy/proxy_server.py | 5 +-
ui/litellm-dashboard/eslint-suppressions.json | 7 +-
.../_components/CostOptimizationView.tsx | 2 +-
.../app/(dashboard)/hooks/models/useModels.ts | 1 +
.../components/AllModelsTab.tsx | 4 +-
.../AutoRouters/AutoRoutersPanel.test.tsx | 10 ++-
.../AutoRouters/AutoRoutersPanel.tsx | 27 ++++--
.../AutoRouters/autoRouterRows.test.ts | 82 ++++++++++++++++--
.../components/AutoRouters/autoRouterRows.ts | 30 +++++--
.../(dashboard)/models-and-endpoints/page.tsx | 22 +++--
.../panels/AutoRoutersTabPanel.tsx | 27 ++++--
.../src/components/add_model/AddModelForm.tsx | 9 +-
.../add_model/add_auto_router_tab.test.tsx | 80 ++++++++++++++++-
.../add_model/add_auto_router_tab.tsx | 31 ++++++-
.../src/components/model_info_view.tsx | 9 +-
ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 +-
.../src/utils/modelPermissions.test.ts | 85 +++++++++++++++++++
.../src/utils/modelPermissions.ts | 80 +++++++++++++++++
18 files changed, 459 insertions(+), 54 deletions(-)
create mode 100644 ui/litellm-dashboard/src/utils/modelPermissions.test.ts
create mode 100644 ui/litellm-dashboard/src/utils/modelPermissions.ts
diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py
index bf010c7f249..c6d9e28226f 100644
--- a/litellm/proxy/proxy_server.py
+++ b/litellm/proxy/proxy_server.py
@@ -12087,8 +12087,9 @@ async def model_info_v2(
False,
description=(
"Omit auto-router deployments (litellm model prefixed `auto_router/`). "
- "They are routing constructs rather than deployments, and are managed on the "
- "Router Settings page. Defaults to false, so existing callers are unaffected"
+ "They select among deployments rather than being deployments themselves, so a "
+ "caller rendering a deployment list can leave them out. Defaults to false, so "
+ "existing callers are unaffected"
),
),
):
diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json
index c0d24d3a1e6..6819b2851f5 100644
--- a/ui/litellm-dashboard/eslint-suppressions.json
+++ b/ui/litellm-dashboard/eslint-suppressions.json
@@ -151,14 +151,11 @@
"no-restricted-imports": {
"count": 1
},
- "prefer-const": {
- "count": 1
- },
"react-hooks/purity": {
"count": 1
},
"react-hooks/set-state-in-effect": {
- "count": 2
+ "count": 1
}
},
"src/app/(dashboard)/caching/_components/cache_health.tsx": {
@@ -3320,7 +3317,7 @@
"count": 5
},
"no-restricted-syntax": {
- "count": 153
+ "count": 152
},
"prefer-const": {
"count": 32
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx
index 9c2d0b20b56..f6593e80999 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx
@@ -45,7 +45,7 @@ const CostOptimizationView: React.FC = ({ accessToken
Track and configure the mechanisms that save you money: prompt compression and prompt caching. Auto routers
- live on the Router Settings page
+ live under Models + Endpoints, on the Auto-Routers tab
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts
index 88c4836f112..52459d69b9a 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts
@@ -104,6 +104,7 @@ export interface AutoRouterDeployment extends AutoRouterCandidateDeployment {
created_at?: string | null;
updated_at?: string | null;
team_id?: string | null;
+ created_by?: string | null;
} | null;
}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx
index 1a65109eac8..1d206f81030 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx
@@ -104,8 +104,8 @@ const AllModelsTab = ({
teamIdForQuery,
sortBy,
sortOrder,
- // Auto-routers are routing constructs, not deployments; they are listed and managed on
- // the Router Settings page. Excluded server-side so total_count stays honest.
+ // Auto-routers are routing constructs, not deployments; the sibling Auto-Routers tab
+ // lists and manages them. Excluded server-side so total_count stays honest.
true,
);
const isLoading = isLoadingModelsInfo || isLoadingModelCostMap;
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.test.tsx
index 7d026f067fb..9ec551bc227 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.test.tsx
@@ -108,7 +108,15 @@ const mockDeploymentsPage = () => {
};
const renderPanel = (canModify = true) =>
- renderWithProviders( );
+ renderWithProviders(
+ ,
+ );
describe("AutoRoutersPanel", () => {
beforeEach(() => {
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.tsx
index 3fbce1bd4a0..f27c1e1c44a 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.tsx
@@ -11,6 +11,8 @@ import NotificationsManager from "@/components/molecules/notifications_manager";
import { modelDeleteCall } from "@/components/networking";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { type ModelWriteScope } from "@/utils/modelPermissions";
+import { Team } from "@/components/networking";
import { AutoRoutersTable } from "./AutoRoutersTable";
import { AutoRouterRow, toAutoRouterRows } from "./autoRouterRows";
@@ -18,11 +20,14 @@ import { AutoRouterRow, toAutoRouterRows } from "./autoRouterRows";
interface AutoRoutersPanelProps {
accessToken: string;
userRole: string;
- /** Owned by the page, which knows whether the caller may write. */
- canModify: boolean;
+ userID: string | null;
+ teams: Team[] | null;
+ /** Owned by the page, which knows how this caller must scope what they create. */
+ createScope: ModelWriteScope;
}
-export function AutoRoutersPanel({ accessToken, userRole, canModify }: AutoRoutersPanelProps) {
+export function AutoRoutersPanel({ accessToken, userRole, userID, teams, createScope }: AutoRoutersPanelProps) {
+ const canCreate = createScope !== "forbidden";
const { data: deployments, isLoading } = useAutoRouters();
const invalidateAutoRouters = useInvalidateAutoRouters();
// Clicking a router opens the same ?model= drill-in the All Models table uses, so an auto
@@ -33,7 +38,10 @@ export function AutoRoutersPanel({ accessToken, userRole, canModify }: AutoRoute
const [deletingRouter, setDeletingRouter] = useState(null);
const [isDeleting, setIsDeleting] = useState(false);
- const routers = useMemo(() => toAutoRouterRows(deployments ?? []), [deployments]);
+ const routers = useMemo(
+ () => toAutoRouterRows(deployments ?? [], { userRole, userID }, teams),
+ [deployments, userRole, userID, teams],
+ );
const handleCreated = () => {
setIsCreating(false);
@@ -65,7 +73,7 @@ export function AutoRoutersPanel({ accessToken, userRole, canModify }: AutoRoute
so clients keep using a single model name.
- {canModify && (
+ {canCreate && (
setIsCreating(true)} className="shrink-0">
Add Auto Router
@@ -76,7 +84,7 @@ export function AutoRoutersPanel({ accessToken, userRole, canModify }: AutoRoute
openModel(row.id)}
onDeleteClick={setDeletingRouter}
/>
@@ -92,7 +100,12 @@ export function AutoRoutersPanel({ accessToken, userRole, canModify }: AutoRoute
using a single model name.
-
+
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/autoRouterRows.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/autoRouterRows.test.ts
index 924391d16fb..9944653b638 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/autoRouterRows.test.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/autoRouterRows.test.ts
@@ -3,6 +3,11 @@ import { describe, expect, it } from "vitest";
import { autoRouterStrategy, isComplexityRouter } from "@/components/add_model/auto_router_strategies";
import { toAutoRouterRow, toAutoRouterRows } from "./autoRouterRows";
+// Existing cases assert resource classification, so they run as a proxy admin: the actor
+// gate is then a pass-through and canEdit/canDelete still reflect the row itself.
+const ADMIN = { userRole: "Admin", userID: "u-admin" };
+const TEAM_ADMIN = { userRole: "Internal User", userID: "u-team-admin" };
+
const complexityDeployment = {
model_name: "tri-tier-router",
litellm_params: {
@@ -38,7 +43,7 @@ const semanticDeployment = {
describe("autoRouterRows", () => {
it("classifies a complexity router and unions its tier models as targets", () => {
- const row = toAutoRouterRow(complexityDeployment, 0);
+ const row = toAutoRouterRow(complexityDeployment, 0, ADMIN, null);
expect(row.kind).toBe("complexity");
expect(row.typeLabel).toBe("Heuristic");
@@ -49,7 +54,7 @@ describe("autoRouterRows", () => {
});
it("parses a semantic router whose config arrives as a JSON string", () => {
- const row = toAutoRouterRow(semanticDeployment, 0);
+ const row = toAutoRouterRow(semanticDeployment, 0, ADMIN, null);
expect(row.kind).toBe("semantic");
expect(row.typeLabel).toBe("Semantic");
@@ -70,6 +75,8 @@ describe("autoRouterRows", () => {
},
},
0,
+ ADMIN,
+ null,
);
expect(row.targets).toEqual(["gpt-4o-mini", "anthropic-sonnet-4-6"]);
@@ -85,6 +92,8 @@ describe("autoRouterRows", () => {
},
},
0,
+ ADMIN,
+ null,
);
expect(row.typeLabel).toBe("LLM Classifier");
@@ -102,6 +111,8 @@ describe("autoRouterRows", () => {
model_info: { id: "bid-1" },
},
0,
+ ADMIN,
+ null,
);
expect(row.kind).toBe("semantic");
@@ -109,10 +120,14 @@ describe("autoRouterRows", () => {
});
it("falls back to a stable synthetic id when the deployment has no model_info id", () => {
- const rows = toAutoRouterRows([
- { model_name: "a", litellm_params: { model: "auto_router/a" } },
- { model_name: "b", litellm_params: { model: "auto_router/b" } },
- ]);
+ const rows = toAutoRouterRows(
+ [
+ { model_name: "a", litellm_params: { model: "auto_router/a" } },
+ { model_name: "b", litellm_params: { model: "auto_router/b" } },
+ ],
+ ADMIN,
+ null,
+ );
expect(rows.map((row) => row.id)).toEqual(["a-0", "b-1"]);
});
@@ -130,6 +145,8 @@ describe("autoRouterRows", () => {
model_info: { id: "ad-1" },
},
0,
+ ADMIN,
+ null,
);
expect(row.kind).toBe("adaptive");
@@ -150,6 +167,8 @@ describe("autoRouterRows", () => {
model_info: { id: "q-1" },
},
0,
+ ADMIN,
+ null,
);
expect(row.kind).toBe("quality");
@@ -170,7 +189,12 @@ describe("autoRouterRows", () => {
// with no delete control. Live-verified: for a config row PATCH /model/{id}/update 404s
// and POST /model/delete 400s.
const rowFor = (model: string, dbModel: boolean) =>
- toAutoRouterRow({ model_name: "r", litellm_params: { model }, model_info: { id: "x", db_model: dbModel } }, 0);
+ toAutoRouterRow(
+ { model_name: "r", litellm_params: { model }, model_info: { id: "x", db_model: dbModel } },
+ 0,
+ ADMIN,
+ null,
+ );
it.each([
{ model: "auto_router/complexity_router", db: true, canEdit: true, canDelete: true, reason: null },
@@ -189,8 +213,50 @@ describe("autoRouterRows", () => {
});
it("treats a missing db_model as config-defined rather than assuming it is writable", () => {
- const row = toAutoRouterRow({ ...complexityDeployment, model_info: { id: "unknown-1" } }, 0);
+ const row = toAutoRouterRow({ ...complexityDeployment, model_info: { id: "unknown-1" } }, 0, ADMIN, null);
expect(row.canEdit).toBe(false);
expect(row.canDelete).toBe(false);
});
});
+
+describe("autoRouterRows actor gating", () => {
+ const TEAMS = [
+ { team_id: "team-1", members_with_roles: [{ user_id: "u-team-admin", user_email: "t@t", role: "admin" }] },
+ ] as never;
+
+ const rowIn = (actor: { userRole: string; userID: string }, teamId: string | null) =>
+ toAutoRouterRow(
+ { ...complexityDeployment, model_info: { id: "cid-1", db_model: true, team_id: teamId } },
+ 0,
+ actor,
+ TEAMS,
+ );
+
+ // Opening the tab to team admins puts rows they cannot act on in the same list: other
+ // teams' routers, and the proxy-level unscoped ones. PATCH and DELETE both 403 those, so
+ // the affordance has to be per row rather than per tab.
+ it("hides write affordances on another team's router", () => {
+ const row = rowIn(TEAM_ADMIN, "other-team");
+ expect(row.canEdit).toBe(false);
+ expect(row.canDelete).toBe(false);
+ });
+
+ it("hides them on an unscoped router a proxy admin owns", () => {
+ const row = rowIn(TEAM_ADMIN, null);
+ expect(row.canEdit).toBe(false);
+ expect(row.canDelete).toBe(false);
+ });
+
+ // Authorizing on created_by would fail this: the API lets any admin of the owning team act.
+ it("keeps them on the team's router regardless of who created it", () => {
+ const row = rowIn(TEAM_ADMIN, "team-1");
+ expect(row.canEdit).toBe(true);
+ expect(row.canDelete).toBe(true);
+ });
+
+ it("lets a proxy admin act on any team's router", () => {
+ const row = rowIn(ADMIN, "other-team");
+ expect(row.canEdit).toBe(true);
+ expect(row.canDelete).toBe(true);
+ });
+});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/autoRouterRows.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/autoRouterRows.ts
index 4817711f469..35172d67e84 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/autoRouterRows.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/autoRouterRows.ts
@@ -6,9 +6,14 @@ import {
autoRouterStrategy,
} from "@/components/add_model/auto_router_strategies";
import { normalizeTierModels } from "@/components/add_model/complexity_router_tiers";
+import { Team } from "@/components/networking";
+import { type ModelActor, canModifyModel } from "@/utils/modelPermissions";
export type { AutoRouterKind };
+/** Who is looking at the list; decides which rows offer write affordances. */
+export type AutoRouterActor = ModelActor;
+
export interface AutoRouterRow {
id: string;
name: string;
@@ -16,7 +21,11 @@ export interface AutoRouterRow {
typeLabel: string;
/** Edit needs an API-created row AND a strategy the dashboard has a form for. */
canEdit: boolean;
- /** Delete only needs an API-created row; removing by id never reads the config. */
+ /**
+ * Resource capability ANDed with the caller's standing on this specific row. A team admin
+ * sees rows they cannot delete (another team's, or one a teammate created), and the API
+ * would 403 those, so the affordance has to be per row rather than per tab.
+ */
canDelete: boolean;
editBlockedReason: EditBlockedReason | null;
targets: string[];
@@ -78,19 +87,25 @@ const PRESENTERS: Record) => Pr
quality: (config) => configManaged("Quality", config),
};
-export const toAutoRouterRow = (deployment: AutoRouterDeployment, index: number): AutoRouterRow => {
+export const toAutoRouterRow = (
+ deployment: AutoRouterDeployment,
+ index: number,
+ actor: AutoRouterActor,
+ teams: Team[] | null,
+): AutoRouterRow => {
const params = deployment.litellm_params ?? {};
const info = deployment.model_info ?? {};
const name = deployment.model_name ?? "";
const strategy = autoRouterStrategy(params);
const { canEdit, canDelete, editBlockedReason } = autoRouterCapabilities(params, info);
+ const mayActOnRow = canModifyModel(actor, teams, { teamId: info.team_id, isDbModel: info.db_model === true });
return {
id: info.id ?? `${name}-${index}`,
name,
kind: strategy.kind,
- canEdit,
- canDelete,
+ canEdit: canEdit && mayActOnRow,
+ canDelete: canDelete && mayActOnRow,
editBlockedReason,
createdAt: info.created_at ?? null,
defaultModel: (params[strategy.defaultModelKey] as string | null | undefined) ?? null,
@@ -99,5 +114,8 @@ export const toAutoRouterRow = (deployment: AutoRouterDeployment, index: number)
};
};
-export const toAutoRouterRows = (deployments: AutoRouterDeployment[]): AutoRouterRow[] =>
- deployments.map(toAutoRouterRow);
+export const toAutoRouterRows = (
+ deployments: AutoRouterDeployment[],
+ actor: AutoRouterActor,
+ teams: Team[] | null,
+): AutoRouterRow[] => deployments.map((deployment, index) => toAutoRouterRow(deployment, index, actor, teams));
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx
index b7b16caf2ad..22ffb6d6cc8 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx
@@ -7,7 +7,8 @@ import { useQueryClient } from "@tanstack/react-query";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
-import { all_admin_roles, internalUserRoles, isProxyAdminRole, isUserTeamAdminForAnyTeam } from "@/utils/roles";
+import { all_admin_roles, internalUserRoles } from "@/utils/roles";
+import { canCreateModels } from "@/utils/modelPermissions";
import BetaBadge from "@/components/BetaBadge";
import CostOptimizationFeedbackBanner from "@/components/molecules/cost_optimization_feedback_banner";
import ModelInfoView from "@/components/model_info_view";
@@ -83,24 +84,27 @@ export default function ModelsAndEndpointsPage() {
const [activeKey, setActiveKey] = useState(BASE_TAB_KEY);
const [lastRefreshed, setLastRefreshed] = useState("");
- const isProxyAdmin = userRole && isProxyAdminRole(userRole);
const isInternalUser = userRole && internalUserRoles.includes(userRole);
- const isUserTeamAdmin = userID && isUserTeamAdminForAnyTeam(teams ?? null, userID);
- const addModelDisabledForInternalUsers =
- isInternalUser && uiSettings?.values?.disable_model_add_for_internal_users === true;
- const shouldHideAddModelTab = !isProxyAdmin && (addModelDisabledForInternalUsers || !isUserTeamAdmin);
+ const canCreate = canCreateModels(
+ { userRole, userID },
+ {
+ teams: teams ?? null,
+ disabledForInternalUsers:
+ isInternalUser === true && uiSettings?.values?.disable_model_add_for_internal_users === true,
+ },
+ );
const isAdmin = all_admin_roles.includes(userRole);
const visibleSlugs = useMemo>(
() => [
"",
- ...(shouldHideAddModelTab ? [] : (["add"] as const)),
- ...(isAdmin ? (["auto-routers"] as const) : []),
+ ...(canCreate ? (["add"] as const) : []),
+ ...(isAdmin || canCreate ? (["auto-routers"] as const) : []),
...(isAdmin
? (["llm-credentials", "pass-through", "health", "retry-settings", "model-group-alias", "price-data"] as const)
: []),
],
- [shouldHideAddModelTab, isAdmin],
+ [canCreate, isAdmin],
);
const allModelsLabel = isAdmin ? "All Models" : "Your Models";
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AutoRoutersTabPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AutoRoutersTabPanel.tsx
index 8b620e22bf9..5f7d56e8e33 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AutoRoutersTabPanel.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AutoRoutersTabPanel.tsx
@@ -1,23 +1,40 @@
"use client";
+import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
+import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
-import { isProxyAdminRole } from "@/utils/roles";
+import { internalUserRoles } from "@/utils/roles";
+import { modelCreationScope } from "@/utils/modelPermissions";
import { AutoRoutersPanel } from "../components/AutoRouters/AutoRoutersPanel";
/**
* Owns the permission decision for the Auto-Routers tab so the panel stays a renderer.
- * Creating or editing an auto router is a POST /model/new or PATCH /model/{id}/update, both
- * proxy-admin gated, so viewer roles read the list without write affordances.
+ * Creating an auto router is a POST /model/new, the same endpoint Add Model posts to, so it
+ * takes the same audience rule: a proxy admin, or a team admin who scopes it to a team.
+ * Viewer roles reach the list without write affordances.
*/
export default function AutoRoutersTabPanel() {
- const { accessToken, userRole } = useAuthorized();
+ const { accessToken, userRole, userId: userID } = useAuthorized();
+ const { data: teams } = useTeams();
+ const { data: uiSettings } = useUISettings();
+
+ const isInternalUser = userRole != null && internalUserRoles.includes(userRole);
+ const scope = modelCreationScope(
+ { userRole, userID },
+ {
+ teams: teams ?? null,
+ disabledForInternalUsers: isInternalUser && uiSettings?.values?.disable_model_add_for_internal_users === true,
+ },
+ );
return (
);
}
diff --git a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx
index a99f8048dca..03be04b9919 100644
--- a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx
@@ -2,6 +2,7 @@ import { useProviderFields } from "@/app/(dashboard)/hooks/providers/useProvider
import { useGuardrails } from "@/app/(dashboard)/hooks/guardrails/useGuardrails";
import { useTags } from "@/app/(dashboard)/hooks/tags/useTags";
import { all_admin_roles, isUserTeamAdminForAnyTeam } from "@/utils/roles";
+import { modelCreationScope } from "@/utils/modelPermissions";
import { Switch, Text } from "@tremor/react";
import type { FormInstance } from "antd";
import { Select as AntdSelect, Button, Card, Col, Form, Modal, Row, Tooltip, Typography, Alert } from "antd";
@@ -101,6 +102,10 @@ const AddModelForm: React.FC = ({
const isAdmin = all_admin_roles.includes(userRole);
const isTeamAdmin = isUserTeamAdminForAnyTeam(teams, userId);
+ // Same owner the Auto-Routers tab uses, so the two creation forms cannot disagree about
+ // who has to name a team. This form is only reachable when creation is allowed at all.
+ const createScope = modelCreationScope({ userRole, userID: userId }, { teams, disabledForInternalUsers: false });
+ const requiresTeamScope = createScope === "team-required";
return (
<>
@@ -120,7 +125,7 @@ const AddModelForm: React.FC = ({
labelAlign="left"
>
<>
- {isTeamAdmin && !isAdmin && (
+ {requiresTeamScope && (
<>
= ({
)}
{/* Conditional Team Selection */}
- {isTeamOnly && (isAdmin || !isTeamAdmin) && (
+ {isTeamOnly && !requiresTeamScope && (
({
modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }),
@@ -20,9 +22,36 @@ vi.mock("../molecules/notifications_manager", () => ({
default: { fromBackend: vi.fn() },
}));
+// Kept real by default so the "mandatory field" test still sees genuine tier validation; one
+// test overrides it to reach the submit path without driving four tier selects.
+vi.mock("./build_complexity_router_config", async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, getMissingTiersError: vi.fn(actual.getMissingTiersError) };
+});
+
+// A real TeamDropdown fetches teams and renders an antd Select; the wiring under test is
+// whether team_id is registered, validated and forwarded, so a plain control stands in.
+vi.mock("../common_components/team_dropdown", () => ({
+ default: ({ value, onChange }: { value?: string; onChange?: (next: string) => void }) => (
+ onChange?.(event.target.value)}
+ aria-label="Select Team"
+ >
+ none
+ team-1
+
+ ),
+}));
+
const Harness = () => ;
describe("AddAutoRouterTab", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
it("flags every mandatory field when Add Auto Router is clicked with nothing filled", async () => {
const user = userEvent.setup();
renderWithProviders( );
@@ -33,4 +62,53 @@ describe("AddAutoRouterTab", () => {
expect(screen.getAllByText("This tier is required")).toHaveLength(4);
expect(NotificationManager.fromBackend).toHaveBeenCalledWith("Please enter an Auto Router Name");
});
+
+ it("offers no team selector to a proxy admin, who may create an unscoped router", () => {
+ renderWithProviders( );
+
+ expect(screen.queryByTestId("team-dropdown")).not.toBeInTheDocument();
+ });
+
+ it("requires a team admin to pick a team", async () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByTestId("team-dropdown")).toBeInTheDocument();
+ expect(screen.getByText("Select Team")).toBeInTheDocument();
+ });
+
+ // POST /model/new 403s an unscoped create from a non-proxy-admin, so a selected team that
+ // never reaches the payload is indistinguishable from having no selector at all. The value
+ // has to survive form.validateFields, which only returns the fields it is asked for.
+ it("carries the selected team through to the create payload", async () => {
+ const user = userEvent.setup();
+ vi.mocked(getMissingTiersError).mockReturnValue(null);
+
+ renderWithProviders(
+ ,
+ );
+
+ await user.type(screen.getByPlaceholderText(/smart_router/i), "team-scoped-router");
+ await user.selectOptions(screen.getByTestId("team-dropdown"), "team-1");
+ await user.click(screen.getByRole("button", { name: /add auto router/i }));
+
+ await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalled());
+ expect(vi.mocked(handleAddAutoRouterSubmit).mock.calls.at(-1)?.[0]).toMatchObject({ team_id: "team-1" });
+ });
+
+ it("blocks the submit when a team admin has not picked a team", async () => {
+ const user = userEvent.setup();
+ vi.mocked(getMissingTiersError).mockReturnValue(null);
+
+ renderWithProviders(
+ ,
+ );
+
+ await user.type(screen.getByPlaceholderText(/smart_router/i), "team-scoped-router");
+ await user.click(screen.getByRole("button", { name: /add auto router/i }));
+
+ expect(await screen.findByText("Please select a team to continue")).toBeInTheDocument();
+ expect(handleAddAutoRouterSubmit).not.toHaveBeenCalled();
+ });
});
diff --git a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx
index 6e946bf9106..cd3294b347f 100644
--- a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx
@@ -3,6 +3,8 @@ import { Card, Form, Button, Tooltip, Typography, Select as AntdSelect, Modal }
import { TextInput } from "@tremor/react";
import { modelAvailableCall } from "../networking";
import { all_admin_roles } from "@/utils/roles";
+import { type ModelWriteScope } from "@/utils/modelPermissions";
+import TeamDropdown from "../common_components/team_dropdown";
import { handleAddAutoRouterSubmit } from "./handle_add_auto_router_submit";
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
import ComplexityRouterConfig, {
@@ -26,11 +28,23 @@ interface AddAutoRouterTabProps {
handleOk: () => void;
accessToken: string;
userRole: string;
+ /**
+ * How this caller must scope what they create. A team admin has to name a team, because
+ * POST /model/new rejects an unscoped create from any non-proxy-admin; without the selector
+ * their submit is a guaranteed 403.
+ */
+ createScope?: ModelWriteScope;
}
const { Title } = Typography;
-const AddAutoRouterTab: React.FC = ({ handleOk, accessToken, userRole }) => {
+const AddAutoRouterTab: React.FC = ({
+ handleOk,
+ accessToken,
+ userRole,
+ createScope = "unscoped-ok",
+}) => {
+ const requiresTeamScope = createScope === "team-required";
const [form] = Form.useForm();
const [modelAccessGroups, setModelAccessGroups] = useState([]);
const [modelInfo, setModelInfo] = useState([]);
@@ -122,7 +136,7 @@ const AddAutoRouterTab: React.FC = ({ handleOk, accessTok
});
form
- .validateFields(["auto_router_name"])
+ .validateFields(requiresTeamScope ? ["auto_router_name", "team_id"] : ["auto_router_name"])
.then((values) => {
const complexityRouterConfigParams = {
tiers,
@@ -209,6 +223,19 @@ const AddAutoRouterTab: React.FC = ({ handleOk, accessTok
+ {requiresTeamScope && (
+
+
+
+ )}
+
{
@@ -175,8 +178,10 @@ export default function ModelInfoView({
// Keep modelData variable name for backwards compatibility
const modelData = transformedModelData;
- const canEditModel =
- (userRole === "Admin" || modelData?.model_info?.created_by === userID) && modelData?.model_info?.db_model;
+ const canEditModel = canModifyModel({ userRole, userID }, teams ?? null, {
+ teamId: modelData?.model_info?.team_id,
+ isDbModel: modelData?.model_info?.db_model === true,
+ });
const isAdmin = userRole === "Admin";
// Editor-aware on purpose: an adaptive or quality router must not offer Edit Auto Router.
const isAutoRouterModel = hasAutoRouterEditor(modelData?.litellm_params);
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index 121cd79eccb..380c2616c5d 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -58089,7 +58089,7 @@ export interface operations {
sortBy?: string | null;
/** @description Sort order. Options: asc, desc */
sortOrder?: string | null;
- /** @description Omit auto-router deployments (litellm model prefixed `auto_router/`). They are routing constructs rather than deployments, and are managed on the Router Settings page. Defaults to false, so existing callers are unaffected */
+ /** @description Omit auto-router deployments (litellm model prefixed `auto_router/`). They select among deployments rather than being deployments themselves, so a caller rendering a deployment list can leave them out. Defaults to false, so existing callers are unaffected */
exclude_auto_routers?: boolean | null;
};
header?: never;
diff --git a/ui/litellm-dashboard/src/utils/modelPermissions.test.ts b/ui/litellm-dashboard/src/utils/modelPermissions.test.ts
new file mode 100644
index 00000000000..179a9b7933a
--- /dev/null
+++ b/ui/litellm-dashboard/src/utils/modelPermissions.test.ts
@@ -0,0 +1,85 @@
+import { describe, expect, it } from "vitest";
+
+import { Team } from "@/components/networking";
+import { canModifyModel, modelCreationScope } from "./modelPermissions";
+
+const teamWhere = (userId: string, role: string, teamId = "team-1"): Team[] =>
+ [{ team_id: teamId, members_with_roles: [{ user_id: userId, user_email: "t@test.com", role }] }] as unknown as Team[];
+
+const PROXY_ADMIN = { userRole: "Admin", userID: "u-admin" };
+const TEAM_ADMIN = { userRole: "Internal User", userID: "u-team-admin" };
+const MEMBER = { userRole: "Internal User", userID: "u-member" };
+
+const noLimits = { disabledForInternalUsers: false };
+
+describe("modelCreationScope", () => {
+ it("lets a proxy admin create without naming a team", () => {
+ expect(modelCreationScope(PROXY_ADMIN, { teams: null, ...noLimits })).toBe("unscoped-ok");
+ });
+
+ // Live-verified: POST /model/new from a team admin 403s without model_info.team_id and
+ // returns 200 with it, so the form must make the team mandatory rather than optional.
+ it("requires a team admin to name a team", () => {
+ expect(modelCreationScope(TEAM_ADMIN, { teams: teamWhere("u-team-admin", "admin"), ...noLimits })).toBe(
+ "team-required",
+ );
+ });
+
+ it("forbids a plain team member", () => {
+ expect(modelCreationScope(MEMBER, { teams: teamWhere("u-member", "user"), ...noLimits })).toBe("forbidden");
+ });
+
+ // The admin setting is scoped to internal users and must never lock out a proxy admin.
+ it("honours the internal-user kill switch without touching proxy admins", () => {
+ const limits = { teams: teamWhere("u-team-admin", "admin"), disabledForInternalUsers: true };
+ expect(modelCreationScope(TEAM_ADMIN, limits)).toBe("forbidden");
+ expect(modelCreationScope(PROXY_ADMIN, limits)).toBe("unscoped-ok");
+ });
+
+ // org_admin and Admin Viewer are in all_admin_roles but are not PROXY_ADMIN to the API, so
+ // an unscoped create from them 403s. Treating them as admins here is what let a form submit
+ // a payload the backend always rejected.
+ it("does not treat an org admin as able to create unscoped", () => {
+ const orgAdmin = { userRole: "org_admin", userID: "u-org" };
+ expect(modelCreationScope(orgAdmin, { teams: teamWhere("u-org", "admin"), ...noLimits })).toBe("team-required");
+ });
+});
+
+describe("canModifyModel", () => {
+ const teamRow = { teamId: "team-1", isDbModel: true };
+
+ // config.yaml rows: PATCH /model/{id}/update 404s and POST /model/delete 400s for everyone.
+ it("refuses a config-defined row even to a proxy admin", () => {
+ expect(canModifyModel(PROXY_ADMIN, null, { teamId: "team-1", isDbModel: false })).toBe(false);
+ });
+
+ it("lets a proxy admin act on any DB row", () => {
+ expect(canModifyModel(PROXY_ADMIN, null, teamRow)).toBe(true);
+ });
+
+ // The regression this whole owner exists for. Live-verified: a model created by the proxy
+ // admin (created_by=default_user_id) was PATCHed and DELETEd 200 by a team admin who did
+ // not create it. Authorizing on created_by hid controls the API accepts.
+ it("lets a team admin act on their team's row they did not create", () => {
+ expect(canModifyModel(TEAM_ADMIN, teamWhere("u-team-admin", "admin"), teamRow)).toBe(true);
+ });
+
+ it("refuses a plain member of the owning team", () => {
+ expect(canModifyModel(MEMBER, teamWhere("u-member", "user"), teamRow)).toBe(false);
+ });
+
+ it("refuses a team admin of a different team", () => {
+ expect(canModifyModel(TEAM_ADMIN, teamWhere("u-team-admin", "admin", "other-team"), teamRow)).toBe(false);
+ });
+
+ // Unscoped rows can only have been created by a proxy admin, and only one can edit them.
+ it("refuses a team admin on an unscoped row", () => {
+ expect(canModifyModel(TEAM_ADMIN, teamWhere("u-team-admin", "admin"), { teamId: null, isDbModel: true })).toBe(
+ false,
+ );
+ });
+
+ it("does not treat two absent identities as a match", () => {
+ expect(canModifyModel({ userRole: "Internal User", userID: null }, null, teamRow)).toBe(false);
+ });
+});
diff --git a/ui/litellm-dashboard/src/utils/modelPermissions.ts b/ui/litellm-dashboard/src/utils/modelPermissions.ts
new file mode 100644
index 00000000000..b5914f9d7ea
--- /dev/null
+++ b/ui/litellm-dashboard/src/utils/modelPermissions.ts
@@ -0,0 +1,80 @@
+import { Team } from "@/components/networking";
+
+import { isProxyAdminRole, isUserTeamAdminForAnyTeam, isUserTeamAdminForSingleTeam } from "./roles";
+
+/**
+ * The dashboard's mirror of ModelManagementAuthChecks in
+ * litellm/proxy/management_endpoints/model_management_endpoints.py.
+ *
+ * Both questions below are answered there by exactly two inputs: the caller's role, and
+ * whether the caller admins the team named in `model_info.team_id`. `created_by` is written
+ * at creation and never read by an auth check, so it is deliberately absent here; gating on
+ * it hid controls from team admins the API accepts, and showed controls to former team admins
+ * the API rejects.
+ */
+export interface ModelActor {
+ userRole: string | null;
+ userID: string | null;
+}
+
+/** How this actor must scope a deployment they create, or that they may not create one. */
+export type ModelWriteScope = "forbidden" | "unscoped-ok" | "team-required";
+
+export interface ModelCreationLimits {
+ teams: Team[] | null;
+ /** The admin setting that withdraws model creation from internal users. */
+ disabledForInternalUsers: boolean;
+}
+
+const isTeamAdminOf = (teams: Team[] | null, userID: string, teamId: string): boolean => {
+ const team = teams?.find((candidate) => candidate.team_id === teamId);
+ return team != null && isUserTeamAdminForSingleTeam(team.members_with_roles, userID);
+};
+
+/**
+ * POST /model/new takes a proxy admin unconditionally, or a team admin whose payload names a
+ * team; an unscoped create from anyone else is a 403. Returning the requirement rather than a
+ * pair of booleans keeps "may not create" and "may create unscoped" from being confused.
+ */
+export const modelCreationScope = (
+ { userRole, userID }: ModelActor,
+ { teams, disabledForInternalUsers }: ModelCreationLimits,
+): ModelWriteScope => {
+ if (userRole != null && isProxyAdminRole(userRole)) {
+ return "unscoped-ok";
+ }
+ if (disabledForInternalUsers) {
+ return "forbidden";
+ }
+ if (userID != null && isUserTeamAdminForAnyTeam(teams, userID)) {
+ return "team-required";
+ }
+ return "forbidden";
+};
+
+export const canCreateModels = (actor: ModelActor, limits: ModelCreationLimits): boolean =>
+ modelCreationScope(actor, limits) !== "forbidden";
+
+export interface ModelRowOrigin {
+ teamId: string | null | undefined;
+ /** False for config.yaml rows, which update and delete both refuse whoever asks. */
+ isDbModel: boolean;
+}
+
+/** May this actor edit or delete this specific deployment? */
+export const canModifyModel = (
+ { userRole, userID }: ModelActor,
+ teams: Team[] | null,
+ { teamId, isDbModel }: ModelRowOrigin,
+): boolean => {
+ if (!isDbModel) {
+ return false;
+ }
+ if (userRole != null && isProxyAdminRole(userRole)) {
+ return true;
+ }
+ if (userID == null || teamId == null) {
+ return false;
+ }
+ return isTeamAdminOf(teams, userID, teamId);
+};
From 76cf3bf6acaf900c0e00ba93d53c6745930e5cbd Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Thu, 30 Jul 2026 13:48:43 +0000
Subject: [PATCH 11/33] chore(typing): clear basedpyright Any errors in proxy
auth, repositories, and openai transforms
Replace `Model(**untyped_dict)` construction with `Model.model_validate(...)` at
the hot Any seams, and give the repository layer a real record type instead of
`Any`.
reportAny 22710 -> 21448, reportExplicitAny 7283 -> 7269, with every other rule
at or below its baseline repo-wide.
---
basedpyright-code-budget.json | 10 ++--
litellm/llms/openai/openai.py | 24 ++++----
.../llms/openai/responses/transformation.py | 8 +--
.../mcp_server/mcp_server_manager.py | 2 +-
litellm/proxy/auth/auth_checks.py | 4 +-
litellm/proxy/auth/oauth2_proxy_hook.py | 19 +++---
litellm/proxy/auth/resolvers/store.py | 2 +-
.../management_endpoints/common_utils.py | 4 +-
.../mcp_management_endpoints.py | 4 +-
litellm/proxy/management_helpers/utils.py | 8 +--
litellm/proxy/proxy_server.py | 8 +--
litellm/repositories/base_repository.py | 54 ++++++++++-------
.../repositories/organization_repository.py | 6 +-
litellm/repositories/project_repository.py | 9 +--
litellm/repositories/team_repository.py | 56 +++++++++---------
.../verification_token_repository.py | 58 +++++++++----------
ruff-strict-budget.json | 4 +-
.../repositories/test_repositories.py | 19 ++++--
type-discipline-budget.json | 4 +-
19 files changed, 161 insertions(+), 142 deletions(-)
diff --git a/basedpyright-code-budget.json b/basedpyright-code-budget.json
index 3f89ff179eb..f4e506afcde 100644
--- a/basedpyright-code-budget.json
+++ b/basedpyright-code-budget.json
@@ -1,6 +1,6 @@
{
"reportAny": {
- "limit": 33171
+ "limit": 31909
},
"reportArgumentType": {
"limit": 2645
@@ -24,7 +24,7 @@
"limit": 42
},
"reportExplicitAny": {
- "limit": 10228
+ "limit": 10214
},
"reportFunctionMemberAccess": {
"limit": 11
@@ -90,7 +90,7 @@
"limit": 12
},
"reportReturnType": {
- "limit": 221
+ "limit": 219
},
"reportTypedDictNotRequiredAccess": {
"limit": 27
@@ -99,7 +99,7 @@
"limit": 0
},
"reportUnknownArgumentType": {
- "limit": 45522
+ "limit": 45366
},
"reportUnknownLambdaType": {
"limit": 113
@@ -111,7 +111,7 @@
"limit": 20341
},
"reportUnknownVariableType": {
- "limit": 32052
+ "limit": 32051
},
"reportUnnecessaryCast": {
"limit": 177
diff --git a/litellm/llms/openai/openai.py b/litellm/llms/openai/openai.py
index 6b191144a11..8fc7e6d0ebd 100644
--- a/litellm/llms/openai/openai.py
+++ b/litellm/llms/openai/openai.py
@@ -1626,7 +1626,7 @@ class OpenAIFilesAPI(BaseLLM):
openai_client: AsyncOpenAI,
) -> OpenAIFileObject:
response = await openai_client.files.create(**create_file_data) # type: ignore[arg-type]
- return OpenAIFileObject(**response.model_dump())
+ return OpenAIFileObject.model_validate(response.model_dump())
def create_file(
self,
@@ -1662,7 +1662,7 @@ class OpenAIFilesAPI(BaseLLM):
create_file_data=create_file_data, openai_client=openai_client
)
response = cast(OpenAI, openai_client).files.create(**create_file_data) # type: ignore[arg-type]
- return OpenAIFileObject(**response.model_dump())
+ return OpenAIFileObject.model_validate(response.model_dump())
async def afile_content(
self,
@@ -1986,7 +1986,7 @@ class OpenAIBatchesAPI(BaseLLM):
openai_client: AsyncOpenAI,
) -> LiteLLMBatch:
response = await openai_client.batches.create(**create_batch_data) # type: ignore[arg-type]
- return LiteLLMBatch(**response.model_dump())
+ return LiteLLMBatch.model_validate(response.model_dump())
def create_batch(
self,
@@ -2023,7 +2023,7 @@ class OpenAIBatchesAPI(BaseLLM):
)
response = cast(OpenAI, openai_client).batches.create(**create_batch_data) # type: ignore[arg-type]
- return LiteLLMBatch(**response.model_dump())
+ return LiteLLMBatch.model_validate(response.model_dump())
async def aretrieve_batch(
self,
@@ -2032,7 +2032,7 @@ class OpenAIBatchesAPI(BaseLLM):
) -> LiteLLMBatch:
verbose_logger.debug("retrieving batch, args= %s", retrieve_batch_data)
response = await openai_client.batches.retrieve(**retrieve_batch_data) # type: ignore[arg-type]
- return LiteLLMBatch(**response.model_dump())
+ return LiteLLMBatch.model_validate(response.model_dump())
def retrieve_batch(
self,
@@ -2068,7 +2068,7 @@ class OpenAIBatchesAPI(BaseLLM):
retrieve_batch_data=retrieve_batch_data, openai_client=openai_client
)
response = cast(OpenAI, openai_client).batches.retrieve(**retrieve_batch_data) # type: ignore[arg-type]
- return LiteLLMBatch(**response.model_dump())
+ return LiteLLMBatch.model_validate(response.model_dump())
async def acancel_batch(
self,
@@ -2077,7 +2077,7 @@ class OpenAIBatchesAPI(BaseLLM):
) -> LiteLLMBatch:
verbose_logger.debug("async cancelling batch, args= %s", cancel_batch_data)
response = await openai_client.batches.cancel(**cancel_batch_data)
- return LiteLLMBatch(**response.model_dump())
+ return LiteLLMBatch.model_validate(response.model_dump())
def cancel_batch(
self,
@@ -2117,7 +2117,7 @@ class OpenAIBatchesAPI(BaseLLM):
if not isinstance(openai_client, OpenAI):
raise ValueError("OpenAI client is not an instance of OpenAI. Make sure you passed a sync OpenAI client.")
response = openai_client.batches.cancel(**cancel_batch_data)
- return LiteLLMBatch(**response.model_dump())
+ return LiteLLMBatch.model_validate(response.model_dump())
async def alist_batches(
self,
@@ -2477,9 +2477,9 @@ class OpenAIAssistantsAPI(BaseLLM):
response_obj: Optional[OpenAIMessage] = None
if getattr(thread_message, "status", None) is None:
thread_message.status = "completed"
- response_obj = OpenAIMessage(**thread_message.dict())
+ response_obj = OpenAIMessage.model_validate(thread_message.dict())
else:
- response_obj = OpenAIMessage(**thread_message.dict())
+ response_obj = OpenAIMessage.model_validate(thread_message.dict())
return response_obj
# fmt: off
@@ -2556,9 +2556,9 @@ class OpenAIAssistantsAPI(BaseLLM):
response_obj: Optional[OpenAIMessage] = None
if getattr(thread_message, "status", None) is None:
thread_message.status = "completed"
- response_obj = OpenAIMessage(**thread_message.dict())
+ response_obj = OpenAIMessage.model_validate(thread_message.dict())
else:
- response_obj = OpenAIMessage(**thread_message.dict())
+ response_obj = OpenAIMessage.model_validate(thread_message.dict())
return response_obj
async def async_get_messages(
diff --git a/litellm/llms/openai/responses/transformation.py b/litellm/llms/openai/responses/transformation.py
index 3c2ae238a0b..dc4e98e6216 100644
--- a/litellm/llms/openai/responses/transformation.py
+++ b/litellm/llms/openai/responses/transformation.py
@@ -280,7 +280,7 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
raw_response_headers = dict(raw_response.headers)
processed_headers = process_response_headers(raw_response_headers)
try:
- response = ResponsesAPIResponse(**raw_response_json)
+ response = ResponsesAPIResponse.model_validate(raw_response_json)
except Exception:
verbose_logger.debug(f"Error constructing ResponsesAPIResponse: {raw_response_json}, using model_construct")
response = ResponsesAPIResponse.model_construct(**raw_response_json)
@@ -506,7 +506,7 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
raise OpenAIError(message=raw_response.text, status_code=raw_response.status_code)
raw_response_headers = dict(raw_response.headers)
processed_headers = process_response_headers(raw_response_headers)
- response = ResponsesAPIResponse(**raw_response_json)
+ response = ResponsesAPIResponse.model_validate(raw_response_json)
response._hidden_params["additional_headers"] = processed_headers
response._hidden_params["headers"] = raw_response_headers
@@ -588,7 +588,7 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
raw_response_headers = dict(raw_response.headers)
processed_headers = process_response_headers(raw_response_headers)
- response = ResponsesAPIResponse(**raw_response_json)
+ response = ResponsesAPIResponse.model_validate(raw_response_json)
response._hidden_params["additional_headers"] = processed_headers
response._hidden_params["headers"] = raw_response_headers
@@ -647,7 +647,7 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
processed_headers = process_response_headers(raw_response_headers)
try:
- response = ResponsesAPIResponse(**raw_response_json)
+ response = ResponsesAPIResponse.model_validate(raw_response_json)
except Exception:
verbose_logger.debug(f"Error constructing ResponsesAPIResponse: {raw_response_json}, using model_construct")
response = ResponsesAPIResponse.model_construct(**raw_response_json)
diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
index 3e0775ac09e..f61ac4866b0 100644
--- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
+++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
@@ -5322,7 +5322,7 @@ class MCPServerManager:
]
}
)
- db_mcp_servers = [LiteLLM_MCPServerTable(**r.model_dump()) for r in raw_rows]
+ db_mcp_servers = [LiteLLM_MCPServerTable.model_validate(r.model_dump()) for r in raw_rows]
verbose_logger.info(f"Found {len(db_mcp_servers)} MCP servers in database")
previous_registry = self.registry
diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py
index c46bc110ca8..9d023292074 100644
--- a/litellm/proxy/auth/auth_checks.py
+++ b/litellm/proxy/auth/auth_checks.py
@@ -2434,7 +2434,7 @@ class ExperimentalUIJWTToken:
if decrypted_token is None:
return None
try:
- return UserAPIKeyAuth(**json.loads(decrypted_token))
+ return UserAPIKeyAuth.model_validate(json.loads(decrypted_token))
except Exception as e:
raise Exception(f"Invalid hash key. Hash key={hashed_token}. Decrypted token={decrypted_token}. Error: {e}")
@@ -2553,7 +2553,7 @@ async def get_key_object(
code=status.HTTP_401_UNAUTHORIZED,
)
- _response = UserAPIKeyAuth(**_valid_token.model_dump(exclude_none=True))
+ _response = UserAPIKeyAuth.model_validate(_valid_token.model_dump(exclude_none=True))
# Load object_permission if object_permission_id exists but object_permission is not loaded
if _response.object_permission_id and not _response.object_permission:
diff --git a/litellm/proxy/auth/oauth2_proxy_hook.py b/litellm/proxy/auth/oauth2_proxy_hook.py
index 2b0593d3618..ca6a7ee4b1d 100644
--- a/litellm/proxy/auth/oauth2_proxy_hook.py
+++ b/litellm/proxy/auth/oauth2_proxy_hook.py
@@ -1,4 +1,5 @@
-from typing import Any, Dict, FrozenSet
+from collections.abc import Mapping
+from typing import Dict, FrozenSet, List, Union
from fastapi import Request
@@ -83,21 +84,17 @@ async def handle_oauth2_proxy_request(request: Request) -> UserAPIKeyAuth:
"(signature-validated) instead of header-trust."
)
- auth_data: Dict[str, Any] = {}
- for key, header in oauth2_config_mappings.items():
- value = request.headers.get(header)
- if not value:
- continue
- if key == "models":
- auth_data[key] = [model.strip() for model in value.split(",")]
- else:
- auth_data[key] = value
+ auth_data: Mapping[str, Union[str, List[str]]] = {
+ key: [model.strip() for model in value.split(",")] if key == "models" else value
+ for key, header in oauth2_config_mappings.items()
+ if (value := request.headers.get(header))
+ }
verbose_proxy_logger.debug(
"Auth data before creating UserAPIKeyAuth object: keys=%s",
list(auth_data.keys()),
)
- user_api_key_auth = UserAPIKeyAuth(**auth_data)
+ user_api_key_auth = UserAPIKeyAuth.model_validate(auth_data)
verbose_proxy_logger.debug(
"UserAPIKeyAuth object created with keys: %s",
list(user_api_key_auth.__fields_set__),
diff --git a/litellm/proxy/auth/resolvers/store.py b/litellm/proxy/auth/resolvers/store.py
index ad9fd234163..7c2bd324064 100644
--- a/litellm/proxy/auth/resolvers/store.py
+++ b/litellm/proxy/auth/resolvers/store.py
@@ -118,7 +118,7 @@ class IdentityStore:
if from_db is None:
raise KeyNotFoundError(hashed_token)
- key = UserAPIKeyAuth(**from_db.model_dump(exclude_none=True))
+ key = UserAPIKeyAuth.model_validate(from_db.model_dump(exclude_none=True))
if key.object_permission_id and not key.object_permission:
try:
diff --git a/litellm/proxy/management_endpoints/common_utils.py b/litellm/proxy/management_endpoints/common_utils.py
index 8162babef40..877130c2066 100644
--- a/litellm/proxy/management_endpoints/common_utils.py
+++ b/litellm/proxy/management_endpoints/common_utils.py
@@ -216,7 +216,7 @@ async def _user_has_admin_privileges(
teams = await TeamRepository(prisma_client).table.find_many(where={"team_id": {"in": user_obj.teams}})
for team in teams:
- team_obj = LiteLLM_TeamTable(**team.model_dump())
+ team_obj = LiteLLM_TeamTable.model_validate(team.model_dump())
if _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=team_obj):
return True
@@ -288,7 +288,7 @@ async def _team_admin_can_invite_user(
for team in teams
if _is_user_team_admin(
user_api_key_dict=user_api_key_dict,
- team_obj=LiteLLM_TeamTable(**team.model_dump()),
+ team_obj=LiteLLM_TeamTable.model_validate(team.model_dump()),
)
]
if not admin_team_ids:
diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py
index 1205d23ce02..64cc13a5543 100644
--- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py
+++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py
@@ -459,7 +459,7 @@ if MCP_AVAILABLE:
payload_dict: dict[str, Any] = loaded
try:
- return MCPServer(**payload_dict)
+ return MCPServer.model_validate(payload_dict)
except Exception as e:
verbose_proxy_logger.debug(f"Invalid temporary MCP server payload in Redis cache: {str(e)}")
return None
@@ -704,7 +704,7 @@ if MCP_AVAILABLE:
except AttributeError:
payload_dict = payload.dict() # type: ignore[attr-defined]
payload_dict["credentials"] = inherited_credentials
- return NewMCPServerRequest(**payload_dict)
+ return NewMCPServerRequest.model_validate(payload_dict)
def _build_temporary_mcp_server_record(
payload: NewMCPServerRequest,
diff --git a/litellm/proxy/management_helpers/utils.py b/litellm/proxy/management_helpers/utils.py
index 7c52b04c4eb..14ba1dbfde1 100644
--- a/litellm/proxy/management_helpers/utils.py
+++ b/litellm/proxy/management_helpers/utils.py
@@ -308,7 +308,7 @@ async def add_new_member(
)
await _append_team_id_if_absent(prisma_client, new_member.user_id, team_id)
if _returned_user is not None:
- returned_user = LiteLLM_UserTable(**_returned_user.model_dump())
+ returned_user = LiteLLM_UserTable.model_validate(_returned_user.model_dump())
elif new_member.user_email is not None:
new_user_defaults = get_new_internal_user_defaults(user_id=str(uuid.uuid4()), user_email=new_member.user_email)
## user email is not unique acc. to prisma schema -> future improvement
@@ -323,11 +323,11 @@ async def add_new_member(
_returned_user = await prisma_client.insert_data(data=new_user_defaults, table_name="user") # type: ignore
if _returned_user is not None:
- returned_user = LiteLLM_UserTable(**_returned_user.model_dump())
+ returned_user = LiteLLM_UserTable.model_validate(_returned_user.model_dump())
elif len(existing_user_row) == 1:
user_info = existing_user_row[0]
await _append_team_id_if_absent(prisma_client, user_info.user_id, team_id)
- returned_user = LiteLLM_UserTable(**user_info.model_dump())
+ returned_user = LiteLLM_UserTable.model_validate(user_info.model_dump())
elif len(existing_user_row) > 1:
raise HTTPException(
status_code=400,
@@ -354,7 +354,7 @@ async def add_new_member(
include={"litellm_budget_table": True},
)
- returned_team_membership = LiteLLM_TeamMembership(**_returned_team_membership.model_dump())
+ returned_team_membership = LiteLLM_TeamMembership.model_validate(_returned_team_membership.model_dump())
if returned_user is None:
raise Exception("Unable to update user table with membership information!")
diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py
index 18a927e7a44..ba41083e9b8 100644
--- a/litellm/proxy/proxy_server.py
+++ b/litellm/proxy/proxy_server.py
@@ -5398,7 +5398,7 @@ class ProxyConfig:
# decrypt values
for k, v in _litellm_params.items():
_litellm_params[k] = self._resolve_db_litellm_param(key=k, value=v)
- _litellm_params = LiteLLM_Params(**_litellm_params)
+ _litellm_params = LiteLLM_Params.model_validate(_litellm_params)
else:
verbose_proxy_logger.error(
@@ -5429,7 +5429,7 @@ class ProxyConfig:
# decrypt values
for k, v in _litellm_params.items():
_litellm_params[k] = self._resolve_db_litellm_param(key=k, value=v)
- _litellm_params = LiteLLM_Params(**_litellm_params)
+ _litellm_params = LiteLLM_Params.model_validate(_litellm_params)
else:
verbose_proxy_logger.error(
f"Invalid model added to proxy db. Invalid litellm params. litellm_params={_litellm_params}"
@@ -13063,7 +13063,7 @@ def _get_model_group_info(
_model_group_info = llm_router.get_model_group_info(model_group=model)
if _model_group_info is not None:
- model_groups.append(ModelGroupInfoProxy(**_model_group_info.model_dump()))
+ model_groups.append(ModelGroupInfoProxy.model_validate(_model_group_info.model_dump()))
else:
model_group_info = ModelGroupInfoProxy(
model_group=model,
@@ -14782,7 +14782,7 @@ async def update_config_general_settings(
)
try:
- ConfigGeneralSettings(**{data.field_name: data.field_value})
+ ConfigGeneralSettings.model_validate({data.field_name: data.field_value})
except Exception:
raise HTTPException(
status_code=400,
diff --git a/litellm/repositories/base_repository.py b/litellm/repositories/base_repository.py
index 40aeb6df3de..755e4595c01 100644
--- a/litellm/repositories/base_repository.py
+++ b/litellm/repositories/base_repository.py
@@ -3,38 +3,58 @@ Base repository class with common functionality.
"""
from abc import ABC, abstractmethod
-from typing import Any, Dict, Generic, List, Optional, Type, TypeVar
+from collections.abc import Iterable, Mapping, Sequence
+from typing import Any, Dict, Generic, List, Optional, Protocol, Tuple, Type, TypeVar, Union, runtime_checkable
from pydantic import BaseModel
T = TypeVar("T", bound=BaseModel)
-def _record_to_dict(record: Any) -> Dict[str, Any]:
- if isinstance(record, dict):
- return record
- if hasattr(record, "model_dump") and callable(record.model_dump):
+@runtime_checkable
+class SupportsModelDump(Protocol):
+ def model_dump(self) -> Dict[str, object]: ...
+
+
+@runtime_checkable
+class SupportsDict(Protocol):
+ def dict(self) -> Dict[str, object]: ...
+
+
+DbRecord = Union[
+ Mapping[str, object],
+ SupportsModelDump,
+ SupportsDict,
+ Sequence[Tuple[str, object]],
+]
+
+
+def record_to_dict(record: DbRecord) -> Mapping[str, object]:
+ """Project a database record into a mapping of column name to value."""
+ if isinstance(record, SupportsModelDump):
return record.model_dump()
- if hasattr(record, "dict") and callable(record.dict):
+ if isinstance(record, SupportsDict):
return record.dict()
- return dict(record)
+ if isinstance(record, Mapping):
+ return record
+ return {key: value for key, value in record}
class BaseRepository(ABC, Generic[T]):
"""Abstract base class for all repositories."""
- def __init__(self, prisma_client: Any):
+ def __init__(self, prisma_client: Any): # any-ok: PrismaClient is an untyped runtime wrapper
self._prisma_client = prisma_client
@property
- def prisma_client(self) -> Any:
+ def prisma_client(self) -> Any: # any-ok: PrismaClient is an untyped runtime wrapper
if self._prisma_client is None:
raise RuntimeError("No DB Connected. See - https://docs.litellm.ai/docs/proxy/virtual_keys")
return self._prisma_client
@property
@abstractmethod
- def table(self) -> Any:
+ def table(self) -> Any: # any-ok: Prisma table actions are reached through the untyped client wrapper
"""Return the Prisma table for this repository."""
...
@@ -44,21 +64,15 @@ class BaseRepository(ABC, Generic[T]):
"""Return the domain model class for this repository."""
...
- def _to_model(self, record: Any) -> Optional[T]:
+ def _to_model(self, record: Optional[DbRecord]) -> Optional[T]:
"""Convert a database record to a domain model."""
if record is None:
return None
- return self.model_class(**_record_to_dict(record))
+ return self.model_class.model_validate(record_to_dict(record))
- def _to_model_list(self, records: List[Any]) -> List[T]:
+ def _to_model_list(self, records: Iterable[Optional[DbRecord]]) -> List[T]:
"""Convert a list of database records to domain models."""
- result: List[T] = []
- for r in records:
- if r is not None:
- model = self._to_model(r)
- if model is not None:
- result.append(model)
- return result
+ return [model for record in records if record is not None and (model := self._to_model(record)) is not None]
async def find_by_id(self, id_value: str, id_field: str = "id") -> Optional[T]:
"""Find a record by its primary key."""
diff --git a/litellm/repositories/organization_repository.py b/litellm/repositories/organization_repository.py
index d5f8c990001..99c4a881736 100644
--- a/litellm/repositories/organization_repository.py
+++ b/litellm/repositories/organization_repository.py
@@ -26,10 +26,8 @@ class OrganizationRepository(BaseRepository[LiteLLM_OrganizationTable]):
async def find_by_alias(self, organization_alias: str) -> Optional[LiteLLM_OrganizationTable]:
"""Find an organization by alias."""
- records = await self.table.find_many(where={"organization_alias": organization_alias})
- if records:
- return self._to_model(records[0])
- return None
+ organizations = await self.find_many(where={"organization_alias": organization_alias})
+ return organizations[0] if organizations else None
async def create_organization(
self,
diff --git a/litellm/repositories/project_repository.py b/litellm/repositories/project_repository.py
index 86faaf2e13c..27cb346e1b1 100644
--- a/litellm/repositories/project_repository.py
+++ b/litellm/repositories/project_repository.py
@@ -24,15 +24,12 @@ class ProjectRepository(BaseRepository[LiteLLM_ProjectTable]):
async def find_by_alias(self, project_alias: str) -> Optional[LiteLLM_ProjectTable]:
"""Find a project by alias."""
- records = await self.table.find_many(where={"project_alias": project_alias})
- if records:
- return self._to_model(records[0])
- return None
+ projects = await self.find_many(where={"project_alias": project_alias})
+ return projects[0] if projects else None
async def find_by_team_id(self, team_id: str) -> List[LiteLLM_ProjectTable]:
"""Find all projects belonging to a team."""
- records = await self.table.find_many(where={"team_id": team_id})
- return self._to_model_list(records)
+ return await self.find_many(where={"team_id": team_id})
async def create_project(
self,
diff --git a/litellm/repositories/team_repository.py b/litellm/repositories/team_repository.py
index 68875bd7972..25437cfe49a 100644
--- a/litellm/repositories/team_repository.py
+++ b/litellm/repositories/team_repository.py
@@ -3,55 +3,59 @@ Team repository for database operations on LiteLLM_TeamTable.
"""
import json
+from collections.abc import Mapping
from datetime import datetime
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type
from pydantic import TypeAdapter
from litellm.models.team import LiteLLM_TeamTable, Member
-from litellm.repositories.base_repository import BaseRepository
+from litellm.repositories.base_repository import (
+ BaseRepository,
+ DbRecord,
+ record_to_dict,
+)
if TYPE_CHECKING:
from prisma import Prisma
_MEMBERS_WITH_ROLES_ADAPTER = TypeAdapter(list[Member])
+_JSON_ENCODED_TEAM_FIELDS = (
+ "metadata",
+ "model_spend",
+ "model_max_budget",
+ "router_settings",
+ "budget_limits",
+ "members_with_roles",
+)
class TeamRepository(BaseRepository[LiteLLM_TeamTable]):
"""Repository for team database operations."""
@property
- def table(self) -> Any:
+ def table(self) -> Any: # any-ok: PrismaClient.db is an untyped runtime wrapper
return self.prisma_client.db.litellm_teamtable
@property
- def deleted_table(self) -> Any:
+ def deleted_table(self) -> Any: # any-ok: PrismaClient.db is an untyped runtime wrapper
return self.prisma_client.db.litellm_deletedteamtable
@property
def model_class(self) -> Type[LiteLLM_TeamTable]:
return LiteLLM_TeamTable
- def _to_model(self, record: Any) -> Optional[LiteLLM_TeamTable]:
+ def _to_model(self, record: Optional[DbRecord]) -> Optional[LiteLLM_TeamTable]:
"""Convert a database record to a Team model."""
if record is None:
return None
- data = record.dict() if hasattr(record, "dict") else dict(record)
+ data = {
+ field: json.loads(value) if field in _JSON_ENCODED_TEAM_FIELDS and isinstance(value, str) else value
+ for field, value in record_to_dict(record).items()
+ }
- json_fields = [
- "metadata",
- "model_spend",
- "model_max_budget",
- "router_settings",
- "budget_limits",
- "members_with_roles",
- ]
- for field in json_fields:
- if isinstance(data.get(field), str):
- data[field] = json.loads(data[field])
-
- return LiteLLM_TeamTable(**data)
+ return LiteLLM_TeamTable.model_validate(data)
async def get_members_with_roles_locked(self, tx: "Prisma", team_id: str) -> List[Member]:
"""Return the team's members_with_roles, locking the row FOR UPDATE.
@@ -103,8 +107,8 @@ class TeamRepository(BaseRepository[LiteLLM_TeamTable]):
organization_id: Optional[str] = None,
admins: Optional[List[str]] = None,
members: Optional[List[str]] = None,
- members_with_roles: Optional[Dict[str, Any]] = None,
- metadata: Optional[Dict[str, Any]] = None,
+ members_with_roles: Optional[Mapping[str, object]] = None,
+ metadata: Optional[Mapping[str, object]] = None,
max_budget: Optional[float] = None,
soft_budget: Optional[float] = None,
models: Optional[List[str]] = None,
@@ -115,7 +119,7 @@ class TeamRepository(BaseRepository[LiteLLM_TeamTable]):
object_permission_id: Optional[str] = None,
) -> LiteLLM_TeamTable:
"""Create a new team."""
- data: Dict[str, Any] = {"team_id": team_id}
+ data: Dict[str, object] = {"team_id": team_id}
if team_alias is not None:
data["team_alias"] = team_alias
if organization_id is not None:
@@ -154,8 +158,8 @@ class TeamRepository(BaseRepository[LiteLLM_TeamTable]):
organization_id: Optional[str] = None,
admins: Optional[List[str]] = None,
members: Optional[List[str]] = None,
- members_with_roles: Optional[Dict[str, Any]] = None,
- metadata: Optional[Dict[str, Any]] = None,
+ members_with_roles: Optional[Mapping[str, object]] = None,
+ metadata: Optional[Mapping[str, object]] = None,
max_budget: Optional[float] = None,
soft_budget: Optional[float] = None,
models: Optional[List[str]] = None,
@@ -167,7 +171,7 @@ class TeamRepository(BaseRepository[LiteLLM_TeamTable]):
object_permission_id: Optional[str] = None,
) -> Optional[LiteLLM_TeamTable]:
"""Update a team."""
- data: Dict[str, Any] = {}
+ data: Dict[str, object] = {}
if team_alias is not None:
data["team_alias"] = team_alias
if organization_id is not None:
@@ -228,9 +232,9 @@ class TeamRepository(BaseRepository[LiteLLM_TeamTable]):
return team
- def _build_archive_data(self, team: LiteLLM_TeamTable) -> Dict[str, Any]:
+ def _build_archive_data(self, team: LiteLLM_TeamTable) -> Dict[str, object]:
"""Build archive data dict with only columns that exist in LiteLLM_DeletedTeamTable."""
- data: Dict[str, Any] = {"team_id": team.team_id}
+ data: Dict[str, object] = {"team_id": team.team_id}
if team.team_alias is not None:
data["team_alias"] = team.team_alias
if team.organization_id is not None:
diff --git a/litellm/repositories/verification_token_repository.py b/litellm/repositories/verification_token_repository.py
index 19352c1b3c4..f7795f15fd5 100644
--- a/litellm/repositories/verification_token_repository.py
+++ b/litellm/repositories/verification_token_repository.py
@@ -3,14 +3,18 @@ VerificationToken repository for database operations on LiteLLM_VerificationToke
"""
import json
-from collections.abc import Iterator, Mapping
+from collections.abc import Mapping
from datetime import datetime
-from typing import TYPE_CHECKING, Any, Protocol
+from typing import TYPE_CHECKING, Any
from litellm.models.verification_token import (
LiteLLM_VerificationToken,
)
-from litellm.repositories.base_repository import BaseRepository
+from litellm.repositories.base_repository import (
+ BaseRepository,
+ DbRecord,
+ record_to_dict,
+)
if TYPE_CHECKING:
from prisma.models import (
@@ -19,11 +23,17 @@ if TYPE_CHECKING:
from litellm.proxy.utils import PrismaClient
-
-class _DictConvertible(Protocol):
- def dict(self) -> dict[str, object]: ...
-
- def __iter__(self) -> Iterator[tuple[str, object]]: ...
+_JSON_ENCODED_TOKEN_FIELDS = (
+ "aliases",
+ "config",
+ "permissions",
+ "metadata",
+ "model_spend",
+ "model_max_budget",
+ "router_settings",
+ "budget_limits",
+ "litellm_budget_table",
+)
class VerificationTokenRepository(BaseRepository[LiteLLM_VerificationToken]):
@@ -46,31 +56,21 @@ class VerificationTokenRepository(BaseRepository[LiteLLM_VerificationToken]):
def model_class(self) -> type[LiteLLM_VerificationToken]:
return LiteLLM_VerificationToken
- def _to_model(self, record: _DictConvertible | None) -> LiteLLM_VerificationToken | None:
+ def _to_model(self, record: DbRecord | None) -> LiteLLM_VerificationToken | None:
"""Convert a database record to a VerificationToken model."""
if record is None:
return None
- data = record.dict() if hasattr(record, "dict") else dict(record)
-
- json_fields = [
- "aliases",
- "config",
- "permissions",
- "metadata",
- "model_spend",
- "model_max_budget",
- "router_settings",
- "budget_limits",
- "litellm_budget_table",
- ]
- for field in json_fields:
- value = data.get(field)
- if isinstance(value, str):
- data[field] = json.loads(value)
-
- if data.get("org_id") is None and data.get("organization_id") is not None:
- data["org_id"] = data["organization_id"]
+ decoded = {
+ field: json.loads(value) if field in _JSON_ENCODED_TOKEN_FIELDS and isinstance(value, str) else value
+ for field, value in record_to_dict(record).items()
+ }
+ organization_id = decoded.get("organization_id")
+ data = (
+ decoded
+ if decoded.get("org_id") is not None or organization_id is None
+ else {**decoded, "org_id": organization_id}
+ )
return LiteLLM_VerificationToken.model_validate(data)
diff --git a/ruff-strict-budget.json b/ruff-strict-budget.json
index 3b5ec5b0dee..f1c205c2425 100644
--- a/ruff-strict-budget.json
+++ b/ruff-strict-budget.json
@@ -24,7 +24,7 @@
"limit": 130
},
"ANN401": {
- "limit": 2013
+ "limit": 2010
},
"ASYNC230": {
"limit": 14
@@ -324,7 +324,7 @@
"limit": 883
},
"UP006": {
- "limit": 12146
+ "limit": 12142
},
"UP007": {
"limit": 2526
diff --git a/tests/test_litellm/repositories/test_repositories.py b/tests/test_litellm/repositories/test_repositories.py
index 6308faf8fc7..c923b722991 100644
--- a/tests/test_litellm/repositories/test_repositories.py
+++ b/tests/test_litellm/repositories/test_repositories.py
@@ -203,23 +203,32 @@ class TestBaseRepository:
assert len(budgets) == 1
def test_record_to_dict_branches(self):
- from litellm.repositories.base_repository import _record_to_dict
+ from litellm.repositories.base_repository import record_to_dict
- assert _record_to_dict({"a": 1}) == {"a": 1}
+ assert record_to_dict({"a": 1}) == {"a": 1}
class WithModelDump:
def model_dump(self):
return {"src": "model_dump"}
- assert _record_to_dict(WithModelDump()) == {"src": "model_dump"}
+ assert record_to_dict(WithModelDump()) == {"src": "model_dump"}
class WithDict:
def dict(self):
return {"src": "dict"}
- assert _record_to_dict(WithDict()) == {"src": "dict"}
+ assert record_to_dict(WithDict()) == {"src": "dict"}
- assert _record_to_dict([("k", "v")]) == {"k": "v"}
+ assert record_to_dict([("k", "v")]) == {"k": "v"}
+
+ class WithBoth:
+ def model_dump(self):
+ return {"src": "model_dump"}
+
+ def dict(self):
+ return {"src": "dict"}
+
+ assert record_to_dict(WithBoth()) == {"src": "model_dump"}
class TestBudgetRepository:
diff --git a/type-discipline-budget.json b/type-discipline-budget.json
index 25cc1621d54..69b2506dc08 100644
--- a/type-discipline-budget.json
+++ b/type-discipline-budget.json
@@ -1,9 +1,9 @@
{
"LIT001": {
- "limit": 23267
+ "limit": 23261
},
"LIT002": {
- "limit": 27434
+ "limit": 27433
},
"LIT003": {
"limit": 292
From 59118ae5b648666055e240f39b6301601a580920 Mon Sep 17 00:00:00 2001
From: Yassin Kortam
Date: Thu, 30 Jul 2026 10:29:40 -0700
Subject: [PATCH 12/33] feat(cookbook): add a Grafana dashboard for the OTel
GenAI metrics (#35159)
The existing dashboards in this folder chart the litellm_* Prometheus metrics.
Nothing charted the gen_ai.* metrics the OpenTelemetry v2 integration emits, and
Grafana's own prebuilt GenAI dashboards cannot: twenty of their twenty-two panels
filter on telemetry_sdk_name="openlit", a label LiteLLM does not carry and has no
setting to add.
Ten panels over the six gen_ai instruments: spend, tokens, request count and p95
duration as stats, then request rate, spend per hour, tokens per minute split by
input and output, and p95 duration, time to first token, and provider generation
time by model. Template variables for data source, service, and model.
Verified against a live Grafana Cloud stack with real traffic across three
models. The readme documents the attribute filter the panels depend on, since the
default attribute set gives nearly every request its own series and makes every
rate-based panel read zero.
---
.../grafana_dashboard.json | 523 ++++++++++++++++++
.../dashboard_genai_otel/readme.md | 35 ++
.../grafana_dashboard/readme.md | 4 +
3 files changed, 562 insertions(+)
create mode 100644 cookbook/litellm_proxy_server/grafana_dashboard/dashboard_genai_otel/grafana_dashboard.json
create mode 100644 cookbook/litellm_proxy_server/grafana_dashboard/dashboard_genai_otel/readme.md
diff --git a/cookbook/litellm_proxy_server/grafana_dashboard/dashboard_genai_otel/grafana_dashboard.json b/cookbook/litellm_proxy_server/grafana_dashboard/dashboard_genai_otel/grafana_dashboard.json
new file mode 100644
index 00000000000..70608a2ffe8
--- /dev/null
+++ b/cookbook/litellm_proxy_server/grafana_dashboard/dashboard_genai_otel/grafana_dashboard.json
@@ -0,0 +1,523 @@
+{
+ "annotations": {
+ "list": []
+ },
+ "editable": true,
+ "fiscalYearStartMonth": 0,
+ "graphTooltip": 0,
+ "links": [],
+ "panels": [
+ {
+ "type": "stat",
+ "title": "Requests",
+ "gridPos": {
+ "h": 4,
+ "w": 6,
+ "x": 0,
+ "y": 0
+ },
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${datasource}"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "short",
+ "decimals": 0,
+ "color": {
+ "mode": "fixed",
+ "fixedColor": "blue"
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ],
+ "fields": "",
+ "values": false
+ },
+ "colorMode": "background",
+ "graphMode": "none"
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "editorMode": "code",
+ "instant": true,
+ "expr": "sum(increase(gen_ai_client_operation_duration_seconds_count{service_name=~\"$service\", gen_ai_request_model=~\"$model\"}[$__range]))"
+ }
+ ],
+ "id": 1
+ },
+ {
+ "type": "stat",
+ "title": "Spend",
+ "description": "LiteLLM's computed cost for the selected window, from gen_ai.usage.cost",
+ "gridPos": {
+ "h": 4,
+ "w": 6,
+ "x": 6,
+ "y": 0
+ },
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${datasource}"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "currencyUSD",
+ "decimals": 4,
+ "color": {
+ "mode": "fixed",
+ "fixedColor": "green"
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ],
+ "fields": "",
+ "values": false
+ },
+ "colorMode": "background",
+ "graphMode": "none"
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "editorMode": "code",
+ "instant": true,
+ "expr": "sum(increase(gen_ai_usage_cost_USD_sum{service_name=~\"$service\", gen_ai_request_model=~\"$model\"}[$__range]))"
+ }
+ ],
+ "id": 2
+ },
+ {
+ "type": "stat",
+ "title": "Tokens",
+ "gridPos": {
+ "h": 4,
+ "w": 6,
+ "x": 12,
+ "y": 0
+ },
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${datasource}"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "short",
+ "decimals": 0,
+ "color": {
+ "mode": "fixed",
+ "fixedColor": "purple"
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ],
+ "fields": "",
+ "values": false
+ },
+ "colorMode": "background",
+ "graphMode": "none"
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "editorMode": "code",
+ "instant": true,
+ "expr": "sum(increase(gen_ai_client_token_usage_sum{service_name=~\"$service\", gen_ai_request_model=~\"$model\"}[$__range]))"
+ }
+ ],
+ "id": 3
+ },
+ {
+ "type": "stat",
+ "title": "p95 request duration",
+ "gridPos": {
+ "h": 4,
+ "w": 6,
+ "x": 18,
+ "y": 0
+ },
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${datasource}"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "s",
+ "decimals": 2,
+ "color": {
+ "mode": "fixed",
+ "fixedColor": "orange"
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ],
+ "fields": "",
+ "values": false
+ },
+ "colorMode": "background",
+ "graphMode": "none"
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "editorMode": "code",
+ "instant": true,
+ "expr": "histogram_quantile(0.95, sum by (le) (rate(gen_ai_client_operation_duration_seconds_bucket{service_name=~\"$service\", gen_ai_request_model=~\"$model\"}[$__range])))"
+ }
+ ],
+ "id": 4
+ },
+ {
+ "type": "timeseries",
+ "title": "Request rate by model",
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 4
+ },
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${datasource}"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "reqpm",
+ "custom": {
+ "lineWidth": 2,
+ "fillOpacity": 8,
+ "showPoints": "never"
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom"
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "desc"
+ }
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "editorMode": "code",
+ "legendFormat": "{{gen_ai_request_model}}",
+ "expr": "sum by (gen_ai_request_model) (rate(gen_ai_client_operation_duration_seconds_count{service_name=~\"$service\", gen_ai_request_model=~\"$model\"}[$__rate_interval])) * 60"
+ }
+ ],
+ "id": 5
+ },
+ {
+ "type": "timeseries",
+ "title": "Spend rate by model",
+ "description": "USD per hour, derived from the gen_ai.usage.cost histogram",
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 4
+ },
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${datasource}"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "currencyUSD",
+ "custom": {
+ "lineWidth": 2,
+ "fillOpacity": 8,
+ "showPoints": "never"
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom"
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "desc"
+ }
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "editorMode": "code",
+ "legendFormat": "{{gen_ai_request_model}}",
+ "expr": "sum by (gen_ai_request_model) (rate(gen_ai_usage_cost_USD_sum{service_name=~\"$service\", gen_ai_request_model=~\"$model\"}[$__rate_interval])) * 3600"
+ }
+ ],
+ "id": 6
+ },
+ {
+ "type": "timeseries",
+ "title": "Tokens per minute by model and type",
+ "description": "gen_ai.client.token.usage split by the gen_ai.token.type attribute",
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 12
+ },
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${datasource}"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "short",
+ "custom": {
+ "lineWidth": 2,
+ "fillOpacity": 8,
+ "showPoints": "never"
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom"
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "desc"
+ }
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "editorMode": "code",
+ "legendFormat": "{{gen_ai_request_model}} {{gen_ai_token_type}}",
+ "expr": "sum by (gen_ai_request_model, gen_ai_token_type) (rate(gen_ai_client_token_usage_sum{service_name=~\"$service\", gen_ai_request_model=~\"$model\"}[$__rate_interval])) * 60"
+ }
+ ],
+ "id": 7
+ },
+ {
+ "type": "timeseries",
+ "title": "p95 request duration by model",
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 12
+ },
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${datasource}"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "s",
+ "custom": {
+ "lineWidth": 2,
+ "fillOpacity": 0,
+ "showPoints": "never"
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom"
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "desc"
+ }
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "editorMode": "code",
+ "legendFormat": "{{gen_ai_request_model}}",
+ "expr": "histogram_quantile(0.95, sum by (le, gen_ai_request_model) (rate(gen_ai_client_operation_duration_seconds_bucket{service_name=~\"$service\", gen_ai_request_model=~\"$model\"}[$__rate_interval])))"
+ }
+ ],
+ "id": 8
+ },
+ {
+ "type": "timeseries",
+ "title": "p95 time to first token (streaming)",
+ "description": "gen_ai.server.time_to_first_token, recorded only for streaming requests",
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 20
+ },
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${datasource}"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "s",
+ "custom": {
+ "lineWidth": 2,
+ "fillOpacity": 0,
+ "showPoints": "never"
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom"
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "desc"
+ }
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "editorMode": "code",
+ "legendFormat": "{{gen_ai_request_model}}",
+ "expr": "histogram_quantile(0.95, sum by (le, gen_ai_request_model) (rate(gen_ai_server_time_to_first_token_seconds_bucket{service_name=~\"$service\", gen_ai_request_model=~\"$model\"}[$__rate_interval])))"
+ }
+ ],
+ "id": 9
+ },
+ {
+ "type": "timeseries",
+ "title": "p95 provider generation time",
+ "description": "gen_ai.client.response.duration, upstream generation time excluding LiteLLM overhead",
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 20
+ },
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${datasource}"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "s",
+ "custom": {
+ "lineWidth": 2,
+ "fillOpacity": 0,
+ "showPoints": "never"
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom"
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "desc"
+ }
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "editorMode": "code",
+ "legendFormat": "{{gen_ai_request_model}}",
+ "expr": "histogram_quantile(0.95, sum by (le, gen_ai_request_model) (rate(gen_ai_client_response_duration_seconds_bucket{service_name=~\"$service\", gen_ai_request_model=~\"$model\"}[$__rate_interval])))"
+ }
+ ],
+ "id": 10
+ }
+ ],
+ "preload": false,
+ "refresh": "30s",
+ "schemaVersion": 42,
+ "tags": [
+ "litellm",
+ "genai",
+ "opentelemetry"
+ ],
+ "templating": {
+ "list": [
+ {
+ "name": "datasource",
+ "label": "Prometheus",
+ "type": "datasource",
+ "query": "prometheus",
+ "current": {},
+ "hide": 0
+ },
+ {
+ "name": "service",
+ "label": "Service",
+ "type": "query",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${datasource}"
+ },
+ "query": "label_values(gen_ai_client_operation_duration_seconds_count, service_name)",
+ "refresh": 2,
+ "includeAll": true,
+ "multi": true,
+ "current": {
+ "text": "All",
+ "value": "$__all"
+ }
+ },
+ {
+ "name": "model",
+ "label": "Model",
+ "type": "query",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${datasource}"
+ },
+ "query": "label_values(gen_ai_client_operation_duration_seconds_count{service_name=~\"$service\"}, gen_ai_request_model)",
+ "refresh": 2,
+ "includeAll": true,
+ "multi": true,
+ "current": {
+ "text": "All",
+ "value": "$__all"
+ }
+ }
+ ]
+ },
+ "time": {
+ "from": "now-1h",
+ "to": "now"
+ },
+ "timepicker": {},
+ "timezone": "browser",
+ "title": "LiteLLM GenAI (OpenTelemetry)",
+ "uid": "litellm-genai-otel",
+ "weekStart": ""
+}
diff --git a/cookbook/litellm_proxy_server/grafana_dashboard/dashboard_genai_otel/readme.md b/cookbook/litellm_proxy_server/grafana_dashboard/dashboard_genai_otel/readme.md
new file mode 100644
index 00000000000..c51f0166462
--- /dev/null
+++ b/cookbook/litellm_proxy_server/grafana_dashboard/dashboard_genai_otel/readme.md
@@ -0,0 +1,35 @@
+# LiteLLM GenAI dashboard (OpenTelemetry metrics)
+
+Dashboard for the `gen_ai.*` metrics the OpenTelemetry v2 integration emits, as opposed to the `litellm_*` Prometheus metrics the other dashboards in this folder chart.
+
+Import `grafana_dashboard.json` from **Dashboards > New > Import** and pick your Prometheus data source. Panels: request count, spend, token count, p95 duration, request rate by model, spend rate per hour by model, tokens per minute split by input and output, p95 duration by model, p95 time to first token, and p95 provider generation time. Template variables for data source, service, and model.
+
+## Pre-requisites
+
+Metrics are off by default. In the proxy environment:
+
+```shell
+LITELLM_OTEL_V2=true
+LITELLM_OTEL_INTEGRATION_ENABLE_METRICS=true
+OTEL_EXPORTER="otlp_http"
+OTEL_ENDPOINT=""
+```
+
+You also need the metric attribute filter, or the panels will plot flat lines at zero. LiteLLM's default attribute set includes per-request fields, so nearly every request lands in its own time series with a single sample, and `rate()` has nothing to compute over:
+
+```yaml title="config.yaml"
+callback_settings:
+ otel:
+ attributes:
+ include_list:
+ - gen_ai.operation.name
+ - gen_ai.system
+ - gen_ai.request.model
+ - gen_ai.framework
+```
+
+See [Grafana Cloud](https://docs.litellm.ai/docs/observability/grafana_cloud) for the full setup, and [OpenTelemetry v2](https://docs.litellm.ai/docs/observability/opentelemetry_v2#metrics) for the metric reference.
+
+## Note on Grafana's AI Observability integration
+
+Grafana Cloud ships prebuilt GenAI dashboards that query these same metric names, so they look like a drop-in alternative to this one. They are not: twenty of their twenty-two panels filter on `telemetry_sdk_name="openlit"`, a label LiteLLM does not carry and cannot be configured to add, so those panels stay empty.
diff --git a/cookbook/litellm_proxy_server/grafana_dashboard/readme.md b/cookbook/litellm_proxy_server/grafana_dashboard/readme.md
index 81235c308f2..a1564a406e0 100644
--- a/cookbook/litellm_proxy_server/grafana_dashboard/readme.md
+++ b/cookbook/litellm_proxy_server/grafana_dashboard/readme.md
@@ -2,6 +2,10 @@
This folder contains the `json` for creating Grafana Dashboards
+## [LiteLLM GenAI Dashboard (OpenTelemetry)](./dashboard_genai_otel)
+
+Charts the `gen_ai.*` metrics from the OpenTelemetry v2 integration: spend, tokens, request rate, and latency percentiles by model. Separate from the dashboards below, which chart the `litellm_*` Prometheus metrics.
+
## [LiteLLM v2 Dashboard](./dashboard_v2)
From 71dfab71772e9f0b34c96f98f079d46bd8130ba5 Mon Sep 17 00:00:00 2001
From: tin-berri
Date: Thu, 30 Jul 2026 11:55:10 -0700
Subject: [PATCH 13/33] feat(router): record why the auto-router picked a tier
and show it in the logs (#35016)
Auto-routed requests were indistinguishable from ordinary ones once logged:
the spend log recorded the requested model group and the resolved deployment,
but nothing about which tier was chosen or what chose it. That information
existed only inside verbose_router_logger f-strings, so answering "why did my
prompt land on the cheap model" required log access and a running proxy.
The complexity, quality, and adaptive pre-routing strategies now return a typed
StandardLoggingRoutingDecision on their PreRoutingHookResponse, and
Router.async_pre_routing_hook records it once for every attempt. Those three
previously side-channelled their own state through three different metadata
keys; the decision now travels on the hook contract itself, so the bucket is
resolved in one place, through get_or_create_metadata_bucket, which already
owns the question of which dict holds proxy-internal metadata and replaces a
non-dict value instead of skipping the write. Recording happens on every
attempt rather than only on a successful route: a fallback from an auto-router
group to a plain group re-enters the hook with the same request kwargs, and a
decision left behind there would attribute the first router's tier to the
deployment that actually served the retry. The log details drawer renders the
result as a Routing card between Request Details and Metrics; the card is
absent on rows that carry no decision, so ordinary and pre-upgrade rows are
unchanged.
Three defects surfaced while making the recorded cause truthful, each of which
would have persisted a wrong answer. The complexity router hardcoded
cause=complexity_scorer even when the LLM classifier decided, and its silent
fallback to the heuristic on classifier failure meant a row could claim an LLM
verdict the LLM never gave; the cause now reports the path that actually ran.
The keyword that triggered a tier rule was discarded before logging, as was
the escalation keyword. The 2-reasoning-marker override returned REASONING with
a score far below the REASONING boundary and no marker saying so, which reads
as a scoring bug to anyone comparing the two; it now emits a reasoning-override
signal, and the card labels those rows as an override instead of claiming the
score met a boundary. The LLM path no longer reports a synthetic score of 1.0,
and heuristic decisions carry a snapshot of the tier boundaries that mapped the
score, so a historical row stays interpretable after the boundaries change.
Signals name a matched term only when the caller's own message contains it.
Scoring still reads the system prompt, but a term matched solely there is
reported as a count, since signals reach a spend row the caller can read and
naming one would disclose a term from a prompt it cannot see.
routing_decision is stripped from caller-supplied metadata at ingress, so a
client cannot forge its own provenance.
---
litellm/litellm_core_utils/litellm_logging.py | 2 +
litellm/proxy/_types.py | 2 +
litellm/proxy/litellm_pre_call_utils.py | 2 +
litellm/router.py | 70 ++
.../adaptive_router/adaptive_router.py | 13 +-
.../complexity_router/complexity_router.py | 269 ++++++--
.../quality_router/quality_router.py | 27 +
litellm/types/router.py | 3 +-
litellm/types/utils.py | 69 ++
.../test_spend_management_endpoints.py | 6 +-
.../test_spend_tracking_utils.py | 14 +
.../proxy/test_litellm_pre_call_utils.py | 4 +
.../adaptive_router/test_async_pre_routing.py | 22 +
.../router_strategy/test_complexity_router.py | 640 +++++++++++++++++-
.../router_strategy/test_quality_router.py | 50 ++
.../LogDetailsDrawer/LogDetailContent.tsx | 4 +
.../RoutingDecisionCard.test.tsx | 136 ++++
.../LogDetailsDrawer/RoutingDecisionCard.tsx | 192 ++++++
18 files changed, 1445 insertions(+), 80 deletions(-)
create mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/RoutingDecisionCard.test.tsx
create mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/RoutingDecisionCard.tsx
diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py
index 83d6fcc0bee..c2dc7189934 100644
--- a/litellm/litellm_core_utils/litellm_logging.py
+++ b/litellm/litellm_core_utils/litellm_logging.py
@@ -4680,6 +4680,7 @@ class StandardLoggingPayloadSetup:
applied_guardrails=applied_guardrails,
mcp_tool_call_metadata=mcp_tool_call_metadata,
vector_store_request_metadata=vector_store_request_metadata,
+ routing_decision=None,
usage_object=usage_object,
requester_custom_headers=None,
cold_storage_object_key=None,
@@ -5519,6 +5520,7 @@ def get_standard_logging_metadata(
applied_guardrails=None,
mcp_tool_call_metadata=None,
vector_store_request_metadata=None,
+ routing_decision=None,
usage_object=None,
requester_custom_headers=None,
user_api_key_request_route=None,
diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py
index c6d4ee1120a..bfdc954c9fe 100644
--- a/litellm/proxy/_types.py
+++ b/litellm/proxy/_types.py
@@ -53,6 +53,7 @@ from litellm.types.utils import (
StandardLoggingModelInformation,
StandardLoggingPayloadErrorInformation,
StandardLoggingPayloadStatus,
+ StandardLoggingRoutingDecision,
StandardLoggingVectorStoreRequest,
StandardPassThroughResponseObject,
TextCompletionResponse,
@@ -3311,6 +3312,7 @@ class SpendLogsMetadata(TypedDict):
applied_guardrails: Optional[List[str]]
mcp_tool_call_metadata: Optional[StandardLoggingMCPToolCall]
vector_store_request_metadata: Optional[List[StandardLoggingVectorStoreRequest]]
+ routing_decision: StandardLoggingRoutingDecision | None
guardrail_information: Optional[List[StandardLoggingGuardrailInformation]]
eval_information: Optional[Any]
status: StandardLoggingPayloadStatus
diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py
index c40718e41fd..673e73f72fb 100644
--- a/litellm/proxy/litellm_pre_call_utils.py
+++ b/litellm/proxy/litellm_pre_call_utils.py
@@ -150,6 +150,7 @@ _UNTRUSTED_ROOT_CONTROL_FIELDS = (
"applied_guardrails",
"applied_policies",
"policy_sources",
+ "routing_decision",
"pillar_response_headers",
"_guardrail_pipelines",
"_pipeline_managed_guardrails",
@@ -197,6 +198,7 @@ _UNTRUSTED_METADATA_CONTROL_FIELDS = (
"applied_guardrails",
"applied_policies",
"policy_sources",
+ "routing_decision",
"standard_logging_object",
"proxy_server_request",
"secret_fields",
diff --git a/litellm/router.py b/litellm/router.py
index 0234a8f3424..ac00cdbc2b0 100644
--- a/litellm/router.py
+++ b/litellm/router.py
@@ -71,6 +71,7 @@ from litellm.litellm_core_utils.core_helpers import (
_get_parent_otel_span_from_kwargs,
coerce_token_limit,
get_metadata_variable_name_from_kwargs,
+ get_or_create_metadata_bucket,
)
from litellm.litellm_core_utils.coroutine_checker import coroutine_checker
from litellm.litellm_core_utils.credential_accessor import CredentialAccessor
@@ -210,8 +211,10 @@ from litellm.types.utils import (
from litellm.types.utils import ModelInfo
from litellm.types.utils import ModelInfo as ModelMapInfo
from litellm.types.utils import (
+ PROMPT_QUOTING_ROUTING_DECISION_FIELDS,
ModelResponseStream,
StandardLoggingPayload,
+ StandardLoggingRoutingDecision,
Usage,
)
from litellm.utils import (
@@ -11158,6 +11161,7 @@ class Router:
router_strategy = self._select_pre_routing_strategy(model=model, request_kwargs=request_kwargs)
if router_strategy is None:
+ self._record_routing_decision(request_kwargs=request_kwargs, routing_decision=None)
return None
pre_routing_hook_response = await router_strategy.async_pre_routing_hook(
@@ -11167,6 +11171,10 @@ class Router:
input=input,
specific_deployment=specific_deployment,
)
+ self._record_routing_decision(
+ request_kwargs=request_kwargs,
+ routing_decision=(pre_routing_hook_response.routing_decision if pre_routing_hook_response else None),
+ )
# `model` (the alias, e.g. "smart-router") is never the deployment actually
# called - apply the alias's own litellm_params (besides `model` itself,
@@ -11185,6 +11193,68 @@ class Router:
return pre_routing_hook_response
+ @staticmethod
+ def _record_routing_decision(
+ request_kwargs: dict,
+ routing_decision: StandardLoggingRoutingDecision | None,
+ ) -> None:
+ """Make the request's metadata describe THIS routing attempt, and only this one.
+
+ Fallbacks re-enter the hook with the same `request_kwargs`, so an attempt that
+ picks a plain model group after an auto-router group failed must clear the
+ earlier decision; leaving it would attribute the first router's tier and cause
+ to the deployment that actually served the request. Every attempt therefore
+ writes or clears, never just writes.
+ """
+ if routing_decision is None:
+ for bucket in (request_kwargs.get("metadata"), request_kwargs.get("litellm_metadata")):
+ if isinstance(bucket, dict):
+ bucket.pop("routing_decision", None)
+ return
+
+ # `get_or_create_metadata_bucket` is the single owner of "which dict holds
+ # proxy-internal metadata": it picks `litellm_metadata` when present (so the
+ # decision never lands in the `metadata` dict that routes like /v1/messages
+ # forward to the provider) and replaces a non-dict value rather than silently
+ # skipping the write.
+ _, metadata_bucket = get_or_create_metadata_bucket(request_kwargs)
+ metadata_bucket["routing_decision"] = Router._redact_prompt_text_if_needed(
+ request_kwargs=request_kwargs, routing_decision=routing_decision
+ )
+
+ @staticmethod
+ def _redact_prompt_text_if_needed(
+ request_kwargs: Mapping[str, Any],
+ routing_decision: StandardLoggingRoutingDecision,
+ ) -> StandardLoggingRoutingDecision:
+ """Drop verbatim prompt text from the record when message logging is redacted.
+
+ An operator who turns message logging off has said prompt content must not reach
+ the logs, so the fields that quote the prompt (the matched keywords, and the
+ signals that name them) are omitted. Derived values are kept, because a tier, a
+ cause, a score or an escalation flag aggregates the prompt rather than
+ reproducing any of it, and dropping them would leave the row unexplainable for
+ no privacy gain. Applied here rather than in each strategy so a strategy added
+ later cannot bypass it.
+ """
+ from litellm.litellm_core_utils.redact_messages import (
+ should_redact_message_logging,
+ )
+
+ if not should_redact_message_logging(
+ {
+ "litellm_params": request_kwargs,
+ "standard_callback_dynamic_params": request_kwargs.get("standard_callback_dynamic_params"),
+ }
+ ):
+ return routing_decision
+ kept = {
+ field: value
+ for field, value in routing_decision.items()
+ if field not in PROMPT_QUOTING_ROUTING_DECISION_FIELDS
+ }
+ return cast(StandardLoggingRoutingDecision, kept) # cast-ok: dropping optional keys preserves the type
+
def get_available_deployment(
self,
model: str,
diff --git a/litellm/router_strategy/adaptive_router/adaptive_router.py b/litellm/router_strategy/adaptive_router/adaptive_router.py
index ec84eb1decf..e8fcec2667d 100644
--- a/litellm/router_strategy/adaptive_router/adaptive_router.py
+++ b/litellm/router_strategy/adaptive_router/adaptive_router.py
@@ -23,6 +23,7 @@ from litellm._logging import verbose_router_logger
from litellm.litellm_core_utils.prompt_templates.common_utils import (
get_last_user_message,
)
+from litellm.types.utils import StandardLoggingRoutingDecision
from litellm.router_strategy.adaptive_router.bandit import (
BanditCell,
apply_delta,
@@ -193,7 +194,17 @@ class AdaptiveRouter:
if isinstance(kwargs_metadata, dict):
kwargs_metadata[ADAPTIVE_ROUTER_CHOSEN_MODEL_KEY] = chosen_model
- return PreRoutingHookResponse(model=chosen_model, messages=messages)
+ return PreRoutingHookResponse(
+ model=chosen_model,
+ messages=messages,
+ routing_decision=StandardLoggingRoutingDecision(
+ router_model_name=self.router_name,
+ router_type="adaptive",
+ routed_model=chosen_model,
+ cause="bandit",
+ request_type=request_type.value,
+ ),
+ )
# ---- Pick model ------------------------------------------------------
diff --git a/litellm/router_strategy/complexity_router/complexity_router.py b/litellm/router_strategy/complexity_router/complexity_router.py
index 1da8ee68c6e..933c6d170cf 100644
--- a/litellm/router_strategy/complexity_router/complexity_router.py
+++ b/litellm/router_strategy/complexity_router/complexity_router.py
@@ -19,7 +19,7 @@ import asyncio
import random
import re
from collections.abc import Mapping
-from typing import TYPE_CHECKING, Any, Literal, Union, cast
+from typing import TYPE_CHECKING, Any, Literal, NamedTuple, Union, cast
from pydantic import BaseModel
@@ -27,7 +27,12 @@ from litellm._logging import verbose_router_logger
from litellm.constants import RETURN_RAW_MODEL_NAME_METADATA_KEY
from litellm.integrations.custom_logger import CustomLogger
from litellm.llms.base_llm.base_utils import type_to_response_format_param
-from litellm.types.utils import ModelResponse
+from litellm.types.utils import (
+ ModelResponse,
+ RoutingDecisionCause,
+ StandardLoggingRoutingDecision,
+ StandardLoggingRoutingDecisionTierBoundaries,
+)
from .config import (
DEFAULT_CODE_KEYWORDS,
@@ -135,6 +140,27 @@ class DimensionScore:
self.signal = signal
+class KeywordOverride(NamedTuple):
+ """A keyword_tier_rules match: the winning tier and, on the lexical path, the keyword that fired."""
+
+ tier: ComplexityTier
+ matched_keyword: str | None
+
+
+class ClassificationOutcome(NamedTuple):
+ """What the classifier decided and which mechanism actually produced it.
+
+ `cause` reflects the path that ran, not the configured classifier_type: an LLM
+ classifier that fails falls back to the heuristic scorer and reports it.
+ `score` is None on the LLM path, which produces a tier label and no score.
+ """
+
+ tier: ComplexityTier
+ score: float | None
+ signals: tuple[str, ...]
+ cause: Literal["heuristic_scorer", "reasoning_override", "llm_classifier"]
+
+
class ComplexityRouter(CustomLogger):
"""
Complexity router that classifies requests and routes to appropriate models.
@@ -256,6 +282,7 @@ class ComplexityRouter(CustomLogger):
def _score_keyword_match(
self,
text: str,
+ disclosable_text: str,
keywords: list[str],
name: str,
signal_label: str,
@@ -264,6 +291,15 @@ class ComplexityRouter(CustomLogger):
) -> tuple[DimensionScore, int]:
"""Score based on keyword matches using word boundary matching.
+ Scoring reads `text`, which for most dimensions includes the system prompt.
+ The signal names only the terms that also appear in `disclosable_text`, the
+ caller's own message: signals are persisted to the request's spend log, which
+ the caller can read, so naming a term matched solely in the system prompt would
+ let a caller recover configured terms from a prompt it cannot see. Terms it did
+ not supply are reported as a count instead, which explains the score without
+ disclosing anything. `disclosable_text` is required rather than defaulted so a
+ future dimension has to state which text it is willing to quote.
+
Returns:
Tuple of (DimensionScore, match_count) so callers can reuse the count.
"""
@@ -272,18 +308,13 @@ class ComplexityRouter(CustomLogger):
matches = [kw for kw in keywords if self._keyword_matches(text, kw)]
match_count = len(matches)
+ if match_count < low_threshold:
+ return DimensionScore(name, score_none, None), match_count
- if match_count >= high_threshold:
- return (
- DimensionScore(name, score_high, f"{signal_label} ({', '.join(matches[:3])})"),
- match_count,
- )
- if match_count >= low_threshold:
- return (
- DimensionScore(name, score_low, f"{signal_label} ({', '.join(matches[:3])})"),
- match_count,
- )
- return DimensionScore(name, score_none, None), match_count
+ disclosable = [kw for kw in matches if self._keyword_matches(disclosable_text, kw)]
+ detail = ", ".join(disclosable[:3]) if disclosable else f"{match_count} matches"
+ score = score_high if match_count >= high_threshold else score_low
+ return DimensionScore(name, score, f"{signal_label} ({detail})"), match_count
def _score_multi_step(self, text: str) -> DimensionScore:
"""Score based on multi-step patterns."""
@@ -300,8 +331,19 @@ class ComplexityRouter(CustomLogger):
return DimensionScore("questionComplexity", 0, None)
def classify(self, prompt: str, system_prompt: str | None = None) -> tuple[ComplexityTier, float, list[str]]:
+ """Classify a prompt by complexity, discarding which rule decided the tier.
+
+ Kept for callers that only need the tier and score; `_score_and_classify` is the
+ single computation behind both, so the two can never disagree.
"""
- Classify a prompt by complexity.
+ tier, score, signals, _cause = self._score_and_classify(prompt, system_prompt)
+ return tier, score, list(signals)
+
+ def _score_and_classify(
+ self, prompt: str, system_prompt: str | None = None
+ ) -> tuple[ComplexityTier, float, tuple[str, ...], Literal["heuristic_scorer", "reasoning_override"]]:
+ """
+ Classify a prompt by complexity, reporting whether the score chose the tier.
Args:
prompt: The user's prompt/message.
@@ -327,6 +369,7 @@ class ComplexityRouter(CustomLogger):
# Score all dimensions, capturing match counts where needed
code_score, _ = self._score_keyword_match(
full_text,
+ user_text,
self.code_keywords,
"codePresence",
"code",
@@ -334,6 +377,7 @@ class ComplexityRouter(CustomLogger):
(0, 0.5, 1.0),
)
reasoning_score, reasoning_match_count = self._score_keyword_match(
+ user_text,
user_text,
self.reasoning_keywords,
"reasoningMarkers",
@@ -343,6 +387,7 @@ class ComplexityRouter(CustomLogger):
)
technical_score, _ = self._score_keyword_match(
full_text,
+ user_text,
self.technical_keywords,
"technicalTerms",
"technical",
@@ -351,6 +396,7 @@ class ComplexityRouter(CustomLogger):
)
simple_score, _ = self._score_keyword_match(
full_text,
+ user_text,
self.simple_keywords,
"simpleIndicators",
"simple",
@@ -378,48 +424,112 @@ class ComplexityRouter(CustomLogger):
# Check for reasoning override (2+ reasoning markers)
# Reuse match count from _score_keyword_match to avoid scanning twice
if reasoning_match_count >= 2:
- return ComplexityTier.REASONING, weighted_score, signals
+ return ComplexityTier.REASONING, weighted_score, tuple(signals), "reasoning_override"
# Map score to tier
- boundaries = self.config.tier_boundaries
- simple_medium = boundaries.get("simple_medium", 0.15)
- medium_complex = boundaries.get("medium_complex", 0.35)
- complex_reasoning = boundaries.get("complex_reasoning", 0.60)
-
- if weighted_score < simple_medium:
+ boundaries = self._effective_tier_boundaries()
+ if weighted_score < boundaries["simple_medium"]:
tier = ComplexityTier.SIMPLE
- elif weighted_score < medium_complex:
+ elif weighted_score < boundaries["medium_complex"]:
tier = ComplexityTier.MEDIUM
- elif weighted_score < complex_reasoning:
+ elif weighted_score < boundaries["complex_reasoning"]:
tier = ComplexityTier.COMPLEX
else:
tier = ComplexityTier.REASONING
- return tier, weighted_score, signals
+ return tier, weighted_score, tuple(signals), "heuristic_scorer"
+
+ def _effective_tier_boundaries(self) -> StandardLoggingRoutingDecisionTierBoundaries:
+ """The tier boundaries in effect, with the documented defaults filled in.
+
+ Shared by score-to-tier mapping and the per-request routing decision snapshot,
+ so a logged decision always reflects the boundaries that actually applied.
+ """
+ boundaries = self.config.tier_boundaries
+ return StandardLoggingRoutingDecisionTierBoundaries(
+ simple_medium=boundaries.get("simple_medium", 0.15),
+ medium_complex=boundaries.get("medium_complex", 0.35),
+ complex_reasoning=boundaries.get("complex_reasoning", 0.60),
+ )
+
+ def _build_routing_decision(
+ self,
+ *,
+ routed_model: str,
+ cause: RoutingDecisionCause,
+ tier: ComplexityTier | None = None,
+ score: float | None = None,
+ signals: tuple[str, ...] | None = None,
+ matched_keyword: str | None = None,
+ escalation_keyword: str | None = None,
+ escalated: bool = False,
+ classifier_model: str | None = None,
+ ) -> StandardLoggingRoutingDecision:
+ """Assemble the per-request provenance record for this router's decision.
+
+ Optional facts are omitted rather than set to None, so a spend log row only
+ carries the keys that applied to its path. `tier_boundaries` rides with
+ `score` because the score is only interpretable against the boundaries that
+ mapped it to a tier.
+ """
+ decision = StandardLoggingRoutingDecision(
+ router_model_name=self.model_name,
+ router_type="complexity",
+ routed_model=routed_model,
+ cause=cause,
+ )
+ if tier is not None:
+ decision["tier"] = tier.value
+ if score is not None:
+ decision["score"] = score
+ decision["tier_boundaries"] = self._effective_tier_boundaries()
+ if signals:
+ # Stored as a list because this record is serialized to JSON for the spend
+ # log and read back as an array by the dashboard; a sequence type that only
+ # happens to survive the serializer would make the wire shape depend on it.
+ decision["signals"] = list(signals)
+ if matched_keyword is not None:
+ decision["matched_keyword"] = matched_keyword
+ if escalation_keyword is not None:
+ # Two separate facts: the caller asked to escalate, and whether the tier
+ # actually moved. A request that escalates from an already-highest tier has
+ # nowhere to go, so it records the keyword with escalated=False rather than
+ # dropping the ask (which reads as an ordinary route) or claiming a bump
+ # that never happened. Every path reports both the same way.
+ decision["escalation_keyword"] = escalation_keyword
+ decision["escalated"] = escalated
+ if classifier_model is not None:
+ decision["classifier_model"] = classifier_model
+ return decision
async def aclassify(
self,
prompt: str,
system_prompt: str | None = None,
request_kwargs: dict[str, Any] | None = None,
- ) -> tuple[ComplexityTier, float, list[str]]:
+ ) -> ClassificationOutcome:
"""
Classify a prompt by complexity, using the LLM classifier when configured.
Falls back to the local heuristic scorer if classifier_type is "heuristic",
or if the LLM call fails, times out, or returns an unparseable response.
+ The outcome's `cause` reports which path actually classified the request.
"""
if self.config.classifier_type != "llm" or self.config.classifier_llm_config is None:
- return self.classify(prompt, system_prompt)
+ tier, score, signals, cause = self._score_and_classify(prompt, system_prompt)
+ return ClassificationOutcome(tier=tier, score=score, signals=signals, cause=cause)
try:
tier = await self._classify_with_llm(prompt, system_prompt, request_kwargs)
- return tier, 1.0, [f"llm-classifier:{tier.value}"]
+ return ClassificationOutcome(
+ tier=tier, score=None, signals=(f"llm-classifier:{tier.value}",), cause="llm_classifier"
+ )
except Exception as e: # noqa: BLE001 -- external LLM call can fail in many distinct ways (timeout, provider error, validation, parse error); any failure must fall back to the heuristic scorer
verbose_router_logger.warning(
f"ComplexityRouter: LLM classifier failed ({e}), falling back to heuristic scoring"
)
- return self.classify(prompt, system_prompt)
+ tier, score, signals, cause = self._score_and_classify(prompt, system_prompt)
+ return ClassificationOutcome(tier=tier, score=score, signals=signals, cause=cause)
async def _classify_with_llm(
self,
@@ -699,16 +809,16 @@ class ComplexityRouter(CustomLogger):
}
return best_model
- def _escalation_triggered(self, user_message: str) -> bool:
- """Whether the prompt asks to escalate to a stronger model.
+ def _matched_escalation_keyword(self, user_message: str) -> str | None:
+ """The escalation keyword the prompt contains, or None when escalation is off.
Matching is a case-sensitive substring test so the default "LITELLM ESCALATE"
only fires on the deliberate, shouted form and not on incidental lowercase
mentions of the word (e.g. "how do I escalate this ticket").
"""
if not self.escalation_keywords:
- return False
- return any(keyword in user_message for keyword in self.escalation_keywords)
+ return None
+ return next((keyword for keyword in self.escalation_keywords if keyword in user_message), None)
def _tier_for_model(self, model: str) -> ComplexityTier | None:
"""Return the most-severe configured tier whose pool contains this model."""
@@ -746,7 +856,7 @@ class ComplexityRouter(CustomLogger):
return pinned_model
return self.get_model_for_tier(escalated_tier)
- def _lexical_tier_override(self, user_message: str) -> ComplexityTier | None:
+ def _lexical_tier_override(self, user_message: str) -> KeywordOverride | None:
"""When keyword_tier_rules match literally, the most-severe matched tier wins.
Escalating to the highest tier (rather than the first rule in the list) keeps
@@ -757,12 +867,15 @@ class ComplexityRouter(CustomLogger):
if not rules:
return None
text = user_message.lower()
- matched_tiers = [
- rule.tier for rule in rules if any(self._keyword_matches(text, keyword) for keyword in rule.keywords)
+ matches = [
+ KeywordOverride(tier=rule.tier, matched_keyword=matched_keyword)
+ for rule in rules
+ if (matched_keyword := next((kw for kw in rule.keywords if self._keyword_matches(text, kw)), None))
+ is not None
]
- if not matched_tiers:
+ if not matches:
return None
- return max(matched_tiers, key=TIER_SEVERITY_ORDER.index)
+ return max(matches, key=lambda match: TIER_SEVERITY_ORDER.index(match.tier))
def _get_or_create_semantic_routelayer(self) -> SemanticRouter:
"""Build (once) a SemanticRouter with one route per tier, utterances = that tier's keywords."""
@@ -867,7 +980,7 @@ class ComplexityRouter(CustomLogger):
except ValueError:
return None
- async def _resolve_keyword_tier_override(self, user_message: str, request_kwargs: dict) -> ComplexityTier | None:
+ async def _resolve_keyword_tier_override(self, user_message: str, request_kwargs: dict) -> KeywordOverride | None:
"""Resolve a keyword_tier_rule override, semantically or lexically per config.
Returns None (no override -> fall through to the scorer) not only when no rule
@@ -879,12 +992,17 @@ class ComplexityRouter(CustomLogger):
if not self.config.semantic_keyword_matching:
return self._lexical_tier_override(user_message)
try:
- return await self._semantic_tier_override(user_message, request_kwargs)
+ semantic_tier = await self._semantic_tier_override(user_message, request_kwargs)
except Exception as e: # noqa: BLE001 -- embedding call can fail many ways (timeout, provider/network/parse error); any failure must fall back to scoring, never fail the request
verbose_router_logger.warning(
f"ComplexityRouter: semantic keyword matching failed ({e}), falling back to complexity scoring"
)
return None
+ if semantic_tier is None:
+ return None
+ # A semantic match is a similarity hit against the rule's utterances, not a
+ # literal keyword, so there is no single matched keyword to report.
+ return KeywordOverride(tier=semantic_tier, matched_keyword=None)
def _resolve_messages(
self,
@@ -1003,6 +1121,7 @@ class ComplexityRouter(CustomLogger):
pinned_model = await self.litellm_router_instance.cache.async_get_cache(key=cache_key)
if isinstance(pinned_model, str):
routed_model: str | None = pinned_model
+ pin_escalation_keyword: str | None = None
if self.escalation_keywords:
resolved_messages = self._resolve_messages(messages, request_kwargs)
user_message = (
@@ -1010,7 +1129,9 @@ class ComplexityRouter(CustomLogger):
if resolved_messages
else None
)
- if user_message is not None and self._escalation_triggered(user_message):
+ if user_message is not None:
+ pin_escalation_keyword = self._matched_escalation_keyword(user_message)
+ if pin_escalation_keyword is not None:
routed_model = self._escalated_pin(pinned_model)
if routed_model is not None:
# Refresh the TTL on every hit so an active session doesn't lose its
@@ -1028,7 +1149,8 @@ class ComplexityRouter(CustomLogger):
kwargs_metadata = request_kwargs.setdefault("metadata", {})
if isinstance(kwargs_metadata, dict):
kwargs_metadata[ADAPTIVE_ROUTER_CHOSEN_MODEL_KEY] = routed_model
- cause = "session_affinity_escalation" if routed_model != pinned_model else "session_affinity_pin"
+ escalated = routed_model != pinned_model
+ cause: RoutingDecisionCause = "session_affinity_escalation" if escalated else "session_affinity_pin"
verbose_router_logger.info(
f"ComplexityRouter: routing decision cause={cause}, routed_model={routed_model}"
)
@@ -1036,6 +1158,12 @@ class ComplexityRouter(CustomLogger):
return PreRoutingHookResponse(
model=routed_model,
messages=messages if has_original_messages else None,
+ routing_decision=self._build_routing_decision(
+ routed_model=routed_model,
+ cause=cause,
+ escalation_keyword=pin_escalation_keyword,
+ escalated=escalated,
+ ),
)
response = await self._classify_and_route(
@@ -1106,29 +1234,45 @@ class ComplexityRouter(CustomLogger):
return PreRoutingHookResponse(
model=routed_model,
messages=messages if has_original_messages else None,
+ routing_decision=self._build_routing_decision(routed_model=routed_model, cause="default_fallback"),
)
- escalate = self._escalation_triggered(user_message)
+ escalation_keyword = self._matched_escalation_keyword(user_message)
- override_tier = await self._resolve_keyword_tier_override(user_message, request_kwargs)
- if override_tier is not None:
- routed_tier = self._escalate_tier(override_tier) if escalate else override_tier
+ override = await self._resolve_keyword_tier_override(user_message, request_kwargs)
+ if override is not None:
+ routed_tier = self._escalate_tier(override.tier) if escalation_keyword is not None else override.tier
+ keyword_escalated = routed_tier != override.tier
routed_model = await self._pick_model_for_tier(routed_tier, messages, resolved_messages, request_kwargs)
- base_cause = "semantic_keyword_match" if self.config.semantic_keyword_matching else "literal_keyword_match"
- cause = f"{base_cause}+escalation" if escalate else base_cause
+ keyword_cause: RoutingDecisionCause = (
+ "semantic_keyword_match" if self.config.semantic_keyword_matching else "literal_keyword_match"
+ )
verbose_router_logger.info(
- f"ComplexityRouter: routing decision cause={cause}, "
+ f"ComplexityRouter: routing decision cause={keyword_cause}, escalated={keyword_escalated}, "
f"tier={routed_tier.value}, routed_model={routed_model}"
)
return PreRoutingHookResponse(
model=routed_model,
messages=messages if has_original_messages else None,
+ routing_decision=self._build_routing_decision(
+ routed_model=routed_model,
+ cause=keyword_cause,
+ tier=routed_tier,
+ matched_keyword=override.matched_keyword,
+ escalation_keyword=escalation_keyword,
+ escalated=keyword_escalated,
+ ),
)
- tier, score, signals = await self.aclassify(user_message, system_prompt, request_kwargs)
- if escalate:
+ outcome = await self.aclassify(user_message, system_prompt, request_kwargs)
+ tier, score, signals = outcome.tier, outcome.score, outcome.signals
+ classified_tier = tier
+ if escalation_keyword is not None:
tier = self._escalate_tier(tier)
- signals = [*signals, "escalation"]
+ escalated = tier != classified_tier
+ if escalated:
+ signals = (*signals, "escalation")
+ score_repr = f"{score:.3f}" if score is not None else "n/a"
if self.config.adaptive:
routed_model = self._soft_floor_pick(tier, user_message, request_kwargs)
adaptive = self._ensure_adaptive_router()
@@ -1138,18 +1282,33 @@ class ComplexityRouter(CustomLogger):
chosen_key = getattr(self, "_adaptive_chosen_model_key", "adaptive_router_chosen_model")
kwargs_metadata[chosen_key] = routed_model
verbose_router_logger.info(
- f"ComplexityRouter[adaptive]: routing decision cause=complexity_scorer, "
- f"tier={tier.value}, score={score:.3f}, "
+ f"ComplexityRouter[adaptive]: routing decision cause={outcome.cause}, "
+ f"tier={tier.value}, score={score_repr}, "
f"signals={signals}, routed_model={routed_model}"
)
else:
routed_model = await self._pick_model_for_tier(tier, messages, resolved_messages, request_kwargs)
verbose_router_logger.info(
- f"ComplexityRouter: routing decision cause=complexity_scorer, tier={tier.value}, "
- f"score={score:.3f}, signals={signals}, routed_model={routed_model}"
+ f"ComplexityRouter: routing decision cause={outcome.cause}, tier={tier.value}, "
+ f"score={score_repr}, signals={signals}, routed_model={routed_model}"
)
+ classifier_model = (
+ self.config.classifier_llm_config.model
+ if outcome.cause == "llm_classifier" and self.config.classifier_llm_config is not None
+ else None
+ )
return PreRoutingHookResponse(
model=routed_model,
messages=messages if has_original_messages else None,
+ routing_decision=self._build_routing_decision(
+ routed_model=routed_model,
+ cause=outcome.cause,
+ tier=tier,
+ score=score,
+ signals=signals,
+ escalation_keyword=escalation_keyword,
+ escalated=escalated,
+ classifier_model=classifier_model,
+ ),
)
diff --git a/litellm/router_strategy/quality_router/quality_router.py b/litellm/router_strategy/quality_router/quality_router.py
index 15c26f2c278..fd4a91d76bd 100644
--- a/litellm/router_strategy/quality_router/quality_router.py
+++ b/litellm/router_strategy/quality_router/quality_router.py
@@ -23,6 +23,7 @@ from litellm.integrations.custom_logger import CustomLogger
from litellm.router_strategy.complexity_router.complexity_router import (
ComplexityRouter,
)
+from litellm.types.utils import StandardLoggingRoutingDecision
from .config import QualityRouterConfig, RoutingPreferences
@@ -357,6 +358,12 @@ class QualityRouter(CustomLogger):
return PreRoutingHookResponse(
model=self.config.default_model,
messages=messages,
+ routing_decision=StandardLoggingRoutingDecision(
+ router_model_name=self.model_name,
+ router_type="quality",
+ routed_model=self.config.default_model,
+ cause="default_fallback",
+ ),
)
# Try keyword override first — it short-circuits complexity classification.
@@ -380,9 +387,20 @@ class QualityRouter(CustomLogger):
"complexity_tier": None,
},
)
+ routing_decision = StandardLoggingRoutingDecision(
+ router_model_name=self.model_name,
+ router_type="quality",
+ routed_model=routed_model,
+ cause="keyword",
+ matched_keyword=matched_keyword,
+ )
+ keyword_quality_tier = self._model_quality.get(routed_model)
+ if keyword_quality_tier is not None:
+ routing_decision["tier"] = str(keyword_quality_tier)
return PreRoutingHookResponse(
model=routed_model,
messages=messages,
+ routing_decision=routing_decision,
)
# No keyword match → complexity classification flow.
@@ -419,4 +437,13 @@ class QualityRouter(CustomLogger):
return PreRoutingHookResponse(
model=routed_model,
messages=messages,
+ routing_decision=StandardLoggingRoutingDecision(
+ router_model_name=self.model_name,
+ router_type="quality",
+ routed_model=routed_model,
+ cause="quality_tier",
+ tier=str(int(quality_tier)),
+ score=score,
+ signals=list(signals),
+ ),
)
diff --git a/litellm/types/router.py b/litellm/types/router.py
index 28e4a8272e8..837a93367a2 100644
--- a/litellm/types/router.py
+++ b/litellm/types/router.py
@@ -28,7 +28,7 @@ from .completion import CompletionRequest
from .embedding import EmbeddingRequest
from .llms.openai import OpenAIFileObject
from .search import SearchProvider
-from .utils import CustomPricingLiteLLMParams, ModelResponse
+from .utils import CustomPricingLiteLLMParams, ModelResponse, StandardLoggingRoutingDecision
class ConfigurableClientsideParamsCustomAuth(TypedDict):
@@ -839,6 +839,7 @@ class PreRoutingHookResponse(BaseModel):
model: str
messages: Optional[List[Dict[str, Any]]]
+ routing_decision: StandardLoggingRoutingDecision | None = None
_PreRoutingStrategyT_co = TypeVar("_PreRoutingStrategyT_co", covariant=True)
diff --git a/litellm/types/utils.py b/litellm/types/utils.py
index e4dfac48141..9df44c6202c 100644
--- a/litellm/types/utils.py
+++ b/litellm/types/utils.py
@@ -10,6 +10,7 @@ from typing import (
Literal,
Mapping,
Optional,
+ Sequence,
Union,
get_args,
)
@@ -2674,6 +2675,73 @@ class StandardLoggingPromptManagementMetadata(TypedDict):
prompt_integration: str
+class StandardLoggingRoutingDecisionTierBoundaries(TypedDict):
+ """Snapshot of the complexity scorer's tier boundaries at decision time, so a
+ historical spend log row stays explainable after the router config changes."""
+
+ simple_medium: float
+ medium_complex: float
+ complex_reasoning: float
+
+
+RoutingDecisionCause = Literal[
+ "heuristic_scorer",
+ # The scorer found 2+ reasoning markers and forced REASONING regardless of score.
+ # A distinct cause rather than a marker inside `signals`, because it is the fact
+ # that tells a reader the score did NOT choose the tier; encoding it as free text
+ # meant anything that filtered `signals` silently changed what the row claimed.
+ "reasoning_override",
+ "llm_classifier",
+ "literal_keyword_match",
+ "semantic_keyword_match",
+ "session_affinity_pin",
+ "session_affinity_escalation",
+ "default_fallback",
+ "keyword",
+ "quality_tier",
+ "bandit",
+]
+
+
+class StandardLoggingRoutingDecision(TypedDict, total=False):
+ """Per-request provenance for a pre-routing strategy (auto-router) decision."""
+
+ router_model_name: str
+ router_type: Literal["complexity", "adaptive", "quality"]
+ routed_model: str
+ cause: RoutingDecisionCause
+ tier: str
+ request_type: str
+ score: float
+ signals: Sequence[str]
+ matched_keyword: str
+ escalation_keyword: str
+ classifier_model: str
+ escalated: bool
+ tier_boundaries: StandardLoggingRoutingDecisionTierBoundaries
+
+
+# Fields whose values quote the caller's prompt. Dropped when an operator turns message
+# logging off. Every other field aggregates the prompt without reproducing it and is kept,
+# so a redacted row stays explainable. `test_every_routing_decision_field_is_classified`
+# fails if a field is added to the record without being placed in one set or the other.
+PROMPT_QUOTING_ROUTING_DECISION_FIELDS: FrozenSet[str] = frozenset({"signals", "matched_keyword", "escalation_keyword"})
+DERIVED_ROUTING_DECISION_FIELDS: FrozenSet[str] = frozenset(
+ {
+ "router_model_name",
+ "router_type",
+ "routed_model",
+ "cause",
+ "tier",
+ "request_type",
+ "score",
+ "classifier_model",
+ "escalated",
+ "tier_boundaries",
+ }
+)
+
+
class StandardLoggingMetadata(StandardLoggingUserAPIKeyMetadata):
"""
Specific metadata k,v pairs logged to integration for easier cost tracking and prompt management
@@ -2687,6 +2755,7 @@ class StandardLoggingMetadata(StandardLoggingUserAPIKeyMetadata):
prompt_management_metadata: Optional[StandardLoggingPromptManagementMetadata]
mcp_tool_call_metadata: Optional[StandardLoggingMCPToolCall]
vector_store_request_metadata: Optional[List[StandardLoggingVectorStoreRequest]]
+ routing_decision: StandardLoggingRoutingDecision | None
applied_guardrails: Optional[List[str]]
usage_object: Optional[dict]
cold_storage_object_key: Optional[str] # S3/GCS object key for cold storage retrieval
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 71206687b5c..795a99ec266 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
@@ -2396,7 +2396,7 @@ class TestSpendLogsPayload:
"model": "gpt-4o",
"user": "",
"team_id": "",
- "metadata": '{"applied_guardrails": [], "batch_models": null, "mcp_tool_call_metadata": null, "vector_store_request_metadata": null, "guardrail_information": null, "compression_savings": 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": [], "batch_models": null, "mcp_tool_call_metadata": null, "vector_store_request_metadata": null, "routing_decision": null, "guardrail_information": null, "compression_savings": 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,
@@ -2492,7 +2492,7 @@ class TestSpendLogsPayload:
"model": "claude-4-sonnet-20250514",
"user": "",
"team_id": "",
- "metadata": '{"applied_guardrails": [], "batch_models": null, "mcp_tool_call_metadata": null, "vector_store_request_metadata": null, "guardrail_information": null, "compression_savings": 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": [], "batch_models": null, "mcp_tool_call_metadata": null, "vector_store_request_metadata": null, "routing_decision": null, "guardrail_information": null, "compression_savings": 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,
@@ -2586,7 +2586,7 @@ class TestSpendLogsPayload:
"model": "claude-4-sonnet-20250514",
"user": "",
"team_id": "",
- "metadata": '{"applied_guardrails": [], "batch_models": null, "mcp_tool_call_metadata": null, "vector_store_request_metadata": null, "guardrail_information": null, "compression_savings": 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": [], "batch_models": null, "mcp_tool_call_metadata": null, "vector_store_request_metadata": null, "routing_decision": null, "guardrail_information": null, "compression_savings": 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 cc1e2943c8f..c6f2a6f1792 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
@@ -2902,3 +2902,17 @@ async def test_compression_savings_survive_to_spend_log_payload_metadata(monkeyp
"tokens_saved": 7000,
"source": "compression_interception",
}
+
+
+def test_no_routing_decision_key_defaults_to_none_in_spend_log_metadata():
+ payload = get_logging_payload(
+ kwargs={
+ "model": "gpt-4o-mini",
+ "litellm_params": {"metadata": {"user_api_key": "test-key"}},
+ },
+ response_obj=litellm.ModelResponse(id="chatcmpl-no-routing-decision", 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["routing_decision"] is None
diff --git a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py
index d6c5e9b1b81..e8acd7e6b75 100644
--- a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py
+++ b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py
@@ -671,6 +671,7 @@ async def test_add_litellm_data_to_request_strips_user_control_fields():
"applied_guardrails": ["spoofed"],
"applied_policies": ["spoofed-policy"],
"policy_sources": {"spoofed-policy": "request"},
+ "routing_decision": {"cause": "forged", "routed_model": "spoofed"},
"_guardrail_pipelines": [{"name": "spoofed"}],
"_pipeline_managed_guardrails": ["evaded"],
"safe_user_metadata": "kept",
@@ -681,6 +682,7 @@ async def test_add_litellm_data_to_request_strips_user_control_fields():
"mock_response": "free response",
"mock_tool_calls": [{"id": "call_1"}],
"disable_global_guardrails": True,
+ "routing_decision": {"cause": "forged", "routed_model": "spoofed"},
"metadata": copy.deepcopy(malicious_metadata),
"litellm_metadata": copy.deepcopy(malicious_metadata),
}
@@ -697,6 +699,7 @@ async def test_add_litellm_data_to_request_strips_user_control_fields():
assert "mock_response" not in updated
assert "mock_tool_calls" not in updated
assert "disable_global_guardrails" not in updated
+ assert "routing_decision" not in updated
stripped_keys = {
"disable_global_guardrails",
@@ -710,6 +713,7 @@ async def test_add_litellm_data_to_request_strips_user_control_fields():
"applied_guardrails",
"applied_policies",
"policy_sources",
+ "routing_decision",
"_guardrail_pipelines",
"_pipeline_managed_guardrails",
}
diff --git a/tests/test_litellm/router_strategy/adaptive_router/test_async_pre_routing.py b/tests/test_litellm/router_strategy/adaptive_router/test_async_pre_routing.py
index fb43cf403d6..0a8c230e773 100644
--- a/tests/test_litellm/router_strategy/adaptive_router/test_async_pre_routing.py
+++ b/tests/test_litellm/router_strategy/adaptive_router/test_async_pre_routing.py
@@ -240,3 +240,25 @@ async def test_invalid_min_quality_tier_header_treated_as_none():
assert (
r.pick_model.await_args.kwargs["min_quality_tier"] is None # type: ignore[union-attr]
)
+
+
+@pytest.mark.asyncio
+async def test_routing_decision_reports_bandit_choice():
+ r = _make_router()
+ r.pick_model = AsyncMock(return_value="smart") # type: ignore[method-assign]
+
+ response = await r.async_pre_routing_hook(
+ model="smart-cheap-router",
+ request_kwargs={},
+ messages=[{"role": "user", "content": "Write a Python function"}],
+ )
+
+ assert response is not None
+ decision = response.routing_decision
+ assert decision is not None
+ assert decision["router_model_name"] == "smart-cheap-router"
+ assert decision["router_type"] == "adaptive"
+ assert decision["cause"] == "bandit"
+ assert decision["routed_model"] == "smart"
+ assert decision["request_type"] == RequestType.CODE_GENERATION.value
+ assert "tier" not in decision
diff --git a/tests/test_litellm/router_strategy/test_complexity_router.py b/tests/test_litellm/router_strategy/test_complexity_router.py
index f31ca32f4c5..e734d8ec876 100644
--- a/tests/test_litellm/router_strategy/test_complexity_router.py
+++ b/tests/test_litellm/router_strategy/test_complexity_router.py
@@ -24,6 +24,7 @@ from litellm.constants import RETURN_RAW_MODEL_NAME_METADATA_KEY
from litellm.router_strategy.complexity_router.complexity_router import (
ComplexityRouter,
DimensionScore,
+ KeywordOverride,
)
from litellm.router_strategy.complexity_router.config import (
DEFAULT_COMPLEXITY_CONFIG,
@@ -1381,21 +1382,27 @@ class TestLLMClassifier:
async def test_aclassify_heuristic_skips_llm_call(self, complexity_router, mock_router_instance):
"""When classifier_type is 'heuristic' (default), aclassify must not call the LLM."""
mock_router_instance.acompletion = AsyncMock()
- tier, score, signals = await complexity_router.aclassify("Hello!")
+ outcome = await complexity_router.aclassify("Hello!")
mock_router_instance.acompletion.assert_not_called()
- assert tier == ComplexityTier.SIMPLE
+ assert outcome.tier == ComplexityTier.SIMPLE
+ assert outcome.cause == "heuristic_scorer"
+ assert outcome.score is not None
@pytest.mark.asyncio
async def test_aclassify_llm_success_routes_by_llm_verdict(self, llm_complexity_router, mock_router_instance):
"""A well-formed structured LLM response should decide the tier directly.
Uses a prompt that heuristic scoring alone would classify as SIMPLE, to prove
- the LLM verdict -- not the heuristic scorer -- is what decided the tier.
+ the LLM verdict -- not the heuristic scorer -- is what decided the tier. The
+ outcome must say so (cause) and must not fabricate a score: the LLM path
+ produces a tier label only.
"""
mock_router_instance.acompletion = AsyncMock(return_value=_llm_response('{"tier": "COMPLEX"}'))
- tier, score, signals = await llm_complexity_router.aclassify("hi")
- assert tier == ComplexityTier.COMPLEX
- assert "llm-classifier:COMPLEX" in signals
+ outcome = await llm_complexity_router.aclassify("hi")
+ assert outcome.tier == ComplexityTier.COMPLEX
+ assert outcome.cause == "llm_classifier"
+ assert outcome.score is None
+ assert "llm-classifier:COMPLEX" in outcome.signals
mock_router_instance.acompletion.assert_awaited_once()
call_kwargs = mock_router_instance.acompletion.call_args.kwargs
assert call_kwargs["model"] == "haiku-classifier"
@@ -1556,9 +1563,13 @@ class TestLLMClassifier:
):
"""A timeout/error from the classifier model must fall back to heuristic scoring."""
mock_router_instance.acompletion = AsyncMock(side_effect=TimeoutError("classifier timed out"))
- tier, score, signals = await llm_complexity_router.aclassify("Hello!")
- assert tier == llm_complexity_router.classify("Hello!")[0]
- assert tier == ComplexityTier.SIMPLE
+ outcome = await llm_complexity_router.aclassify("Hello!")
+ assert outcome.tier == llm_complexity_router.classify("Hello!")[0]
+ assert outcome.tier == ComplexityTier.SIMPLE
+ # The fallback ran the heuristic, and the outcome must say so even though
+ # the configured classifier_type is "llm".
+ assert outcome.cause == "heuristic_scorer"
+ assert outcome.score is not None
@pytest.mark.asyncio
async def test_aclassify_falls_back_to_heuristic_on_unparseable_response(
@@ -1566,8 +1577,9 @@ class TestLLMClassifier:
):
"""Non-JSON or schema-violating output must fall back to heuristic scoring, not raise."""
mock_router_instance.acompletion = AsyncMock(return_value=_llm_response("not json"))
- tier, score, signals = await llm_complexity_router.aclassify("Hello!")
- assert tier == ComplexityTier.SIMPLE
+ outcome = await llm_complexity_router.aclassify("Hello!")
+ assert outcome.tier == ComplexityTier.SIMPLE
+ assert outcome.cause == "heuristic_scorer"
@pytest.mark.asyncio
async def test_aclassify_falls_back_to_heuristic_on_empty_content(
@@ -1575,8 +1587,9 @@ class TestLLMClassifier:
):
"""Empty/None message content (e.g. provider quirk) must fall back, not raise."""
mock_router_instance.acompletion = AsyncMock(return_value=_llm_response(None))
- tier, score, signals = await llm_complexity_router.aclassify("Hello!")
- assert tier == ComplexityTier.SIMPLE
+ outcome = await llm_complexity_router.aclassify("Hello!")
+ assert outcome.tier == ComplexityTier.SIMPLE
+ assert outcome.cause == "heuristic_scorer"
@pytest.mark.asyncio
async def test_pre_routing_hook_uses_llm_classifier_end_to_end(self, llm_complexity_router, mock_router_instance):
@@ -2071,8 +2084,12 @@ class TestLexicalKeywordTierRules:
litellm_router_instance=mock_router_instance,
complexity_router_config=config,
)
- assert router._lexical_tier_override("hi there, please advise") == ComplexityTier.COMPLEX
- assert router._lexical_tier_override("just saying hi") == ComplexityTier.SIMPLE
+ assert router._lexical_tier_override("hi there, please advise") == KeywordOverride(
+ tier=ComplexityTier.COMPLEX, matched_keyword="advise"
+ )
+ assert router._lexical_tier_override("just saying hi") == KeywordOverride(
+ tier=ComplexityTier.SIMPLE, matched_keyword="hi"
+ )
assert router._lexical_tier_override("nothing relevant here") is None
@pytest.mark.asyncio
@@ -2108,7 +2125,9 @@ class TestLexicalKeywordTierRules:
litellm_router_instance=mock_router_instance,
complexity_router_config=config,
)
- assert router._lexical_tier_override("running my k8s cluster") == ComplexityTier.REASONING
+ assert router._lexical_tier_override("running my k8s cluster") == KeywordOverride(
+ tier=ComplexityTier.REASONING, matched_keyword="k8s"
+ )
assert router._lexical_tier_override("what is a k8scluster thing") is None
@@ -2812,7 +2831,7 @@ class TestRoutingDecisionCauseLogging:
request_kwargs={},
messages=[{"role": "user", "content": "What is the boiling point of water at sea level?"}],
)
- assert "routing decision cause=complexity_scorer" in router_log_capture.text
+ assert "routing decision cause=heuristic_scorer" in router_log_capture.text
assert "score=" in router_log_capture.text
assert "cause=literal_keyword_match" not in router_log_capture.text
assert "cause=semantic_keyword_match" not in router_log_capture.text
@@ -3316,9 +3335,9 @@ class TestEscalationKeywords:
assert complexity_router.escalation_keywords == ["LITELLM ESCALATE"]
def test_escalation_triggered_is_case_sensitive(self, complexity_router):
- assert complexity_router._escalation_triggered("please LITELLM ESCALATE now") is True
- assert complexity_router._escalation_triggered("please litellm escalate now") is False
- assert complexity_router._escalation_triggered("how do I escalate this ticket") is False
+ assert complexity_router._matched_escalation_keyword("please LITELLM ESCALATE now") == "LITELLM ESCALATE"
+ assert complexity_router._matched_escalation_keyword("please litellm escalate now") is None
+ assert complexity_router._matched_escalation_keyword("how do I escalate this ticket") is None
def test_escalate_tier_bumps_one_step(self, complexity_router):
assert complexity_router._escalate_tier(ComplexityTier.SIMPLE) == ComplexityTier.MEDIUM
@@ -3559,3 +3578,584 @@ class TestEscalationKeywords:
messages=[{"role": "user", "content": "LITELLM ESCALATE do better"}],
)
assert result.model == "o1-b" # unchanged: no random hop to o1-a / o1-c
+
+
+class TestRoutingDecisionContents:
+ """Every routing path must return a PreRoutingHookResponse carrying a routing_decision
+ that names the mechanism that actually decided, with the facts of that path only."""
+
+ @pytest.mark.asyncio
+ async def test_heuristic_decision_carries_score_signals_and_boundary_snapshot(self, complexity_router):
+ response = await complexity_router.async_pre_routing_hook(
+ model="test-complexity-router",
+ request_kwargs={},
+ messages=[{"role": "user", "content": "Hello!"}],
+ )
+ assert response is not None
+ decision = response.routing_decision
+ assert decision is not None
+ assert decision["router_model_name"] == "test-complexity-router"
+ assert decision["router_type"] == "complexity"
+ assert decision["cause"] == "heuristic_scorer"
+ assert decision["tier"] == "SIMPLE"
+ assert decision["routed_model"] == response.model == "gpt-4o-mini"
+ assert isinstance(decision["score"], float)
+ assert any("short" in signal for signal in decision["signals"])
+ # The snapshot must reflect the CONFIGURED boundaries (the fixture overrides the
+ # 0.15/0.35/0.60 defaults), so a logged row stays truthful after config edits.
+ assert decision["tier_boundaries"] == {
+ "simple_medium": 0.25,
+ "medium_complex": 0.50,
+ "complex_reasoning": 0.75,
+ }
+ assert "escalated" not in decision
+ assert "classifier_model" not in decision
+
+ @pytest.mark.asyncio
+ async def test_llm_classifier_decision_names_judge_and_omits_score(
+ self, llm_complexity_router, mock_router_instance
+ ):
+ mock_router_instance.acompletion = AsyncMock(return_value=_llm_response('{"tier": "REASONING"}'))
+ response = await llm_complexity_router.async_pre_routing_hook(
+ model="test-complexity-router",
+ request_kwargs={},
+ messages=[{"role": "user", "content": "hi"}],
+ )
+ assert response is not None
+ decision = response.routing_decision
+ assert decision is not None
+ assert decision["cause"] == "llm_classifier"
+ assert decision["classifier_model"] == "haiku-classifier"
+ assert decision["tier"] == "REASONING"
+ # The LLM path produces a tier label, not a score: no synthetic score and no
+ # boundary snapshot may appear on these rows.
+ assert "score" not in decision
+ assert "tier_boundaries" not in decision
+
+ @pytest.mark.asyncio
+ async def test_llm_classifier_fallback_decision_reports_heuristic(
+ self, llm_complexity_router, mock_router_instance
+ ):
+ """A failed LLM classifier falls back to the heuristic, and the persisted cause
+ must say heuristic_scorer even though classifier_type is 'llm'."""
+ mock_router_instance.acompletion = AsyncMock(side_effect=TimeoutError("classifier timed out"))
+ response = await llm_complexity_router.async_pre_routing_hook(
+ model="test-complexity-router",
+ request_kwargs={},
+ messages=[{"role": "user", "content": "Hello!"}],
+ )
+ assert response is not None
+ decision = response.routing_decision
+ assert decision is not None
+ assert decision["cause"] == "heuristic_scorer"
+ assert "classifier_model" not in decision
+ assert isinstance(decision["score"], float)
+
+ @pytest.mark.asyncio
+ async def test_keyword_override_decision_carries_matched_keyword(self, mock_router_instance, basic_config):
+ config = {
+ **basic_config,
+ "keyword_tier_rules": [{"keywords": ["deploy to k8s"], "tier": "REASONING"}],
+ }
+ router = ComplexityRouter(
+ model_name="test-complexity-router",
+ litellm_router_instance=mock_router_instance,
+ complexity_router_config=config,
+ )
+ response = await router.async_pre_routing_hook(
+ model="test-complexity-router",
+ request_kwargs={},
+ messages=[{"role": "user", "content": "please deploy to k8s now"}],
+ )
+ assert response is not None
+ decision = response.routing_decision
+ assert decision is not None
+ assert decision["cause"] == "literal_keyword_match"
+ assert decision["matched_keyword"] == "deploy to k8s"
+ assert decision["tier"] == "REASONING"
+ assert "score" not in decision
+
+ @pytest.mark.asyncio
+ async def test_no_user_message_decision_is_default_fallback(self, complexity_router):
+ response = await complexity_router.async_pre_routing_hook(
+ model="test-complexity-router",
+ request_kwargs={},
+ messages=[{"role": "system", "content": "be nice"}],
+ )
+ assert response is not None
+ decision = response.routing_decision
+ assert decision is not None
+ assert decision["cause"] == "default_fallback"
+ assert decision["routed_model"] == response.model
+ assert "tier" not in decision
+
+ @pytest.mark.asyncio
+ async def test_session_pin_decision(self, mock_router_instance, basic_config):
+ mock_router_instance.cache = DualCache()
+ router = ComplexityRouter(
+ model_name="test-complexity-router",
+ litellm_router_instance=mock_router_instance,
+ complexity_router_config={**basic_config, "session_affinity": True},
+ )
+ request_kwargs = {"metadata": {"session_id": "session-decision"}}
+ cache_key = router._get_session_affinity_cache_key("session-decision", request_kwargs)
+ await mock_router_instance.cache.async_set_cache(key=cache_key, value="gpt-4o")
+ response = await router.async_pre_routing_hook(
+ model="test-complexity-router",
+ request_kwargs=request_kwargs,
+ messages=[{"role": "user", "content": "hi again"}],
+ )
+ assert response is not None
+ decision = response.routing_decision
+ assert decision is not None
+ assert decision["cause"] == "session_affinity_pin"
+ assert decision["routed_model"] == "gpt-4o"
+ assert "escalated" not in decision
+
+ @pytest.mark.asyncio
+ async def test_reasoning_override_is_its_own_cause(self, complexity_router):
+ """The override is the fact that the score did NOT choose the tier, so it is a
+ cause rather than a marker inside `signals`; anything that filters signals would
+ otherwise change what the row claims."""
+ response = await complexity_router.async_pre_routing_hook(
+ model="test-complexity-router",
+ request_kwargs={},
+ messages=[{"role": "user", "content": "Let's think step by step and prove the theorem."}],
+ )
+ decision = response.routing_decision
+ assert decision["tier"] == "REASONING"
+ assert decision["cause"] == "reasoning_override"
+ # The score is still recorded, but the cause is what says it did not decide.
+ assert decision["score"] < decision["tier_boundaries"]["complex_reasoning"]
+
+
+class TestSignalsNeverQuoteTheSystemPrompt:
+ """Signals are persisted to the caller-readable spend log, so they may name a matched
+ term only when the caller supplied it. A term matched solely in the system prompt is
+ reported as a count, which still explains the score without letting a caller recover
+ configured terms from a prompt it cannot see."""
+
+ @pytest.mark.asyncio
+ async def test_system_prompt_only_terms_are_reported_as_a_count(self, complexity_router):
+ response = await complexity_router.async_pre_routing_hook(
+ model="test-complexity-router",
+ request_kwargs={},
+ messages=[
+ {"role": "system", "content": "You operate the kubernetes database api for the deployment pipeline."},
+ {"role": "user", "content": "say hi"},
+ ],
+ )
+ assert response is not None
+ signals = response.routing_decision["signals"]
+ joined = " ".join(signals)
+ # The system prompt drove these matches, so no signal may name them.
+ for term in ("kubernetes", "database", "api", "deployment"):
+ assert term not in joined
+ # The match is still reported, as a count, so the score stays explainable.
+ assert any("matches" in signal for signal in signals)
+
+ @pytest.mark.asyncio
+ async def test_terms_the_caller_supplied_are_still_named(self, complexity_router):
+ response = await complexity_router.async_pre_routing_hook(
+ model="test-complexity-router",
+ request_kwargs={},
+ messages=[
+ {"role": "system", "content": "You operate the kubernetes cluster."},
+ {"role": "user", "content": "help me debug the database api timeout in production"},
+ ],
+ )
+ assert response is not None
+ signals = " ".join(response.routing_decision["signals"])
+ # The caller typed these, so quoting them discloses nothing.
+ assert "database" in signals or "api" in signals
+ # It did not type this one.
+ assert "kubernetes" not in signals
+
+ def test_scoring_still_reads_the_system_prompt(self, complexity_router):
+ """Redaction is a disclosure rule, not a scoring change: the system prompt must
+ still count toward the tier exactly as before."""
+ with_system = complexity_router.classify(
+ "say hi", "You operate the kubernetes database api for the deployment pipeline."
+ )
+ without_system = complexity_router.classify("say hi")
+ assert with_system[1] > without_system[1]
+
+
+class TestRoutingDecisionSurvivesToSpendLogOnEveryMetadataShape:
+ """The decision must reach the spend-log row on every request surface.
+
+ `/v1/chat/completions` carries proxy state in `metadata`; `/v1/messages` and the
+ batch-style routes carry it in `litellm_metadata` (so the provider's own `metadata`
+ field stays untouched), and a caller may supply either, both, or neither. Logging
+ snapshots `litellm_metadata` by value (`function_setup`, litellm/utils.py), so a
+ stash written to the wrong bucket, or read after a copy, is dropped silently and
+ only on the surfaces nobody exercised. This drives the real hook and then the real
+ spend-log payload builder for every shape.
+ """
+
+ MODEL_LIST = [
+ {
+ "model_name": "smart-router",
+ "litellm_params": {
+ "model": "auto_router/complexity_router",
+ "complexity_router_config": {
+ "tiers": {"SIMPLE": ["gpt-4o-mini"], "MEDIUM": ["gpt-4o"]},
+ "session_affinity": False,
+ },
+ },
+ },
+ {"model_name": "gpt-4o-mini", "litellm_params": {"model": "openai/gpt-4o-mini"}},
+ {"model_name": "gpt-4o", "litellm_params": {"model": "openai/gpt-4o"}},
+ ]
+
+ @pytest.mark.parametrize(
+ "request_kwargs, expected_bucket",
+ [
+ pytest.param({}, "metadata", id="no-caller-metadata"),
+ pytest.param({"metadata": {"caller_tag": "x"}}, "metadata", id="caller-metadata"),
+ pytest.param({"litellm_metadata": {}}, "litellm_metadata", id="litellm-metadata-seeded"),
+ pytest.param(
+ {"litellm_metadata": {"caller_tag": "x"}}, "litellm_metadata", id="litellm-metadata-with-caller-value"
+ ),
+ pytest.param(
+ {"litellm_metadata": {}, "metadata": {"user_id": "end-user-1"}},
+ "litellm_metadata",
+ id="both-buckets",
+ ),
+ ],
+ )
+ @pytest.mark.asyncio
+ async def test_decision_reaches_the_spend_log_payload(self, request_kwargs, expected_bucket):
+ import datetime
+ import json
+
+ from litellm.proxy.spend_tracking.spend_tracking_utils import get_logging_payload
+
+ router = Router(model_list=self.MODEL_LIST)
+ response = await router.async_pre_routing_hook(
+ model="smart-router",
+ request_kwargs=request_kwargs,
+ messages=[{"role": "user", "content": "Hello!"}],
+ )
+ assert response is not None
+ assert "routing_decision" in request_kwargs[expected_bucket]
+ if expected_bucket == "litellm_metadata" and isinstance(request_kwargs.get("metadata"), dict):
+ # On these routes `metadata` is the provider's own field, forwarded upstream.
+ assert "routing_decision" not in request_kwargs["metadata"]
+
+ # Mirror function_setup: it copies `litellm_metadata` by value into
+ # litellm_params AFTER the router hook has run, so the copy must carry
+ # the decision. Reading the stash any earlier would lose it.
+ litellm_params: Dict = {}
+ if "metadata" in request_kwargs:
+ litellm_params["metadata"] = request_kwargs["metadata"]
+ if isinstance(request_kwargs.get("litellm_metadata"), dict):
+ litellm_params["litellm_metadata"] = request_kwargs["litellm_metadata"].copy()
+
+ payload = get_logging_payload(
+ kwargs={"model": "gpt-4o-mini", "litellm_params": litellm_params},
+ response_obj=litellm.ModelResponse(id="chatcmpl-shape", choices=[], usage=litellm.Usage()),
+ start_time=datetime.datetime.now(datetime.timezone.utc),
+ end_time=datetime.datetime.now(datetime.timezone.utc),
+ )
+ persisted = json.loads(payload["metadata"])["routing_decision"]
+ assert persisted is not None, f"routing_decision dropped for {expected_bucket}"
+ assert persisted["router_model_name"] == "smart-router"
+
+
+class TestRoutingDecisionIsPerAttempt:
+ """The stash must describe the attempt that actually served the request.
+
+ Fallbacks re-enter `async_pre_routing_hook` with the SAME request_kwargs, so a
+ decision left behind by a failed auto-router attempt would be attributed to the
+ plain model group that served the retry, making the spend row claim a tier the
+ request never used. The bucket is also resolved through the shared owner, so a
+ non-dict value in the bucket slot is replaced rather than silently skipped.
+ """
+
+ MODEL_LIST = [
+ {
+ "model_name": "smart-router",
+ "litellm_params": {
+ "model": "auto_router/complexity_router",
+ "complexity_router_config": {
+ "tiers": {"SIMPLE": ["gpt-4o-mini"], "MEDIUM": ["gpt-4o"]},
+ "session_affinity": False,
+ },
+ },
+ },
+ {"model_name": "gpt-4o-mini", "litellm_params": {"model": "openai/gpt-4o-mini"}},
+ {"model_name": "gpt-4o", "litellm_params": {"model": "openai/gpt-4o"}},
+ ]
+
+ @pytest.mark.parametrize(
+ "seed, bucket", [({}, "metadata"), ({"litellm_metadata": {}}, "litellm_metadata")]
+ )
+ @pytest.mark.asyncio
+ async def test_fallback_to_plain_model_group_clears_the_earlier_decision(self, seed, bucket):
+ router = Router(model_list=self.MODEL_LIST)
+ request_kwargs: Dict = dict(seed)
+ messages = [{"role": "user", "content": "Hello!"}]
+
+ await router.async_pre_routing_hook(
+ model="smart-router", request_kwargs=request_kwargs, messages=messages
+ )
+ assert "routing_decision" in request_kwargs[bucket]
+
+ # The fallback attempt reuses the same kwargs and selects no strategy.
+ response = await router.async_pre_routing_hook(
+ model="gpt-4o-mini", request_kwargs=request_kwargs, messages=messages
+ )
+ assert response is None
+ assert "routing_decision" not in request_kwargs[bucket]
+
+ @pytest.mark.parametrize("unusable_bucket", [None, "not-a-dict"])
+ @pytest.mark.asyncio
+ async def test_non_dict_bucket_is_replaced_not_skipped(self, unusable_bucket):
+ """A caller can send `litellm_metadata` as a non-dict (unparsed string, null).
+ Skipping the write there would drop provenance on a successfully routed
+ request with no error, so the shared bucket owner replaces the value."""
+ router = Router(model_list=self.MODEL_LIST)
+ request_kwargs: Dict = {"litellm_metadata": unusable_bucket}
+
+ response = await router.async_pre_routing_hook(
+ model="smart-router",
+ request_kwargs=request_kwargs,
+ messages=[{"role": "user", "content": "Hello!"}],
+ )
+
+ assert response is not None
+ bucket = request_kwargs["litellm_metadata"]
+ assert isinstance(bucket, dict)
+ assert bucket["routing_decision"]["router_model_name"] == "smart-router"
+
+
+class TestRecordRoutingDecision:
+ """Direct coverage of the single recording point, whose contract is write-or-clear:
+ the request's metadata must describe the current attempt and nothing else."""
+
+ DECISION = {"router_model_name": "smart-router", "router_type": "complexity", "routed_model": "gpt-4o-mini"}
+
+ def test_none_clears_a_previous_decision_from_both_buckets(self):
+ request_kwargs: Dict = {
+ "metadata": {"routing_decision": self.DECISION, "keep": 1},
+ "litellm_metadata": {"routing_decision": self.DECISION},
+ }
+ Router._record_routing_decision(request_kwargs=request_kwargs, routing_decision=None)
+ assert "routing_decision" not in request_kwargs["metadata"]
+ assert "routing_decision" not in request_kwargs["litellm_metadata"]
+ assert request_kwargs["metadata"]["keep"] == 1
+
+ def test_none_creates_no_bucket_on_a_request_that_had_none(self):
+ request_kwargs: Dict = {}
+ Router._record_routing_decision(request_kwargs=request_kwargs, routing_decision=None)
+ assert request_kwargs == {}
+
+
+class TestEscalationIsRecordedConsistently:
+ """An escalation keyword records two separate facts on every path: that the caller
+ asked, and whether the tier actually moved. Dropping the ask when there is nowhere
+ higher to go makes a request look like an ordinary route, and reporting a bump that
+ never happened is the opposite error; both must be avoided identically everywhere."""
+
+ CEILING_CONFIG = {
+ "tiers": {"SIMPLE": ["gpt-4o-mini"], "REASONING": ["o1-preview"]},
+ "session_affinity": False,
+ }
+
+ @pytest.mark.asyncio
+ async def test_scorer_path_at_ceiling_keeps_the_keyword_and_reports_no_bump(self, mock_router_instance):
+ router = ComplexityRouter(
+ model_name="test-router",
+ litellm_router_instance=mock_router_instance,
+ complexity_router_config={
+ **self.CEILING_CONFIG,
+ "tier_boundaries": {"simple_medium": -99, "medium_complex": -99, "complex_reasoning": -99},
+ },
+ )
+ response = await router.async_pre_routing_hook(
+ model="test-router",
+ request_kwargs={},
+ messages=[{"role": "user", "content": "LITELLM ESCALATE already at the top"}],
+ )
+ decision = response.routing_decision
+ assert decision["tier"] == "REASONING"
+ assert decision["escalation_keyword"] == "LITELLM ESCALATE"
+ assert decision["escalated"] is False
+
+ @pytest.mark.asyncio
+ async def test_scorer_path_below_ceiling_reports_the_bump(self, complexity_router):
+ response = await complexity_router.async_pre_routing_hook(
+ model="test-router",
+ request_kwargs={},
+ messages=[{"role": "user", "content": "LITELLM ESCALATE what is 2+2"}],
+ )
+ decision = response.routing_decision
+ assert decision["escalation_keyword"] == "LITELLM ESCALATE"
+ assert decision["escalated"] is True
+
+ @pytest.mark.asyncio
+ async def test_session_pin_at_ceiling_still_records_the_ask(self, mock_router_instance):
+ mock_router_instance.cache = DualCache()
+ router = ComplexityRouter(
+ model_name="test-router",
+ litellm_router_instance=mock_router_instance,
+ complexity_router_config={**self.CEILING_CONFIG, "session_affinity": True},
+ )
+ request_kwargs = {"metadata": {"session_id": "session-ceiling"}}
+ cache_key = router._get_session_affinity_cache_key("session-ceiling", request_kwargs)
+ await mock_router_instance.cache.async_set_cache(key=cache_key, value="o1-preview")
+
+ response = await router.async_pre_routing_hook(
+ model="test-router",
+ request_kwargs=request_kwargs,
+ messages=[{"role": "user", "content": "LITELLM ESCALATE go higher"}],
+ )
+ decision = response.routing_decision
+ assert decision["routed_model"] == "o1-preview"
+ assert decision["cause"] == "session_affinity_pin"
+ # Previously the keyword was dropped here, so the row was indistinguishable
+ # from a turn that never asked to escalate.
+ assert decision["escalation_keyword"] == "LITELLM ESCALATE"
+ assert decision["escalated"] is False
+
+ @pytest.mark.asyncio
+ async def test_session_pin_below_ceiling_reports_the_bump(self, mock_router_instance):
+ mock_router_instance.cache = DualCache()
+ router = ComplexityRouter(
+ model_name="test-router",
+ litellm_router_instance=mock_router_instance,
+ complexity_router_config={**self.CEILING_CONFIG, "session_affinity": True},
+ )
+ request_kwargs = {"metadata": {"session_id": "session-below"}}
+ cache_key = router._get_session_affinity_cache_key("session-below", request_kwargs)
+ await mock_router_instance.cache.async_set_cache(key=cache_key, value="gpt-4o-mini")
+
+ response = await router.async_pre_routing_hook(
+ model="test-router",
+ request_kwargs=request_kwargs,
+ messages=[{"role": "user", "content": "LITELLM ESCALATE go higher"}],
+ )
+ decision = response.routing_decision
+ assert decision["cause"] == "session_affinity_escalation"
+ assert decision["escalation_keyword"] == "LITELLM ESCALATE"
+ assert decision["escalated"] is True
+
+ @pytest.mark.asyncio
+ async def test_signals_are_a_json_array_not_a_stringified_tuple(self, complexity_router):
+ """The dashboard maps over `signals`, so the persisted shape has to be an array
+ regardless of how any given serializer treats sequence types."""
+ import json
+
+ from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
+
+ response = await complexity_router.async_pre_routing_hook(
+ model="test-router",
+ request_kwargs={},
+ messages=[{"role": "user", "content": "Hello!"}],
+ )
+ signals = response.routing_decision["signals"]
+ assert isinstance(signals, list)
+ assert isinstance(json.loads(safe_dumps({"d": response.routing_decision}))["d"]["signals"], list)
+
+
+class TestRedactedLoggingDropsPromptText:
+ """An operator who turns message logging off has said prompt content must not reach
+ the logs. The routing decision quotes the prompt in its matched keywords and in the
+ signals that name them, so those are dropped while the derived values that make the
+ row explainable are kept."""
+
+ MODEL_LIST = [
+ {
+ "model_name": "smart-router",
+ "litellm_params": {
+ "model": "auto_router/complexity_router",
+ "complexity_router_config": {
+ "tiers": {"SIMPLE": ["gpt-4o-mini"], "REASONING": ["gpt-4o"]},
+ "session_affinity": False,
+ "keyword_tier_rules": [{"keywords": ["deploy to k8s"], "tier": "REASONING"}],
+ },
+ },
+ },
+ {"model_name": "gpt-4o-mini", "litellm_params": {"model": "openai/gpt-4o-mini"}},
+ {"model_name": "gpt-4o", "litellm_params": {"model": "openai/gpt-4o"}},
+ ]
+
+ MESSAGES = [{"role": "user", "content": "LITELLM ESCALATE please deploy to k8s now"}]
+
+ async def _decision(self, request_kwargs: Dict) -> Dict:
+ router = Router(model_list=self.MODEL_LIST)
+ response = await router.async_pre_routing_hook(
+ model="smart-router", request_kwargs=request_kwargs, messages=self.MESSAGES
+ )
+ assert response is not None
+ return request_kwargs["metadata"]["routing_decision"]
+
+ @pytest.mark.asyncio
+ async def test_prompt_text_is_persisted_when_logging_is_not_redacted(self):
+ decision = await self._decision({})
+ # Control: without redaction the terms are the point of the feature.
+ assert decision["matched_keyword"] == "deploy to k8s"
+ assert decision["escalation_keyword"] == "LITELLM ESCALATE"
+
+ @pytest.mark.asyncio
+ async def test_redaction_drops_quoted_prompt_text_but_keeps_the_explanation(self, monkeypatch):
+ # The usual deployment shape: `litellm_settings: turn_off_message_logging: true`
+ monkeypatch.setattr(litellm, "turn_off_message_logging", True)
+ decision = await self._decision({})
+
+ for field in ("signals", "matched_keyword", "escalation_keyword"):
+ assert field not in decision, f"{field} quotes the prompt and must be dropped"
+ # Nothing here reproduces the prompt, so the row stays explainable.
+ assert decision["cause"] == "literal_keyword_match"
+ assert decision["tier"] == "REASONING"
+ assert decision["routed_model"] == "gpt-4o"
+ assert decision["escalated"] is False
+
+ def test_only_verbatim_prompt_fields_are_classified_as_prompt_text(self, monkeypatch):
+ """The field classification is the whole contract, so pin it directly: anything
+ that quotes the prompt goes, anything derived from it stays."""
+ monkeypatch.setattr(litellm, "turn_off_message_logging", True)
+ full = {
+ "router_model_name": "smart-router",
+ "router_type": "complexity",
+ "routed_model": "gpt-4o",
+ "cause": "literal_keyword_match",
+ "tier": "REASONING",
+ "score": 0.8,
+ "tier_boundaries": {"simple_medium": 0.15, "medium_complex": 0.35, "complex_reasoning": 0.6},
+ "classifier_model": "claude-haiku",
+ "escalated": True,
+ "signals": ["code (python)"],
+ "matched_keyword": "deploy to k8s",
+ "escalation_keyword": "LITELLM ESCALATE",
+ }
+ kept = Router._redact_prompt_text_if_needed(request_kwargs={}, routing_decision=full)
+ assert set(full) - set(kept) == {"signals", "matched_keyword", "escalation_keyword"}
+
+ @pytest.mark.asyncio
+ async def test_redaction_via_request_header_is_honored(self):
+ request_kwargs: Dict = {"metadata": {"headers": {"x-litellm-enable-message-redaction": True}}}
+ decision = await self._decision(request_kwargs)
+ assert "matched_keyword" not in decision
+ assert decision["cause"] == "literal_keyword_match"
+
+
+def test_every_routing_decision_field_is_classified():
+ """Redaction is derived from a declaration, not a list at the call site, so every
+ field has to be classified as quoting the prompt or aggregating it. A field added
+ without a decision fails here rather than silently shipping unredacted or, worse,
+ being over-redacted and taking a load-bearing fact with it."""
+ from litellm.types.utils import (
+ DERIVED_ROUTING_DECISION_FIELDS,
+ PROMPT_QUOTING_ROUTING_DECISION_FIELDS,
+ StandardLoggingRoutingDecision,
+ )
+
+ declared = set(StandardLoggingRoutingDecision.__annotations__)
+ classified = PROMPT_QUOTING_ROUTING_DECISION_FIELDS | DERIVED_ROUTING_DECISION_FIELDS
+ assert declared == classified, (
+ "classify new routing-decision fields in litellm/types/utils.py: "
+ f"unclassified={declared - classified}, stale={classified - declared}"
+ )
+ assert not (PROMPT_QUOTING_ROUTING_DECISION_FIELDS & DERIVED_ROUTING_DECISION_FIELDS)
diff --git a/tests/test_litellm/router_strategy/test_quality_router.py b/tests/test_litellm/router_strategy/test_quality_router.py
index 01574cb980d..b2e901739da 100644
--- a/tests/test_litellm/router_strategy/test_quality_router.py
+++ b/tests/test_litellm/router_strategy/test_quality_router.py
@@ -1031,3 +1031,53 @@ class TestRouterQualityDeploymentMethods:
)
router.init_quality_router_deployment(deployment)
assert "auto_router/quality_router/test-router" in router.quality_routers
+
+
+class TestRoutingDecisionProvenance:
+ """Every quality-router path must attach a routing_decision to its hook response,
+ including the no-user-message default path that previously recorded nothing."""
+
+ @pytest.mark.asyncio
+ async def test_quality_tier_decision(self, quality_router):
+ resp = await quality_router.async_pre_routing_hook(
+ model="quality-router-test",
+ request_kwargs={},
+ messages=[{"role": "user", "content": "hi"}],
+ )
+ assert resp is not None
+ decision = resp.routing_decision
+ assert decision is not None
+ assert decision["router_model_name"] == "quality-router-test"
+ assert decision["router_type"] == "quality"
+ assert decision["cause"] == "quality_tier"
+ assert decision["routed_model"] == "haiku"
+ assert decision["tier"] == "1"
+ assert isinstance(decision["score"], float)
+
+ @pytest.mark.asyncio
+ async def test_keyword_decision_carries_matched_keyword(self, keyword_router):
+ resp = await keyword_router.async_pre_routing_hook(
+ model="quality-router-test",
+ request_kwargs={},
+ messages=[{"role": "user", "content": "please write python for me"}],
+ )
+ assert resp is not None
+ decision = resp.routing_decision
+ assert decision is not None
+ assert decision["cause"] == "keyword"
+ assert decision["matched_keyword"] == "python"
+ assert decision["routed_model"] == resp.model
+
+ @pytest.mark.asyncio
+ async def test_no_user_message_decision_is_default_fallback(self, quality_router):
+ resp = await quality_router.async_pre_routing_hook(
+ model="quality-router-test",
+ request_kwargs={},
+ messages=[{"role": "system", "content": "You are a helpful assistant."}],
+ )
+ assert resp is not None
+ decision = resp.routing_decision
+ assert decision is not None
+ assert decision["cause"] == "default_fallback"
+ assert decision["routed_model"] == "haiku"
+ assert "tier" not in decision
diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx
index 2752cccce56..e61d614f9a1 100644
--- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx
+++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx
@@ -12,6 +12,7 @@ import { VectorStoreViewer } from "../VectorStoreViewer";
import { TruncatedValue } from "./TruncatedValue";
import { TokenFlow } from "./TokenFlow";
import { JsonViewer } from "./JsonViewer";
+import { RoutingDecisionCard, type RoutingDecision } from "./RoutingDecisionCard";
import {
formatData,
checkHasMessages,
@@ -137,6 +138,9 @@ export function LogDetailContent({ logEntry, isLoadingDetails = false, accessTok
+ {/* Routing */}
+
+
{/* Metrics */}
diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/RoutingDecisionCard.test.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/RoutingDecisionCard.test.tsx
new file mode 100644
index 00000000000..99fe20278ea
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/RoutingDecisionCard.test.tsx
@@ -0,0 +1,136 @@
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect } from "vitest";
+import { RoutingDecisionCard, type RoutingDecision } from "./RoutingDecisionCard";
+
+const heuristic: RoutingDecision = {
+ router_model_name: "smart-router",
+ router_type: "complexity",
+ routed_model: "claude-sonnet",
+ cause: "heuristic_scorer",
+ tier: "REASONING",
+ score: 0.82,
+ signals: ["long (900 tokens)", "code (python, function)"],
+ tier_boundaries: { simple_medium: 0.15, medium_complex: 0.35, complex_reasoning: 0.6 },
+};
+
+describe("RoutingDecisionCard", () => {
+ it("renders nothing when the request carried no routing decision", () => {
+ const { container } = render( );
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("explains a heuristic score against the boundaries that were in effect", () => {
+ render( );
+ expect(screen.getByText("smart-router")).toBeInTheDocument();
+ expect(screen.getByText("(Auto-Router v2)")).toBeInTheDocument();
+ expect(screen.getByText("REASONING")).toBeInTheDocument();
+ expect(screen.getByText("Heuristic scorer")).toBeInTheDocument();
+ expect(screen.getByText("0.82")).toBeInTheDocument();
+ expect(screen.getByText("(at or above 0.6, REASONING)")).toBeInTheDocument();
+ expect(screen.getByText("claude-sonnet")).toBeInTheDocument();
+ expect(screen.getByText("long (900 tokens)")).toBeInTheDocument();
+ });
+
+ it("uses the persisted boundary snapshot, not today's defaults", () => {
+ // Same score, boundaries the operator had configured lower: it lands in a
+ // different band, and the card must say so.
+ render(
+ ,
+ );
+ expect(screen.getByText("(at or above 0.3, REASONING)")).toBeInTheDocument();
+ });
+
+ it("labels a reasoning override and does not claim the score met a boundary", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("Heuristic, REASONING override (2 or more reasoning markers)")).toBeInTheDocument();
+ expect(screen.getByText("0.20")).toBeInTheDocument();
+ // The score did not decide this tier, so NO band explanation may render at all.
+ // Asserting the absence of one specific band would pass vacuously: 0.20 sits in
+ // the MEDIUM band, so the REASONING wording is absent either way.
+ expect(screen.queryByText(/SIMPLE|MEDIUM|COMPLEX|at or above/)).not.toBeInTheDocument();
+ });
+
+ it("names the judge model on the LLM classifier path and shows no score", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("LLM classifier (claude-haiku)")).toBeInTheDocument();
+ expect(screen.queryByText("Score")).not.toBeInTheDocument();
+ });
+
+ it("shows the keyword that fired a tier rule", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText('Keyword match: "deploy to k8s"')).toBeInTheDocument();
+ });
+
+ it("shows the escalation keyword", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText('Yes, keyword "LITELLM ESCALATE"')).toBeInTheDocument();
+ });
+
+ it("still shows the ask when escalation had nowhere higher to go", () => {
+ // The tier did not move, but the row must not read like a request that never
+ // asked to escalate.
+ render(
+ ,
+ );
+ expect(screen.getByText('Requested via "LITELLM ESCALATE"; already at the highest tier')).toBeInTheDocument();
+ });
+
+ it("omits the escalation row when no escalation was requested", () => {
+ render( );
+ expect(screen.queryByText("Escalated")).not.toBeInTheDocument();
+ });
+
+ it("still shows a ceiling escalation after the keyword is redacted away", () => {
+ // Under message redaction the keyword is gone but `escalated` survives, so the
+ // row must still say an escalation was requested.
+ render( );
+ expect(screen.getByText("Requested; already at the highest tier")).toBeInTheDocument();
+ });
+
+ it("does not claim the score chose the tier on a redacted override row", () => {
+ // `signals` is gone under redaction; the cause alone must suppress the band.
+ render( );
+ expect(screen.queryByText(/SIMPLE|MEDIUM|COMPLEX|at or above/)).not.toBeInTheDocument();
+ expect(screen.getByText("Heuristic, REASONING override (2 or more reasoning markers)")).toBeInTheDocument();
+ });
+
+ it("falls back to the raw cause for a value this build does not know", () => {
+ render( );
+ expect(screen.getByText("some_future_cause")).toBeInTheDocument();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/RoutingDecisionCard.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/RoutingDecisionCard.tsx
new file mode 100644
index 00000000000..77813971b78
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/RoutingDecisionCard.tsx
@@ -0,0 +1,192 @@
+"use client";
+
+import { Waypoints } from "lucide-react";
+
+import { Badge } from "@/components/ui/badge";
+import { cn } from "@/lib/cva.config";
+
+export interface RoutingDecisionTierBoundaries {
+ simple_medium?: number;
+ medium_complex?: number;
+ complex_reasoning?: number;
+}
+
+export interface RoutingDecision {
+ router_model_name?: string;
+ router_type?: string;
+ routed_model?: string;
+ cause?: string;
+ tier?: string;
+ request_type?: string;
+ score?: number;
+ signals?: string[];
+ matched_keyword?: string;
+ escalation_keyword?: string;
+ classifier_model?: string;
+ escalated?: boolean;
+ tier_boundaries?: RoutingDecisionTierBoundaries;
+}
+
+const ROUTER_TYPE_LABELS: Record = {
+ complexity: "Auto-Router v2",
+ adaptive: "Adaptive router",
+ quality: "Quality router",
+};
+
+/**
+ * The tier the score alone would have produced, given the boundaries in effect when
+ * the decision was made. Rendered as the bracket that explains a score, so it must
+ * use the snapshot rather than today's config.
+ */
+function describeScoreAgainstBoundaries(score: number, boundaries?: RoutingDecisionTierBoundaries): string | null {
+ if (!boundaries) return null;
+ const {
+ simple_medium: simpleMedium,
+ medium_complex: mediumComplex,
+ complex_reasoning: complexReasoning,
+ } = boundaries;
+ if (simpleMedium === undefined || mediumComplex === undefined || complexReasoning === undefined) return null;
+
+ if (score < simpleMedium) return `below ${simpleMedium}, SIMPLE`;
+ if (score < mediumComplex) return `${simpleMedium} to ${mediumComplex}, MEDIUM`;
+ if (score < complexReasoning) return `${mediumComplex} to ${complexReasoning}, COMPLEX`;
+ return `at or above ${complexReasoning}, REASONING`;
+}
+
+function describeCause(decision: RoutingDecision): string {
+ const { cause, classifier_model: classifierModel, matched_keyword: matchedKeyword } = decision;
+
+ switch (cause) {
+ case "heuristic_scorer":
+ return "Heuristic scorer";
+ case "reasoning_override":
+ return "Heuristic, REASONING override (2 or more reasoning markers)";
+ case "llm_classifier":
+ return classifierModel ? `LLM classifier (${classifierModel})` : "LLM classifier";
+ case "literal_keyword_match":
+ return matchedKeyword ? `Keyword match: "${matchedKeyword}"` : "Keyword match";
+ case "semantic_keyword_match":
+ return "Semantic keyword match";
+ case "session_affinity_pin":
+ return "Pinned to session";
+ case "session_affinity_escalation":
+ return "Escalated from session pin";
+ case "quality_tier":
+ return "Quality tier mapping";
+ case "keyword":
+ return matchedKeyword ? `Keyword match: "${matchedKeyword}"` : "Keyword match";
+ case "bandit":
+ return "Adaptive bandit";
+ case "default_fallback":
+ return "Default model, no route matched";
+ default:
+ return cause ?? "Unknown";
+ }
+}
+
+/**
+ * A request can ask to escalate and get nowhere, when its tier is already the highest
+ * one configured. That row still has to say the caller asked, otherwise it reads as an
+ * ordinary route; it just must not claim a bump that did not happen. Only called when
+ * the request escalated or asked to, so there is no "did not escalate" case.
+ */
+function describeEscalation(escalated: boolean, keyword: string | undefined): string {
+ if (escalated) return keyword ? `Yes, keyword "${keyword}"` : "Yes";
+ return keyword ? `Requested via "${keyword}"; already at the highest tier` : "Requested; already at the highest tier";
+}
+
+function Row({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+ {label}
+ {children}
+
+ );
+}
+
+export function RoutingDecisionCard({
+ decision,
+ className,
+}: {
+ decision?: RoutingDecision | null;
+ className?: string;
+}) {
+ if (!decision || !decision.cause) return null;
+
+ const {
+ router_model_name: routerModelName,
+ router_type: routerType,
+ routed_model: routedModel,
+ tier,
+ request_type: requestType,
+ score,
+ signals,
+ escalated,
+ escalation_keyword: escalationKeyword,
+ tier_boundaries: tierBoundaries,
+ } = decision;
+
+ // On an override row the score did not decide the tier, so showing it against a
+ // boundary would claim something untrue. Keyed off the cause rather than a marker
+ // inside `signals`, which redaction can remove.
+ const scoreExplanation =
+ score !== undefined && decision.cause !== "reasoning_override"
+ ? describeScoreAgainstBoundaries(score, tierBoundaries)
+ : null;
+
+ return (
+
+
Routing
+
+ {routerModelName && (
+
+
+ {routerModelName}
+ {routerType && (
+
+ ({ROUTER_TYPE_LABELS[routerType] ?? routerType})
+
+ )}
+
+ )}
+
+ {tier && (
+
+
+ {tier}
+
+
+ )}
+
+ {requestType &&
{requestType}
}
+
+
{describeCause(decision)}
+
+ {score !== undefined && (
+
+ {score.toFixed(2)}
+ {scoreExplanation && ({scoreExplanation}) }
+
+ )}
+
+ {routedModel &&
{routedModel}
}
+
+ {escalated !== undefined &&
{describeEscalation(escalated, escalationKeyword)}
}
+
+ {signals && signals.length > 0 && (
+
+
+ {signals.map((signal) => (
+
+ {signal}
+
+ ))}
+
+
+ )}
+
+
+ );
+}
+
+export default RoutingDecisionCard;
From bf8e4af0e2458668530918aadb860ea11384ac1e Mon Sep 17 00:00:00 2001
From: Yassin Kortam
Date: Thu, 30 Jul 2026 12:01:10 -0700
Subject: [PATCH 14/33] fix(otel): cap tool-definition attributes so they
cannot evict gen_ai.* from the LLM span (#34828)
* fix(otel): cap tool-definition attributes so they cannot evict gen_ai.* from the LLM span
The genai and legacy mappers each spelled out every declared tool as
per-index span attributes. A request declaring hundreds of tools produced
roughly 500 attributes against the OTel SDK's default 128-attribute span
limit, which evicts oldest-first, so the canonical gen_ai.* set written
first was discarded and the span exported with only a tail of tool
schemas. Cap the family at 8 tools, shared by both vocabularies, and
carry the declared total on litellm.request.tools.declared so the
truncation is visible rather than silent.
* fix(otel): apply the tool-definition cap to the OpenInference mapper
The OpenInference vocabulary emits its own unbounded llm.tools.{idx}.*
family, which Arize and Phoenix layer on top of the default two, so those
configurations still overran the span attribute limit and evicted the
core gen_ai.* attributes. Route it through the same shared cap and cover
the layered-mapper path with a test.
* fix(otel): share one span-wide tool-definition budget across vocabularies
Capping the tool-definition family per mapper left each active vocabulary
its own allowance, and several vocabularies write to the same span. With
every vendor vocabulary configured, the three that spell tools out per
index still summed past the SDK's 128-attribute span limit, so the core
gen_ai.* set written first was evicted exactly as before: measured at 128
attributes with 7 dropped and gen_ai.request.model gone.
Reserve a quarter of the span for tool detail and split that ceiling
across the distinct tool-emitting vocabularies at mapper-resolution time,
so the family is bounded span-wide no matter how many are configured. The
same worst case now exports 90 attributes with nothing dropped.
---
litellm/integrations/otel/README.md | 5 +-
litellm/integrations/otel/mappers/__init__.py | 30 ++--
litellm/integrations/otel/mappers/genai.py | 28 ++--
litellm/integrations/otel/mappers/legacy.py | 25 ++--
.../otel/mappers/openinference.py | 32 +++--
litellm/integrations/otel/mappers/utils.py | 51 ++++++-
litellm/integrations/otel/model/semconv.py | 1 +
.../integrations/otel/test_otel_v2_emitter.py | 135 ++++++++++++++++++
8 files changed, 258 insertions(+), 49 deletions(-)
diff --git a/litellm/integrations/otel/README.md b/litellm/integrations/otel/README.md
index 3038bdb90b2..f318bb42b82 100644
--- a/litellm/integrations/otel/README.md
+++ b/litellm/integrations/otel/README.md
@@ -267,7 +267,10 @@ lives in [`plumbing/`](./plumbing):
- **A new attribute vocabulary for a backend**: add a mapper in `mappers/`
(a class with a `map(data) -> AttributeMap` method, typically built from
- `key -> extractor` tables) and register it in `mappers/__init__._MAPPER_BY_NAME`.
+ `key -> extractor` tables) and register it in `mappers/__init__._PLAIN_MAPPERS`.
+ If it spells declared tool definitions out per index, register it in
+ `_TOOL_DEFINITION_MAPPERS` instead and take the shared attribute budget in its
+ constructor, so the family stays bounded span-wide rather than per vocabulary.
- **A new integration**: add a preset in `presets/` that returns an
`OpenTelemetryV2Config`, and register it in `presets/__init__.PRESET_BY_CALLBACK`.
If it supports dynamic credentials, add a header builder to
diff --git a/litellm/integrations/otel/mappers/__init__.py b/litellm/integrations/otel/mappers/__init__.py
index b0c1d7019db..55504c5e8bf 100644
--- a/litellm/integrations/otel/mappers/__init__.py
+++ b/litellm/integrations/otel/mappers/__init__.py
@@ -18,13 +18,19 @@ from litellm.integrations.otel.mappers.langfuse import LangfuseMapper
from litellm.integrations.otel.mappers.langtrace import LangtraceMapper
from litellm.integrations.otel.mappers.legacy import LegacyMapper
from litellm.integrations.otel.mappers.openinference import OpenInferenceMapper
+from litellm.integrations.otel.mappers.utils import tool_attr_budget
from litellm.integrations.otel.mappers.weave import WeaveMapper
-# Registry keyed by ``config.mapper_names`` entries.
-_MAPPER_BY_NAME: dict[str, Callable[[], AttributeMapper]] = {
+# Registries keyed by ``config.mapper_names`` entries, split by whether the
+# vocabulary spells declared tool definitions out per index. Those share one
+# span-wide attribute ceiling, so resolution has to know how many of them are
+# active before it can build them.
+_TOOL_DEFINITION_MAPPERS: dict[str, Callable[[int], AttributeMapper]] = {
"genai": GenAIMapper,
"legacy": LegacyMapper,
"openinference": OpenInferenceMapper,
+}
+_PLAIN_MAPPERS: dict[str, Callable[[], AttributeMapper]] = {
"langfuse": LangfuseMapper,
"weave": WeaveMapper,
"langtrace": LangtraceMapper,
@@ -33,13 +39,19 @@ _MAPPER_BY_NAME: dict[str, Callable[[], AttributeMapper]] = {
def resolve_mappers(names: Iterable[str]) -> list[AttributeMapper]:
"""Resolve mapper names to instances. Unknown names raise ``ValueError``."""
- out: list[AttributeMapper] = []
- for name in names:
- factory = _MAPPER_BY_NAME.get(name)
- if factory is None:
- raise ValueError(f"unknown mapper name {name!r}; known: {sorted(_MAPPER_BY_NAME)}")
- out.append(factory())
- return out
+ ordered = tuple(names)
+ for name in ordered:
+ if name not in _TOOL_DEFINITION_MAPPERS and name not in _PLAIN_MAPPERS:
+ known = sorted((*_TOOL_DEFINITION_MAPPERS, *_PLAIN_MAPPERS))
+ raise ValueError(f"unknown mapper name {name!r}; known: {known}")
+ # Distinct vocabularies each write the tool family under their own keys, so
+ # the ceiling is split by how many of them are configured. Repeating a name
+ # rewrites the same keys, so only distinct ones count.
+ budget = tool_attr_budget(len({*ordered} & _TOOL_DEFINITION_MAPPERS.keys()))
+ return [
+ _TOOL_DEFINITION_MAPPERS[name](budget) if name in _TOOL_DEFINITION_MAPPERS else _PLAIN_MAPPERS[name]()
+ for name in ordered
+ ]
__all__ = [
diff --git a/litellm/integrations/otel/mappers/genai.py b/litellm/integrations/otel/mappers/genai.py
index f568afa9e3e..70414734b72 100644
--- a/litellm/integrations/otel/mappers/genai.py
+++ b/litellm/integrations/otel/mappers/genai.py
@@ -11,10 +11,11 @@ from typing import Callable
from litellm.integrations.otel.mappers.base import AttributeMap, AttrValue, SpanData
from litellm.integrations.otel.mappers.utils import (
+ MAX_TOOL_DEFINITION_ATTRS_PER_SPAN,
collect,
- drop_none,
output_messages,
serialize_messages,
+ tool_definition_attrs,
)
from litellm.integrations.otel.model.payloads import (
GuardrailSpanData,
@@ -135,6 +136,9 @@ class GenAIMapper:
LiteLLM.SERVICE_CALL_TYPE: lambda d: d.call_type,
}
+ def __init__(self, tool_attr_budget: int = MAX_TOOL_DEFINITION_ATTRS_PER_SPAN) -> None:
+ self._tool_attr_budget = tool_attr_budget
+
def map(self, data: SpanData) -> AttributeMap:
match data:
case LLMCallSpanData():
@@ -150,18 +154,18 @@ class GenAIMapper:
case _:
return {}
- @classmethod
- def _llm_call(cls, data: LLMCallSpanData) -> AttributeMap:
- attrs = collect(cls._LLM_CALL_ATTRS, data)
- attrs.update(
- drop_none(
- {
- f"gen_ai.tool.{idx}.{suffix}": extract(tool)
- for idx, tool in enumerate(data.tools)
- for suffix, extract in cls._TOOL_ATTRS.items()
- }
+ def _llm_call(self, data: LLMCallSpanData) -> AttributeMap:
+ attrs = collect(self._LLM_CALL_ATTRS, data)
+ if data.tools:
+ attrs[LiteLLM.TOOLS_DECLARED] = len(data.tools)
+ attrs.update(
+ tool_definition_attrs(
+ lambda idx, suffix: f"gen_ai.tool.{idx}.{suffix}",
+ data.tools,
+ self._TOOL_ATTRS,
+ self._tool_attr_budget,
+ )
)
- )
return attrs
@classmethod
diff --git a/litellm/integrations/otel/mappers/legacy.py b/litellm/integrations/otel/mappers/legacy.py
index 57dc7ed3632..f17a62828e7 100644
--- a/litellm/integrations/otel/mappers/legacy.py
+++ b/litellm/integrations/otel/mappers/legacy.py
@@ -12,7 +12,11 @@ Like ``GenAIMapper``, each span kind declares its schema as a flat
from typing import Callable, Final
from litellm.integrations.otel.mappers.base import AttributeMap, AttrValue, SpanData
-from litellm.integrations.otel.mappers.utils import collect, drop_none
+from litellm.integrations.otel.mappers.utils import (
+ MAX_TOOL_DEFINITION_ATTRS_PER_SPAN,
+ collect,
+ tool_definition_attrs,
+)
from litellm.integrations.otel.model.payloads import (
LLMCallSpanData,
ServiceSpanData,
@@ -63,6 +67,9 @@ class LegacyMapper:
_LEGACY_ERROR: lambda d: d.error.message if d.error is not None and d.error.message else None,
}
+ def __init__(self, tool_attr_budget: int = MAX_TOOL_DEFINITION_ATTRS_PER_SPAN) -> None:
+ self._tool_attr_budget = tool_attr_budget
+
def map(self, data: SpanData) -> AttributeMap:
match data:
case LLMCallSpanData():
@@ -72,16 +79,14 @@ class LegacyMapper:
case _:
return {}
- @classmethod
- def _llm_call(cls, data: LLMCallSpanData) -> AttributeMap:
- attrs = collect(cls._LLM_CALL_ATTRS, data)
+ def _llm_call(self, data: LLMCallSpanData) -> AttributeMap:
+ attrs = collect(self._LLM_CALL_ATTRS, data)
attrs.update(
- drop_none(
- {
- f"llm.request.functions.{idx}.{suffix}": extract(tool)
- for idx, tool in enumerate(data.tools)
- for suffix, extract in cls._TOOL_ATTRS.items()
- }
+ tool_definition_attrs(
+ lambda idx, suffix: f"llm.request.functions.{idx}.{suffix}",
+ data.tools,
+ self._TOOL_ATTRS,
+ self._tool_attr_budget,
)
)
return attrs
diff --git a/litellm/integrations/otel/mappers/openinference.py b/litellm/integrations/otel/mappers/openinference.py
index dab9a616979..87c4d0d6484 100644
--- a/litellm/integrations/otel/mappers/openinference.py
+++ b/litellm/integrations/otel/mappers/openinference.py
@@ -13,9 +13,11 @@ from litellm.integrations.otel.mappers.base import AttributeMap, AttrValue, Span
from litellm.integrations.otel.mappers.utils import (
collect,
drop_none,
+ MAX_TOOL_DEFINITION_ATTRS_PER_SPAN,
json_if,
message_content,
output_messages,
+ tool_definition_attrs,
)
from litellm.integrations.otel.model.payloads import (
LLMCallSpanData,
@@ -70,6 +72,9 @@ class OpenInferenceMapper:
),
}
+ def __init__(self, tool_attr_budget: int = MAX_TOOL_DEFINITION_ATTRS_PER_SPAN) -> None:
+ self._tool_attr_budget = tool_attr_budget
+
def map(self, data: SpanData) -> AttributeMap:
match data:
case LLMCallSpanData():
@@ -77,14 +82,13 @@ class OpenInferenceMapper:
case _:
return {}
- @classmethod
- def _llm_call(cls, data: LLMCallSpanData) -> AttributeMap:
+ def _llm_call(self, data: LLMCallSpanData) -> AttributeMap:
return {
- **collect(cls._LLM_CALL_ATTRS, data),
- **collect(cls._BLOB_ATTRS, data),
- **cls._messages("llm.input_messages", "input.value", data.messages_in),
- **cls._messages("llm.output_messages", "output.value", output_messages(data)),
- **cls._tools(data),
+ **collect(self._LLM_CALL_ATTRS, data),
+ **collect(self._BLOB_ATTRS, data),
+ **self._messages("llm.input_messages", "input.value", data.messages_in),
+ **self._messages("llm.output_messages", "output.value", output_messages(data)),
+ **self._tools(data),
}
@staticmethod
@@ -108,12 +112,10 @@ class OpenInferenceMapper:
attrs[value_key] = json.dumps([{"role": role, "content": content} for role, content in parsed])
return attrs
- @classmethod
- def _tools(cls, data: LLMCallSpanData) -> AttributeMap:
- return drop_none(
- {
- f"llm.tools.{idx}.{suffix}": extract(tool)
- for idx, tool in enumerate(data.tools)
- for suffix, extract in cls._TOOL_ATTRS.items()
- }
+ def _tools(self, data: LLMCallSpanData) -> AttributeMap:
+ return tool_definition_attrs(
+ lambda idx, suffix: f"llm.tools.{idx}.{suffix}",
+ data.tools,
+ self._TOOL_ATTRS,
+ self._tool_attr_budget,
)
diff --git a/litellm/integrations/otel/mappers/utils.py b/litellm/integrations/otel/mappers/utils.py
index a91e59e4ab8..cbdb60f42c9 100644
--- a/litellm/integrations/otel/mappers/utils.py
+++ b/litellm/integrations/otel/mappers/utils.py
@@ -6,10 +6,34 @@ they live in one place.
"""
import json
-from typing import Callable, Mapping, Sequence
+from typing import Callable, Final, Mapping, Sequence
from litellm.integrations.otel.mappers.base import AttributeMap, AttrValue
-from litellm.integrations.otel.model.payloads import LLMCallSpanData
+from litellm.integrations.otel.model.payloads import LLMCallSpanData, ToolDefinition
+
+DEFAULT_SPAN_ATTRIBUTE_LIMIT: Final = 128
+"""The OTel SDK's default per-span attribute count limit."""
+
+MAX_TOOL_DEFINITION_ATTRS_PER_SPAN: Final = DEFAULT_SPAN_ATTRIBUTE_LIMIT // 4
+"""Span-wide ceiling on attributes spent spelling out declared tool definitions.
+
+Tool definitions are an unbounded attribute family: one entry per declared
+tool, per field, per active vocabulary. Agentic clients declare hundreds, which
+overruns the span attribute limit. That limit evicts oldest-first, so an
+uncapped family silently destroys the core ``gen_ai.*`` attributes written
+before it.
+
+The ceiling is span-wide rather than per-mapper because several vocabularies
+can be active at once and each spells the same tools out under its own keys, so
+a per-mapper allowance multiplies by the number of vocabularies and reaches the
+limit again. Reserving a quarter of the span for tool detail leaves the rest to
+core telemetry no matter how many vocabularies are configured.
+"""
+
+
+def tool_attr_budget(vocabularies: int) -> int:
+ """Split the span-wide tool-definition ceiling across active vocabularies."""
+ return MAX_TOOL_DEFINITION_ATTRS_PER_SPAN // max(vocabularies, 1)
def drop_none(values: Mapping[str, AttrValue | None]) -> AttributeMap:
@@ -17,6 +41,29 @@ def drop_none(values: Mapping[str, AttrValue | None]) -> AttributeMap:
return {k: v for k, v in values.items() if v is not None}
+def tool_definition_attrs(
+ key_for: Callable[[int, str], str],
+ tools: Sequence[ToolDefinition],
+ extractors: Mapping[str, Callable[[ToolDefinition], AttrValue | None]],
+ attr_budget: int,
+) -> AttributeMap:
+ """Per-index attributes for as many tools as ``attr_budget`` affords.
+
+ ``key_for`` builds a vocabulary's key from the tool's index and the field
+ name, so each mapper keeps its own naming while sharing the budget. One tool
+ always keeps its detail, so the family stays legible even when many
+ vocabularies split the ceiling.
+ """
+ max_tools = max(attr_budget // max(len(extractors), 1), 1)
+ return drop_none(
+ {
+ key_for(idx, suffix): extract(tool)
+ for idx, tool in enumerate(tools[:max_tools])
+ for suffix, extract in extractors.items()
+ }
+ )
+
+
def collect(table: Mapping[str, Callable], source: object) -> AttributeMap:
"""Apply an extractor table to ``source``, dropping ``None`` results."""
return drop_none({key: extract(source) for key, extract in table.items()})
diff --git a/litellm/integrations/otel/model/semconv.py b/litellm/integrations/otel/model/semconv.py
index 1abe8ca33fa..a0994d1948a 100644
--- a/litellm/integrations/otel/model/semconv.py
+++ b/litellm/integrations/otel/model/semconv.py
@@ -233,6 +233,7 @@ class LiteLLM:
# ``litellm_params.model``), distinct from the user-facing ``gen_ai.request.model``.
PROVIDER_MODEL: Final = "litellm.provider.model"
REQUEST_STREAMING: Final = "litellm.request.streaming"
+ TOOLS_DECLARED: Final = "litellm.request.tools.declared"
GUARDRAIL_NAME: Final = "litellm.guardrail.name"
GUARDRAIL_MODE: Final = "litellm.guardrail.mode"
GUARDRAIL_STATUS: Final = "litellm.guardrail.status"
diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_emitter.py b/tests/test_litellm/integrations/otel/test_otel_v2_emitter.py
index 6b1da4c2952..b1b1b62c820 100644
--- a/tests/test_litellm/integrations/otel/test_otel_v2_emitter.py
+++ b/tests/test_litellm/integrations/otel/test_otel_v2_emitter.py
@@ -17,6 +17,9 @@ from litellm.integrations.otel.plumbing import context as ctx_mod # noqa: E402
from litellm.integrations.otel.plumbing import providers # noqa: E402
from litellm.integrations.otel.emitter import SpanEmitter # noqa: E402
from litellm.integrations.otel.emitter import stamp_error # noqa: E402
+from litellm.integrations.otel.mappers.utils import ( # noqa: E402
+ MAX_TOOL_DEFINITION_ATTRS_PER_SPAN,
+)
from litellm.integrations.otel.model.payloads import ( # noqa: E402
GuardrailSpanData,
LLMCallSpanData,
@@ -305,3 +308,135 @@ def test_guardrail_success_span_is_unset():
)
(span,) = exporter.get_finished_spans()
assert span.status.status_code is StatusCode.UNSET
+
+
+def _tools_payload(count):
+ """A request declaring ``count`` tools, in the chat-completion shape."""
+ return _payload(
+ model_parameters={
+ "temperature": 0.7,
+ "tools": [
+ {
+ "type": "function",
+ "function": {
+ "name": f"tool_{i}",
+ "description": f"description for tool {i}",
+ "parameters": {"type": "object", "properties": {}},
+ },
+ }
+ for i in range(count)
+ ],
+ }
+ )
+
+
+def test_many_tools_do_not_evict_core_attributes():
+ """Tool definitions must never crowd core telemetry off the span.
+
+ An agentic client declares hundreds of tools. Spelling each one out as
+ per-index attributes overruns the OTel SDK's 128-attribute span limit,
+ which evicts oldest-first and so destroys the ``gen_ai.*`` attributes
+ written before it. Capping the tool family keeps the core intact.
+ """
+ engine, exporter = _engine()
+ data = LLMCallSpanData.from_standard_logging_payload(_tools_payload(127))
+ engine.emit(SpanRole.LLM_CALL, data)
+ (span,) = exporter.get_finished_spans()
+ a = span.attributes
+
+ assert a[GenAI.REQUEST_MODEL] == "gpt-4o"
+ assert a[GenAI.PROVIDER_NAME] == "openai"
+ assert a[GenAI.USAGE_INPUT_TOKENS] == 10
+ assert a[GenAI.USAGE_OUTPUT_TOKENS] == 5
+ assert a[GenAI.RESPONSE_FINISH_REASONS] == ("stop",)
+ assert a[f"{LiteLLM.COST_PREFIX}total"] == 0.002
+ assert a["gen_ai.usage.prompt_tokens"] == 10
+
+ assert span.dropped_attributes == 0
+ assert a[LiteLLM.TOOLS_DECLARED] == 127
+ assert a["gen_ai.tool.0.name"] == "tool_0"
+ assert "gen_ai.tool.126.name" not in a
+ assert "llm.request.functions.126.name" not in a
+
+
+def test_tool_definitions_kept_in_full_below_the_cap():
+ """A handful of tools keeps full per-index detail in both vocabularies."""
+ engine, exporter = _engine()
+ data = LLMCallSpanData.from_standard_logging_payload(_tools_payload(3))
+ engine.emit(SpanRole.LLM_CALL, data)
+ (span,) = exporter.get_finished_spans()
+ a = span.attributes
+
+ assert a[LiteLLM.TOOLS_DECLARED] == 3
+ for idx in range(3):
+ assert a[f"gen_ai.tool.{idx}.name"] == f"tool_{idx}"
+ assert a[f"gen_ai.tool.{idx}.description"] == f"description for tool {idx}"
+ assert a[f"gen_ai.tool.{idx}.parameters"]
+ assert a[f"llm.request.functions.{idx}.name"] == f"tool_{idx}"
+
+
+def _tool_span(mapper_names, tool_count):
+ """The exported LLM-call span for ``mapper_names`` and ``tool_count`` tools."""
+ cfg = OpenTelemetryV2Config(
+ exporter="in_memory",
+ legacy_compat=True,
+ mapper_names=list(mapper_names),
+ )
+ provider, exporter = providers.in_memory_provider(cfg)
+ engine = SpanEmitter(providers.get_tracer(provider, "litellm-test"), cfg)
+ engine.emit(
+ SpanRole.LLM_CALL,
+ LLMCallSpanData.from_standard_logging_payload(_tools_payload(tool_count)),
+ )
+ (span,) = exporter.get_finished_spans()
+ return span
+
+
+def _tool_definition_keys(attributes):
+ return [
+ key
+ for key in attributes
+ if key.startswith(("gen_ai.tool.", "llm.request.functions.", "llm.tools."))
+ ]
+
+
+@pytest.mark.parametrize(
+ "mapper_names",
+ [
+ ["genai"],
+ ["genai", "openinference"],
+ ["genai", "openinference", "langfuse", "weave", "langtrace"],
+ ],
+)
+def test_tool_definitions_stay_within_one_span_wide_budget(mapper_names):
+ """Every supported composition has to leave core telemetry on the span.
+
+ Each vocabulary spells the same tools out under its own keys, so an
+ allowance handed to each mapper separately multiplies by the number of
+ configured vocabularies and reaches the attribute limit again. Arize and
+ Phoenix already layer OpenInference on top of the default two, and every
+ vendor vocabulary can be listed at once. One budget shared across them all
+ is what keeps the total bounded.
+ """
+ span = _tool_span(mapper_names, 127)
+ a = span.attributes
+
+ assert span.dropped_attributes == 0
+ assert a[GenAI.REQUEST_MODEL] == "gpt-4o"
+ assert a[GenAI.PROVIDER_NAME] == "openai"
+ assert a[GenAI.USAGE_INPUT_TOKENS] == 10
+ assert a[GenAI.USAGE_OUTPUT_TOKENS] == 5
+ assert a[f"{LiteLLM.COST_PREFIX}total"] == 0.002
+ assert a[LiteLLM.TOOLS_DECLARED] == 127
+
+ emitted = _tool_definition_keys(a)
+ assert emitted, "some tool detail should survive in every composition"
+ assert len(emitted) <= MAX_TOOL_DEFINITION_ATTRS_PER_SPAN
+
+
+def test_vendor_tool_definitions_are_truncated_not_dropped():
+ """The OpenInference vocabulary keeps its leading tools and loses the tail."""
+ a = _tool_span(["genai", "openinference"], 127).attributes
+ assert a["llm.tools.0.tool.name"] == "tool_0"
+ assert a["llm.tools.0.tool.json_schema"]
+ assert "llm.tools.126.tool.name" not in a
From a187cb9886bdf40008362ba918a9600987e7dd5a Mon Sep 17 00:00:00 2001
From: Yassin Kortam
Date: Thu, 30 Jul 2026 12:06:33 -0700
Subject: [PATCH 15/33] feat(mcp): enforce per-user MCP tool-call entitlements
in the auth module (#35146)
The MCP gateway resolved a caller's allowed servers and per-server tool
allowlists from the key, the team, the end user and the agent, but never from
the internal user row, so an admin had no way to bound what a person may call
across every key they hold. Anything the key allowed went through
The internal user now carries the same object_permission an admin already
attaches to a key or a team, and the resolver applies it as a ceiling: the
caller ends up with the intersection of what the key allows and what the user
allows, so adding a user entitlement can only narrow, never widen. A level
that names no server and no tool places no ceiling, which keeps every existing
deployment on its current behavior
/user/new and /user/update accept object_permission and reuse the same
create-or-update helper the team endpoints use, so the row is written once and
the three cached views of it (the user row, the object-permission link and the
permission itself) are invalidated on write. Clearing it with an empty object
now really unlinks the permission instead of being swallowed as an empty value
A row that cannot be read at all places no ceiling, but a row that names a
permission the database cannot return denies the call rather than falling
through to the wider set, so a partial outage cannot hand out access the admin
withheld
The users page grows the MCP servers, access groups, toolsets and per-server
tool pickers the key and team pages already have. A save keeps a tool
allowlist whenever an access group or toolset the admin retained could still
supply that server, since an allowlist is what narrows a grant and an absent
one reads as no restriction; it drops the allowlist once nothing indirect
survives to supply the server, so removing a grant really removes it
---
.../mcp_server/auth/user_api_key_auth_mcp.py | 231 +++++++++++-
.../mcp_server/mcp_server_manager.py | 5 +
litellm/proxy/_types.py | 1 +
litellm/proxy/auth/auth_checks.py | 3 +-
.../proxy/common_utils/user_api_key_cache.py | 22 ++
.../internal_user_endpoints.py | 105 +++++-
.../auth/test_user_api_key_auth_mcp.py | 343 ++++++++++++++++++
.../test_internal_user_endpoints.py | 330 +++++++++++++++++
.../users/_components/user_edit_view.tsx | 66 +++-
.../view_users/user_info_view.test.tsx | 253 ++++++++++++-
.../_components/view_users/user_info_view.tsx | 101 +++++-
.../src/components/networking.tsx | 1 +
.../permissions/MCPServerPermissions.tsx | 2 +-
ui/litellm-dashboard/src/lib/http/schema.d.ts | 5 +-
14 files changed, 1451 insertions(+), 17 deletions(-)
diff --git a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py
index a27d6b92843..423cda5eea2 100644
--- a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py
+++ b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py
@@ -1,6 +1,6 @@
import re
from datetime import datetime, timezone
-from typing import Dict, List, Optional, Set, Tuple, cast
+from typing import TYPE_CHECKING, Dict, List, Optional, Sequence, Set, Tuple, cast
from fastapi import HTTPException
from starlette.datastructures import Headers
@@ -30,6 +30,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.session_credent
)
from litellm.proxy._types import (
UI_TEAM_ID,
+ LiteLLM_ObjectPermissionTable,
LiteLLM_TeamTable,
ProxyException,
SpecialHeaders,
@@ -43,13 +44,27 @@ from litellm.proxy.auth.user_api_key_auth import (
user_api_key_auth,
)
from litellm.proxy.common_utils.http_parsing_utils import _read_request_body
-from litellm.proxy.common_utils.user_api_key_cache import get_management_object_ttl
+from litellm.proxy.common_utils.user_api_key_cache import (
+ USER_NO_MCP_PERMISSION_SENTINEL,
+ get_management_object_ttl,
+ user_object_permission_id_cache_key,
+)
from litellm.repositories.table_repositories import (
AgentsRepository,
MCPServerRepository,
)
+from litellm.repositories.user_repository import UserRepository
from litellm.types.mcp_server.mcp_server_manager import MCPServer
+if TYPE_CHECKING:
+ from litellm.proxy.utils import PrismaClient
+
+
+def _as_list(values: Sequence[str] | None) -> list[str] | None: # mutable-ok: resolver returns a list
+ """Widen a read-only allowlist back to the mutable list the resolver's own contract returns,
+ preserving the ``None`` that means "no restriction"."""
+ return None if values is None else list(values)
+
def _parse_mcp_server_names_from_path(path: str, mcp_servers_header: Optional[List[str]] = None) -> Optional[List[str]]:
"""Resolve the single MCP server name a cold-start passthrough bypass may
@@ -1408,6 +1423,15 @@ class MCPRequestHandler:
f"Applied agent intersection filter. Final allowed servers: {allowed_mcp_servers}"
)
+ #########################################################
+ # Apply the internal user's own ceiling (the entitlement attached to the human)
+ #########################################################
+ capped, user_restricts = await MCPRequestHandler._apply_user_server_ceiling(
+ allowed_mcp_servers, user_api_key_auth, keyless_source=keyless_source
+ )
+ allowed_mcp_servers = list(capped)
+ has_lower_level_mcp_restrictions = has_lower_level_mcp_restrictions or user_restricts
+
#########################################################
# Apply org-level ceiling if org_id is set
#########################################################
@@ -1831,6 +1855,12 @@ class MCPRequestHandler:
# No team restrictions → use key restrictions
allowed_tools = cast(List[str], key_tools)
+ allowed_tools = _as_list(
+ await MCPRequestHandler._apply_user_tool_ceiling(
+ allowed_tools, server_id, user_api_key_auth, keyless_source=keyless_source
+ )
+ )
+
return await MCPRequestHandler._apply_agent_and_org_tool_ceilings(
allowed_tools, server_id, user_api_key_auth, keyless_source=keyless_source
)
@@ -2376,6 +2406,203 @@ class MCPRequestHandler:
verbose_logger.warning(f"Failed to get allowed MCP servers for end_user: {str(e)}")
return []
+ @staticmethod
+ async def _get_user_object_permission(
+ user_api_key_auth: UserAPIKeyAuth | None = None,
+ ) -> LiteLLM_ObjectPermissionTable | None:
+ """The internal user's OWN object_permission: the entitlement attached to the HUMAN rather
+ than to the credential they authenticated with.
+
+ A key's object_permission is the credential's scope and a team's is the group's; this one
+ answers "which MCP servers and tools is this person entitled to", independent of how many keys
+ they hold. Caches the ``user_id -> object_permission_id`` mapping (with a sentinel for "no
+ entitlement") exactly as the agent path does, then reuses the shared ``object_permission_id``
+ cache, so a warm request reads no rows.
+
+ ``None`` means the human places NO ceiling: no user row, or a row naming no permission. The
+ two fault classes are deliberately NOT collapsed into that: a user row we cannot read leaves
+ us unable to say whether they are entitled at all, which is exactly the state before this
+ level existed, so it places no ceiling; a row that NAMES a permission we cannot read is a
+ KNOWN entitlement with unknown contents, so it raises and the caller denies.
+ """
+ from litellm.proxy.auth.auth_checks import get_object_permission
+ from litellm.proxy.proxy_server import (
+ prisma_client,
+ proxy_logging_obj,
+ user_api_key_cache,
+ )
+
+ if not user_api_key_auth or not user_api_key_auth.user_id:
+ return None
+
+ if prisma_client is None:
+ verbose_logger.debug("prisma_client is None")
+ return None
+
+ user_id = user_api_key_auth.user_id
+ object_permission_id = await MCPRequestHandler._user_object_permission_id(user_id, prisma_client)
+ if object_permission_id is None:
+ return None
+
+ object_permission = await get_object_permission(
+ object_permission_id=object_permission_id,
+ prisma_client=prisma_client,
+ user_api_key_cache=user_api_key_cache,
+ parent_otel_span=user_api_key_auth.parent_otel_span,
+ proxy_logging_obj=proxy_logging_obj,
+ )
+ if object_permission is None:
+ raise ValueError(
+ f"user {user_id!r} names object_permission_id {object_permission_id!r} which could not be loaded"
+ )
+ return object_permission
+
+ @staticmethod
+ async def _user_object_permission_id(user_id: str, prisma_client: "PrismaClient") -> str | None:
+ """The permission row this human's user row links to, or None when they link none.
+
+ Caches the link (with a sentinel for "links none") so a human without an entitlement costs no
+ DB read per MCP request. Anything other than an id string is treated as a cache MISS rather
+ than carried into the permission lookup, and a read that fails answers None: not knowing
+ whether someone is entitled is the state that existed before this level, so it places no
+ ceiling. Only a link we DID resolve can make the caller deny.
+ """
+ from litellm.proxy.proxy_server import user_api_key_cache
+
+ cache_key = user_object_permission_id_cache_key(user_id)
+ try:
+ cached: object = await user_api_key_cache.async_get_cache(key=cache_key)
+ if cached == USER_NO_MCP_PERMISSION_SENTINEL:
+ return None
+ if isinstance(cached, str) and cached:
+ return cached
+ user_row = await UserRepository(prisma_client).table.find_unique(where={"user_id": user_id})
+ linked: object = getattr(user_row, "object_permission_id", None) if user_row is not None else None
+ object_permission_id = linked if isinstance(linked, str) and linked else None
+ await user_api_key_cache.async_set_cache(
+ key=cache_key,
+ value=object_permission_id or USER_NO_MCP_PERMISSION_SENTINEL,
+ ttl=get_management_object_ttl(user_api_key_cache),
+ )
+ return object_permission_id
+ except Exception as e: # noqa: BLE001 # unknown whether entitled at all: no ceiling, as before
+ verbose_logger.warning(f"MCP user entitlement: link for {user_id!r} unresolved, no ceiling: {str(e)}")
+ return None
+
+ @staticmethod
+ async def _get_allowed_mcp_servers_for_user(
+ user_api_key_auth: UserAPIKeyAuth | None = None,
+ ) -> Sequence[str] | None:
+ """The MCP servers the internal user is entitled to, as server ids.
+
+ ``[]`` means this human places no restriction (allow-all from this level); ``None`` means the
+ ceiling is UNRESOLVED, which the caller denies on. Servers named only under
+ ``mcp_tool_permissions`` count as entitled, exactly as they do for a key or a team, so
+ granting one tool never requires naming its server twice.
+ """
+ from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
+ global_mcp_server_manager,
+ )
+
+ try:
+ object_permissions = await MCPRequestHandler._get_user_object_permission(user_api_key_auth)
+ if object_permissions is None:
+ return []
+
+ direct_mcp_servers = global_mcp_server_manager.expand_permission_list(object_permissions.mcp_servers or [])
+ access_group_servers = await MCPRequestHandler._get_mcp_servers_from_access_groups(
+ object_permissions.mcp_access_groups or []
+ )
+ tool_perm_servers = list(
+ global_mcp_server_manager.expand_tool_permissions(object_permissions.mcp_tool_permissions).keys()
+ )
+ return list(set(direct_mcp_servers + access_group_servers + tool_perm_servers))
+ except Exception as e: # noqa: BLE001 # any resolution fault is an unresolved ceiling, never "no ceiling"
+ verbose_logger.warning(f"Failed to get allowed MCP servers for user: {str(e)}")
+ return None
+
+ @staticmethod
+ async def _apply_user_server_ceiling(
+ allowed_mcp_servers: Sequence[str],
+ user_api_key_auth: UserAPIKeyAuth | None = None,
+ *,
+ keyless_source: bool = False,
+ ) -> tuple[tuple[str, ...], bool]:
+ """Narrow a resolved server list by the internal user's own entitlement.
+
+ Returns the capped list and whether this human restricted it at all; the caller needs the
+ second value because an org list may only CAP a lower-level restriction, never replace one, so
+ a user ceiling has to be visible to the org step.
+
+ RAISES when the entitlement is known but unreadable, which the resolver's own handler turns
+ into deny-all. That is the point of the level: dropping a ceiling we know exists is exactly the
+ silent widening it is there to prevent.
+ """
+ if keyless_source:
+ return tuple(allowed_mcp_servers), False
+ entitled = await MCPRequestHandler._get_allowed_mcp_servers_for_user(user_api_key_auth)
+ if entitled is None:
+ raise ValueError(
+ f"MCP user ceiling unresolvable for user_id="
+ f"{user_api_key_auth.user_id if user_api_key_auth else None!r}"
+ )
+ if not entitled:
+ return tuple(allowed_mcp_servers), False
+ capped = tuple(server for server in allowed_mcp_servers if server in set(entitled))
+ verbose_logger.debug(f"Applied user ceiling filter. Final allowed servers: {capped}")
+ return capped, True
+
+ @staticmethod
+ async def _user_places_mcp_ceiling(user_api_key_auth: UserAPIKeyAuth | None = None) -> bool:
+ """Whether this human's own entitlement bounds their MCP access at all.
+
+ True when they are entitled to a specific set of servers, and also when that entitlement is
+ UNRESOLVED — a caller uses this to decide whether it may skip the resolver, and skipping it on
+ a transient fault would widen access.
+ """
+ entitled_servers = await MCPRequestHandler._get_allowed_mcp_servers_for_user(user_api_key_auth)
+ return entitled_servers is None or len(entitled_servers) > 0
+
+ @staticmethod
+ async def _apply_user_tool_ceiling(
+ allowed_tools: Sequence[str] | None,
+ server_id: str,
+ user_api_key_auth: UserAPIKeyAuth | None = None,
+ *,
+ keyless_source: bool = False,
+ ) -> Sequence[str] | None:
+ """Narrow a key/team tool allowlist by the internal user's own tool entitlement.
+
+ The human's entitlement can only ever narrow: a user naming tools on ``server_id`` intersects
+ (and becomes the allowlist when no lower level restricts), while a user naming none places no
+ restriction. Returns ``[]`` (deny every tool on this server) when the entitlement cannot be
+ resolved, because the caller's own except-handler treats a raise as allow-all for key auth.
+ """
+ from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
+ global_mcp_server_manager,
+ )
+
+ if keyless_source:
+ return allowed_tools
+
+ try:
+ object_permissions = await MCPRequestHandler._get_user_object_permission(user_api_key_auth)
+ except Exception as e: # noqa: BLE001 # an unresolved human entitlement must deny, not widen
+ verbose_logger.warning(f"MCP user tool ceiling unresolvable, denying tools on {server_id!r}: {str(e)}")
+ return []
+
+ if object_permissions is None or not object_permissions.mcp_tool_permissions:
+ return allowed_tools
+
+ user_tools = global_mcp_server_manager.expand_tool_permissions(object_permissions.mcp_tool_permissions).get(
+ server_id
+ )
+ if user_tools is None:
+ return allowed_tools
+ if allowed_tools is None:
+ return list(user_tools)
+ return list(set(allowed_tools) & set(user_tools))
+
# Sentinel stored in cache when an agent has no object_permission, so we
# don't re-query the DB on every MCP request for that agent.
_AGENT_NO_PERMISSION_SENTINEL = "__agent_no_mcp_permission__"
diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
index f61ac4866b0..ae1095da336 100644
--- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
+++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
@@ -2411,6 +2411,11 @@ class MCPServerManager:
and not is_admitted_subject
and _user_has_admin_view(user_api_key_auth)
and not has_explicit_object_permission
+ # An entitlement attached to the HUMAN binds them whatever their role: it is the
+ # person's scope, not the credential's, so an admin role is not a waiver of it. An
+ # UNRESOLVED entitlement also skips the shortcut, so the resolver denies rather than
+ # handing over the whole registry on a transient fault.
+ and not await MCPRequestHandler._user_places_mcp_ceiling(user_api_key_auth)
):
verbose_logger.debug("Admin user without explicit object_permission - returning all servers")
return list(self.get_registry().keys())
diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py
index bfdc954c9fe..dc84a4ca705 100644
--- a/litellm/proxy/_types.py
+++ b/litellm/proxy/_types.py
@@ -2783,6 +2783,7 @@ class UserInfoV2Response(LiteLLMPydanticObjectBase):
updated_at: Optional[datetime] = None
sso_user_id: Optional[str] = None
teams: List[str] = [] # Just team IDs, not full team objects
+ object_permission: LiteLLM_ObjectPermissionTable | None = None
from litellm.models.config import LiteLLM_Config as LiteLLM_Config # noqa: E402
diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py
index 9d023292074..03e5e80288e 100644
--- a/litellm/proxy/auth/auth_checks.py
+++ b/litellm/proxy/auth/auth_checks.py
@@ -74,6 +74,7 @@ from litellm.proxy.common_utils.http_parsing_utils import (
from litellm.proxy.common_utils.user_api_key_cache import (
UserApiKeyCache,
get_management_object_ttl,
+ object_permission_cache_key,
)
from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler
from litellm.proxy.guardrails.tool_name_extraction import (
@@ -2609,7 +2610,7 @@ async def get_object_permission(
raise Exception("No DB Connected. See - https://docs.litellm.ai/docs/proxy/virtual_keys")
# check if in cache
- key = "object_permission_id:{}".format(object_permission_id)
+ key = object_permission_cache_key(object_permission_id)
deserialized_perm = await user_api_key_cache.async_get_cache(
key=key,
model_type=LiteLLM_ObjectPermissionTable,
diff --git a/litellm/proxy/common_utils/user_api_key_cache.py b/litellm/proxy/common_utils/user_api_key_cache.py
index 09921a3ac1d..dbb5b2c24d0 100644
--- a/litellm/proxy/common_utils/user_api_key_cache.py
+++ b/litellm/proxy/common_utils/user_api_key_cache.py
@@ -150,6 +150,28 @@ class UserApiKeyCache(DualCache):
return await super().async_set_cache_pipeline(cache_list=normalized, local_only=local_only, **kwargs)
+#: Value cached under ``user_object_permission_id_cache_key`` when the user links no permission row,
+#: so a human without an entitlement costs no DB read per request. Lives beside the key builder
+#: because it is part of the same cache protocol: a reader that knows the key must know this value.
+USER_NO_MCP_PERMISSION_SENTINEL = "__user_no_mcp_permission__"
+
+
+def user_object_permission_id_cache_key(user_id: str) -> str:
+ """Cache key for the ``user_id -> object_permission_id`` link.
+
+ Lives here rather than next to either user because two modules own the two halves: the MCP auth
+ resolver writes it on read, and ``/user/update`` deletes it after changing the link. A key format
+ duplicated across those two drifts silently, and the failure is an entitlement change that never
+ takes effect.
+ """
+ return f"user_object_permission_id:{user_id}"
+
+
+def object_permission_cache_key(object_permission_id: str) -> str:
+ """Cache key ``get_object_permission`` stores a permission row under."""
+ return f"object_permission_id:{object_permission_id}"
+
+
def get_management_object_ttl(cache: DualCache) -> float:
"""
In-memory TTL for management-object cache writes (keys, teams, users, budgets, ...).
diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py
index 1bd0a19bfb3..80d9ee21a44 100644
--- a/litellm/proxy/management_endpoints/internal_user_endpoints.py
+++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py
@@ -45,6 +45,14 @@ from litellm.proxy.management_endpoints.key_management_endpoints import (
generate_key_helper_fn,
prepare_metadata_fields,
)
+from litellm.proxy.common_utils.user_api_key_cache import (
+ object_permission_cache_key,
+ user_object_permission_id_cache_key,
+)
+from litellm.proxy.management_helpers.object_permission_utils import (
+ _set_object_permission,
+ handle_update_object_permission_common,
+)
from litellm.proxy.management_helpers.utils import management_endpoint_wrapper
from litellm.proxy.utils import handle_exception_on_proxy, hash_password
from litellm.repositories.organization_repository import OrganizationRepository
@@ -401,7 +409,7 @@ async def new_user(
- duration: Optional[str] - Duration for the key auto-created on `/user/new`. Default is None.
- key_alias: Optional[str] - Alias for the key auto-created on `/user/new`. Default is None.
- sso_user_id: Optional[str] - The id of the user in the SSO provider.
- - object_permission: Optional[LiteLLM_ObjectPermissionBase] - internal user-specific object permission. Example - {"vector_stores": ["vector_store_1", "vector_store_2"]}. IF null or {} then no object permission.
+ - object_permission: Optional[LiteLLM_ObjectPermissionBase] - internal user-specific object permission. Example - {"vector_stores": ["vector_store_1"], "mcp_servers": ["github"], "mcp_tool_permissions": {"github": ["list_issues"]}}. The MCP grants act as a ceiling on every key this user holds. IF null or {} then no object permission.
- prompts: Optional[List[str]] - List of allowed prompts for the user. If specified, the user will only be able to use these specific prompts.
- organizations: List[str] - List of organization id's the user is a member of
- budget_limits: Optional[list] - List of concurrent budget windows for the user. Each window specifies a budget_limit, time_period, and optional budget_duration. Example - [{"budget_limit": 10.0, "time_period": "1d"}, {"budget_limit": 50.0, "time_period": "7d"}].
@@ -466,6 +474,10 @@ async def new_user(
data_json = data.json() # type: ignore
data_json = _update_internal_new_user_params(data_json, data)
+ # Persist the requested grants as their own row and link it, mirroring key/team creation.
+ # generate_key_helper_fn only forwards object_permission_id, so without this the entitlement
+ # the caller sent would be dropped on the floor.
+ data_json = await _set_object_permission(data_json=data_json, prisma_client=prisma_client)
_hash_password_in_dict(data_json)
teams = data.teams
if teams is None:
@@ -852,9 +864,12 @@ async def _check_user_info_v2_access(
if prisma_client is None:
return None
- # Helper: fetch the target user row (reused across branches)
+ # Helper: fetch the target user row (reused across branches). object_permission is included so
+ # callers can read the user's MCP/vector-store entitlements without a second round trip.
async def _fetch_target_user():
- return await UserRepository(prisma_client).table.find_unique(where={"user_id": target_user_id})
+ return await UserRepository(prisma_client).table.find_unique(
+ where={"user_id": target_user_id}, include={"object_permission": True}
+ )
# Rule 1: Proxy admins — fetch and return the target row directly
if _user_has_admin_view(user_api_key_dict):
@@ -972,6 +987,7 @@ async def user_info_v2(
updated_at=user_data.get("updated_at"),
sso_user_id=user_data.get("sso_user_id"),
teams=user_data.get("teams") or [],
+ object_permission=user_data.get("object_permission"),
)
except Exception as e:
verbose_proxy_logger.exception(
@@ -1207,6 +1223,48 @@ async def _invalidate_user_spend_counter_if_changed(
await _invalidate_spend_counter(counter_key=f"spend:user:{non_default_values['user_id']}")
+def _clears_object_permission(user_request: UpdateUserRequest) -> bool:
+ """Whether the caller explicitly asked to remove this user's object_permission.
+
+ Distinguishes "sent nothing" from "sent an empty grant set". Only the latter clears; an omitted
+ field must leave an existing entitlement alone.
+ """
+ if "object_permission" not in (user_request.fields_set() if hasattr(user_request, "fields_set") else set()):
+ return False
+ sent = user_request.object_permission
+ return sent is None or not sent.model_dump(exclude_unset=True, exclude_none=True)
+
+
+async def _invalidate_cached_user_entitlement(user_id: str | None, object_permission_ids: tuple[str, ...]) -> None:
+ """Drop the cache entries an entitlement change makes stale.
+
+ All three kinds are needed: a permission row is cached under its own id (so re-reading the same
+ link still yields the OLD grants), the ``user_id -> object_permission_id`` link is cached
+ separately (so a user who previously had NO entitlement keeps its "none" sentinel), and the user
+ row itself is cached whole. Leaving any behind means an admin revoking a tool keeps serving it
+ until the management-object TTL expires.
+
+ Both the outgoing and incoming permission ids are passed, because a clear leaves no incoming id
+ at all and an upsert may mint a new row; invalidating only one of the two leaves the other's
+ grants live.
+
+ Each deletion is isolated: one that fails must not skip the others, or a single unreachable key
+ would silently leave the rest of a revocation in place. Best-effort overall, exactly as the caches
+ are everywhere else, since one we cannot clear still expires on its own.
+ """
+ from litellm.proxy.proxy_server import user_api_key_cache
+
+ keys = (
+ *(object_permission_cache_key(permission_id) for permission_id in dict.fromkeys(object_permission_ids)),
+ *((user_object_permission_id_cache_key(user_id), user_id) if user_id is not None else ()),
+ )
+ for key in keys:
+ try:
+ await user_api_key_cache.async_delete_cache(key=key)
+ except Exception as e: # noqa: BLE001 # a cache we cannot clear still expires; never fail the write
+ verbose_proxy_logger.warning(f"Failed to invalidate cached entitlement key {key!r}: {str(e)}")
+
+
async def _update_single_user_helper(
user_request: UpdateUserRequest,
user_api_key_dict: UserAPIKeyAuth,
@@ -1259,9 +1317,15 @@ async def _update_single_user_helper(
)
_is_self_update = _target_user_id is not None and user_api_key_dict.user_id == _target_user_id
if _is_self_update and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value:
- _protected_fields = ("max_budget", "soft_budget", "spend")
+ # object_permission is a CEILING on what this human may reach, so a self-write is an
+ # escalation path: sending an empty grant list means "no restriction" and would lift a
+ # restriction an admin placed on them. Checked against the fields the caller actually SENT,
+ # because `_update_internal_user_params` drops empty values, and `object_permission: {}` is
+ # precisely the clear-my-own-ceiling case this must refuse.
+ _sent_fields = user_request.fields_set() if hasattr(user_request, "fields_set") else set()
+ _protected_fields = ("max_budget", "soft_budget", "spend", "object_permission")
for _field in _protected_fields:
- if _field in non_default_values:
+ if _field in non_default_values or _field in _sent_fields:
raise HTTPException(
status_code=403,
detail={
@@ -1282,6 +1346,22 @@ async def _update_single_user_helper(
# Reject NaN/±inf spend before it can reach the DB / spend counter.
validate_finite_spend(non_default_values.get("spend"))
+ # Upsert the grants into their own row and link it, mirroring /key/update and /team/update.
+ # This also removes object_permission from the payload, which is not a column on the user table.
+ if "object_permission" in non_default_values:
+ object_permission_id = await handle_update_object_permission_common(
+ data_json=non_default_values,
+ existing_object_permission_id=getattr(existing_user_row, "object_permission_id", None),
+ prisma_client=prisma_client,
+ )
+ if object_permission_id is not None:
+ non_default_values["object_permission_id"] = object_permission_id
+ elif _clears_object_permission(user_request):
+ # An explicit `{}` or null means "no object permission", which the merge-based upsert cannot
+ # express: merging an empty grant set over the existing row leaves every grant in place. So
+ # the link is dropped instead, which is what makes the documented clear actually clear.
+ non_default_values["object_permission_id"] = None
+
# Perform the update
response: dict[str, Any] | None = None
@@ -1326,6 +1406,19 @@ async def _update_single_user_helper(
await _invalidate_user_spend_counter_if_changed(non_default_values)
+ if "object_permission_id" in non_default_values:
+ await _invalidate_cached_user_entitlement(
+ user_id=non_default_values.get("user_id"),
+ object_permission_ids=tuple(
+ permission_id
+ for permission_id in (
+ getattr(existing_user_row, "object_permission_id", None),
+ non_default_values.get("object_permission_id"),
+ )
+ if isinstance(permission_id, str)
+ ),
+ )
+
if response is None:
raise HTTPException(
status_code=400,
@@ -1407,7 +1500,7 @@ async def user_update(
- team_id: Optional[str] - [DEPRECATED PARAM] The team id of the user. Default is None.
- duration: Optional[str] - [NOT IMPLEMENTED].
- key_alias: Optional[str] - [NOT IMPLEMENTED].
- - object_permission: Optional[LiteLLM_ObjectPermissionBase] - internal user-specific object permission. Example - {"vector_stores": ["vector_store_1", "vector_store_2"]}. IF null or {} then no object permission.
+ - object_permission: Optional[LiteLLM_ObjectPermissionBase] - internal user-specific object permission. Example - {"vector_stores": ["vector_store_1"], "mcp_servers": ["github"], "mcp_tool_permissions": {"github": ["list_issues"]}}. The MCP grants act as a ceiling on every key this user holds. IF null or {} then no object permission.
- prompts: Optional[List[str]] - List of allowed prompts for the user. If specified, the user will only be able to use these specific prompts.
- budget_limits: Optional[list] - List of concurrent budget windows for the user. Each window specifies a budget_limit, time_period, and optional budget_duration. Example - [{"budget_limit": 10.0, "time_period": "1d"}, {"budget_limit": 50.0, "time_period": "7d"}].
diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py
index b3c0dcd1681..4be2bb053ef 100644
--- a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py
+++ b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py
@@ -7645,3 +7645,346 @@ class TestSessionBearerEgressScrub:
assert oauth2 is None
assert "authorization" not in {k.lower() for k in raw}
assert per_server == {"github": {"Authorization": "Bearer gh_injected_upstream"}}
+
+
+# ---------------------------------------------------------------------------
+# Internal-user (human) MCP entitlement tests
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+class TestUserMCPEntitlement:
+ """The entitlement attached to the HUMAN, read at both list time and tool-call time.
+
+ A key's object_permission scopes the credential and a team's scopes the group; the user's own
+ scopes the person, so it must cap every key they hold and every tool those keys may invoke.
+ """
+
+ def _auth(self, user_id: str = "human-1", **kwargs) -> UserAPIKeyAuth:
+ return UserAPIKeyAuth(api_key="sk-test", user_id=user_id, **kwargs)
+
+ def _perm(self, *, servers=None, access_groups=None, tool_permissions=None) -> LiteLLM_ObjectPermissionTable:
+ return LiteLLM_ObjectPermissionTable(
+ object_permission_id="perm-human-1",
+ mcp_servers=servers if servers is not None else [],
+ mcp_access_groups=access_groups if access_groups is not None else [],
+ mcp_tool_permissions=tool_permissions,
+ )
+
+ @contextlib.contextmanager
+ def _entitled(self, perm):
+ """Patch the human's entitlement lookup. ``perm`` may be a permission row, None, or an
+ exception instance to raise (an entitlement that cannot be resolved)."""
+ side_effect = perm if isinstance(perm, Exception) else None
+ with patch.object(
+ MCPRequestHandler,
+ "_get_user_object_permission",
+ new_callable=AsyncMock,
+ return_value=None if side_effect else perm,
+ side_effect=side_effect,
+ ) as patched:
+ yield patched
+
+ @contextlib.contextmanager
+ def _key_and_team_servers(self, key_servers, team_servers):
+ with (
+ patch.object(
+ MCPRequestHandler,
+ "_get_allowed_mcp_servers_for_key",
+ new_callable=AsyncMock,
+ return_value=key_servers,
+ ),
+ patch.object(
+ MCPRequestHandler,
+ "_get_allowed_mcp_servers_for_team",
+ new_callable=AsyncMock,
+ return_value=team_servers,
+ ),
+ patch.object(
+ MCPRequestHandler,
+ "_get_mcp_servers_from_access_groups",
+ new_callable=AsyncMock,
+ return_value=[],
+ ),
+ patch.object(
+ MCPRequestHandler,
+ "_get_key_access_group_mcp_server_extras",
+ new_callable=AsyncMock,
+ return_value=[],
+ ),
+ ):
+ yield
+
+ async def test_entitlement_caps_the_servers_the_key_reaches(self):
+ """The key grants two servers; the human is entitled to one, so only that one resolves."""
+ with self._key_and_team_servers(["srv-a", "srv-b"], []):
+ with self._entitled(self._perm(servers=["srv-a"])):
+ result = await MCPRequestHandler.get_allowed_mcp_servers(self._auth())
+ assert result == ["srv-a"]
+
+ async def test_entitlement_never_widens_the_key(self):
+ """A human entitled to a server their key does not grant still cannot reach it: the level is a
+ ceiling, so it intersects rather than unions."""
+ with self._key_and_team_servers(["srv-a"], []):
+ with self._entitled(self._perm(servers=["srv-a", "srv-elsewhere"])):
+ result = await MCPRequestHandler.get_allowed_mcp_servers(self._auth())
+ assert result == ["srv-a"]
+
+ async def test_no_entitlement_places_no_ceiling(self):
+ """A human with no entitlement row leaves the key/team result untouched."""
+ with self._key_and_team_servers(["srv-a", "srv-b"], []):
+ with self._entitled(None):
+ result = await MCPRequestHandler.get_allowed_mcp_servers(self._auth())
+ assert sorted(result) == ["srv-a", "srv-b"]
+
+ async def test_unresolvable_entitlement_denies_every_server(self):
+ """A KNOWN entitlement whose contents cannot be read must deny, not fall back to the key's
+ wider scope."""
+ with self._key_and_team_servers(["srv-a", "srv-b"], []):
+ with self._entitled(ValueError("permission row unreadable")):
+ result = await MCPRequestHandler.get_allowed_mcp_servers(self._auth())
+ assert result == []
+
+ async def test_entitlement_caps_the_tools_the_key_reaches(self):
+ """Tool-level: the key allows three tools on the server, the human is entitled to one."""
+ key_perm = self._perm(tool_permissions={"srv-a": ["read", "write", "delete"]})
+ with patch.object(MCPRequestHandler, "_get_key_object_permission", return_value=key_perm):
+ with patch.object(
+ MCPRequestHandler, "_get_team_object_permission", new_callable=AsyncMock, return_value=None
+ ):
+ with self._entitled(self._perm(tool_permissions={"srv-a": ["read"]})):
+ result = await MCPRequestHandler.get_allowed_tools_for_server("srv-a", self._auth())
+ assert result == ["read"]
+
+ async def test_entitlement_alone_restricts_tools_on_an_otherwise_unrestricted_key(self):
+ """An unrestricted key (no tool permissions of its own) is still bound by the human's tools."""
+ with patch.object(MCPRequestHandler, "_get_key_object_permission", return_value=None):
+ with patch.object(
+ MCPRequestHandler, "_get_team_object_permission", new_callable=AsyncMock, return_value=None
+ ):
+ with self._entitled(self._perm(tool_permissions={"srv-a": ["read"]})):
+ result = await MCPRequestHandler.get_allowed_tools_for_server("srv-a", self._auth())
+ assert result == ["read"]
+
+ async def test_entitlement_on_another_server_does_not_restrict_this_one(self):
+ """Tool grants are per server: naming tools on srv-b places no bound on srv-a."""
+ with patch.object(MCPRequestHandler, "_get_key_object_permission", return_value=None):
+ with patch.object(
+ MCPRequestHandler, "_get_team_object_permission", new_callable=AsyncMock, return_value=None
+ ):
+ with self._entitled(self._perm(tool_permissions={"srv-b": ["read"]})):
+ result = await MCPRequestHandler.get_allowed_tools_for_server("srv-a", self._auth())
+ assert result is None
+
+ async def test_unresolvable_entitlement_denies_every_tool(self):
+ """Fail closed on the tool axis too. The caller's own except-handler treats a raise as
+ allow-all for key auth, so the ceiling must return the empty allowlist itself."""
+ with patch.object(MCPRequestHandler, "_get_key_object_permission", return_value=None):
+ with patch.object(
+ MCPRequestHandler, "_get_team_object_permission", new_callable=AsyncMock, return_value=None
+ ):
+ with self._entitled(ValueError("permission row unreadable")):
+ result = await MCPRequestHandler.get_allowed_tools_for_server("srv-a", self._auth())
+ assert result == []
+
+ async def test_tool_call_is_rejected_at_call_time(self):
+ """The end-to-end contract: a tool the human is not entitled to is refused when INVOKED, not
+ merely hidden from the advertised list."""
+ from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
+ global_mcp_server_manager,
+ )
+ from litellm.types.mcp import MCPTransport
+ from litellm.types.mcp_server.mcp_server_manager import MCPServer
+
+ server = MCPServer(
+ server_id="srv-a",
+ name="srv-a",
+ server_name="srv-a",
+ url="https://srv-a.example.com",
+ transport=MCPTransport.http,
+ )
+ with patch.object(MCPRequestHandler, "_get_key_object_permission", return_value=None):
+ with patch.object(
+ MCPRequestHandler, "_get_team_object_permission", new_callable=AsyncMock, return_value=None
+ ):
+ with self._entitled(self._perm(tool_permissions={"srv-a": ["read"]})):
+ await global_mcp_server_manager.check_tool_permission_for_key_team(
+ tool_name="read", server=server, user_api_key_auth=self._auth()
+ )
+ with pytest.raises(HTTPException) as exc:
+ await global_mcp_server_manager.check_tool_permission_for_key_team(
+ tool_name="delete", server=server, user_api_key_auth=self._auth()
+ )
+ assert exc.value.status_code == 403
+
+ async def test_keyless_admitted_source_is_not_capped_by_the_user_level(self):
+ """A gateway-admitted human resolves as a UNION over their own grants plus their teams', and
+ their own grants ARE the user source there. Re-applying them as a ceiling per source would
+ make one team's narrower scope silently bound another's, so the level is skipped."""
+ with self._key_and_team_servers(["srv-a", "srv-b"], []):
+ with self._entitled(self._perm(servers=["srv-a"])) as lookup:
+ result = await MCPRequestHandler.get_allowed_mcp_servers(self._auth(), keyless_source=True)
+ assert sorted(result) == ["srv-a", "srv-b"]
+ lookup.assert_not_awaited()
+
+ async def test_keyless_admitted_source_tools_are_not_capped_by_the_user_level(self):
+ with patch.object(MCPRequestHandler, "_get_key_object_permission", return_value=None):
+ with patch.object(
+ MCPRequestHandler, "_get_team_object_permission", new_callable=AsyncMock, return_value=None
+ ):
+ with self._entitled(self._perm(tool_permissions={"srv-a": ["read"]})) as lookup:
+ result = await MCPRequestHandler.get_allowed_tools_for_server(
+ "srv-a", self._auth(), keyless_source=True
+ )
+ assert result is None
+ lookup.assert_not_awaited()
+
+ async def test_servers_named_only_under_tool_permissions_are_entitled(self):
+ """Granting one tool on a server entitles the human to that server, so an admin never has to
+ name it twice."""
+ from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
+ global_mcp_server_manager,
+ )
+ from litellm.types.mcp import MCPTransport
+ from litellm.types.mcp_server.mcp_server_manager import MCPServer
+
+ global_mcp_server_manager.registry["srv-a"] = MCPServer(
+ server_id="srv-a",
+ name="srv-a",
+ server_name="srv-a",
+ url="https://srv-a.example.com",
+ transport=MCPTransport.http,
+ )
+ try:
+ with self._entitled(self._perm(tool_permissions={"srv-a": ["read"]})):
+ with patch.object(
+ MCPRequestHandler,
+ "_get_mcp_servers_from_access_groups",
+ new_callable=AsyncMock,
+ return_value=[],
+ ):
+ result = await MCPRequestHandler._get_allowed_mcp_servers_for_user(self._auth())
+ finally:
+ global_mcp_server_manager.registry.pop("srv-a", None)
+ assert result == ["srv-a"]
+
+ async def test_places_ceiling_is_true_when_unresolvable(self):
+ """``_user_places_mcp_ceiling`` gates the admin shortcut that hands over the whole registry, so
+ an entitlement it cannot resolve must still count as a ceiling."""
+ with self._entitled(ValueError("boom")):
+ assert await MCPRequestHandler._user_places_mcp_ceiling(self._auth()) is True
+ with self._entitled(None):
+ assert await MCPRequestHandler._user_places_mcp_ceiling(self._auth()) is False
+
+
+@pytest.mark.asyncio
+class TestGetUserObjectPermission:
+ """Resolution of the ``user_id -> object_permission_id -> grants`` chain."""
+
+ def _prisma_with_user(self, user_row):
+ prisma_client = MagicMock()
+ prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=user_row)
+ return prisma_client
+
+ async def test_resolves_through_the_shared_permission_cache(self):
+ from litellm.caching.dual_cache import DualCache
+
+ user_row = MagicMock()
+ user_row.object_permission_id = "perm-1"
+ prisma_client = self._prisma_with_user(user_row)
+ auth = UserAPIKeyAuth(api_key="sk-test", user_id="human-shared")
+ expected = MagicMock()
+
+ with (
+ patch("litellm.proxy.proxy_server.prisma_client", prisma_client),
+ patch("litellm.proxy.proxy_server.user_api_key_cache", DualCache()),
+ patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock()),
+ patch(
+ "litellm.proxy.auth.auth_checks.get_object_permission",
+ new_callable=AsyncMock,
+ return_value=expected,
+ ) as mock_get_perm,
+ ):
+ assert await MCPRequestHandler._get_user_object_permission(auth) is expected
+ assert mock_get_perm.await_args.kwargs["object_permission_id"] == "perm-1"
+
+ # The user_id -> object_permission_id link is cached, so the user row is read once.
+ prisma_client.db.litellm_usertable.find_unique.reset_mock()
+ await MCPRequestHandler._get_user_object_permission(auth)
+ prisma_client.db.litellm_usertable.find_unique.assert_not_called()
+
+ async def test_caches_a_sentinel_for_a_human_with_no_entitlement(self):
+ """A human without an entitlement is the common case and must cost no DB read per request."""
+ from litellm.caching.dual_cache import DualCache
+
+ user_row = MagicMock()
+ user_row.object_permission_id = None
+ prisma_client = self._prisma_with_user(user_row)
+ auth = UserAPIKeyAuth(api_key="sk-test", user_id="human-no-perm")
+
+ with (
+ patch("litellm.proxy.proxy_server.prisma_client", prisma_client),
+ patch("litellm.proxy.proxy_server.user_api_key_cache", DualCache()),
+ patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock()),
+ patch("litellm.proxy.auth.auth_checks.get_object_permission", new_callable=AsyncMock) as mock_get_perm,
+ ):
+ assert await MCPRequestHandler._get_user_object_permission(auth) is None
+ assert await MCPRequestHandler._get_user_object_permission(auth) is None
+ mock_get_perm.assert_not_awaited()
+ prisma_client.db.litellm_usertable.find_unique.assert_awaited_once()
+
+ async def test_missing_user_row_places_no_ceiling(self):
+ """Whether this human is entitled at all is unknown when their row is absent, which is the
+ state before the level existed, so it must not deny."""
+ from litellm.caching.dual_cache import DualCache
+
+ prisma_client = self._prisma_with_user(None)
+ auth = UserAPIKeyAuth(api_key="sk-test", user_id="ghost")
+
+ with (
+ patch("litellm.proxy.proxy_server.prisma_client", prisma_client),
+ patch("litellm.proxy.proxy_server.user_api_key_cache", DualCache()),
+ patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock()),
+ ):
+ assert await MCPRequestHandler._get_user_object_permission(auth) is None
+
+ async def test_unreadable_user_row_places_no_ceiling(self):
+ from litellm.caching.dual_cache import DualCache
+
+ prisma_client = MagicMock()
+ prisma_client.db.litellm_usertable.find_unique = AsyncMock(side_effect=Exception("db down"))
+ auth = UserAPIKeyAuth(api_key="sk-test", user_id="human-db-down")
+
+ with (
+ patch("litellm.proxy.proxy_server.prisma_client", prisma_client),
+ patch("litellm.proxy.proxy_server.user_api_key_cache", DualCache()),
+ patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock()),
+ ):
+ assert await MCPRequestHandler._get_user_object_permission(auth) is None
+
+ async def test_named_but_unreadable_permission_raises(self):
+ """A KNOWN entitlement with unknown contents is indeterminate: it must surface so the callers
+ can deny rather than serve the wider key scope."""
+ from litellm.caching.dual_cache import DualCache
+
+ user_row = MagicMock()
+ user_row.object_permission_id = "perm-gone"
+ prisma_client = self._prisma_with_user(user_row)
+ auth = UserAPIKeyAuth(api_key="sk-test", user_id="human-dangling")
+
+ with (
+ patch("litellm.proxy.proxy_server.prisma_client", prisma_client),
+ patch("litellm.proxy.proxy_server.user_api_key_cache", DualCache()),
+ patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock()),
+ patch(
+ "litellm.proxy.auth.auth_checks.get_object_permission",
+ new_callable=AsyncMock,
+ return_value=None,
+ ),
+ ):
+ with pytest.raises(ValueError):
+ await MCPRequestHandler._get_user_object_permission(auth)
+
+ async def test_no_user_id_places_no_ceiling(self):
+ assert await MCPRequestHandler._get_user_object_permission(UserAPIKeyAuth(api_key="sk-test")) is None
+ assert await MCPRequestHandler._get_user_object_permission(None) is None
diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py
index 8de42ca89da..a37f7ca764d 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py
@@ -2893,6 +2893,7 @@ async def test_user_info_v2_response_shape(mocker):
"updated_at",
"sso_user_id",
"teams",
+ "object_permission",
}
assert set(response_dict.keys()) == expected_fields
@@ -3702,3 +3703,332 @@ async def test_get_user_info_for_proxy_admin_validates_keys_and_teams():
returned_key = result.keys[0]
assert returned_key["team_id"] == "team-a"
assert returned_key["models"] == []
+
+
+def _object_permission_mocks(mocker, existing_object_permission_id=None):
+ """Prisma double whose user row optionally already links a permission row."""
+ mock_prisma_client = mocker.MagicMock()
+ existing_user = mocker.MagicMock()
+ existing_user.model_dump.return_value = {
+ "user_id": "target-user",
+ "object_permission_id": existing_object_permission_id,
+ }
+ existing_user.user_id = "target-user"
+ existing_user.object_permission_id = existing_object_permission_id
+ mock_prisma_client.db.litellm_usertable.find_first = mocker.AsyncMock(
+ return_value=existing_user
+ )
+ mock_prisma_client.db.litellm_objectpermissiontable.find_unique = mocker.AsyncMock(
+ return_value=None
+ )
+ mock_prisma_client.db.litellm_objectpermissiontable.upsert = mocker.AsyncMock(
+ return_value=SimpleNamespace(object_permission_id="perm-new")
+ )
+ mock_prisma_client.update_data = mocker.AsyncMock(
+ return_value={"user_id": "target-user"}
+ )
+ mock_prisma_client.jsonify_object = mocker.MagicMock(side_effect=lambda x: x)
+ mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
+ mocker.patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin")
+ mocker.patch(
+ "litellm.proxy.proxy_server._invalidate_spend_counter",
+ new=mocker.AsyncMock(),
+ )
+ return mock_prisma_client
+
+
+@pytest.mark.asyncio
+async def test_user_update_persists_mcp_entitlement_and_links_it(mocker):
+ """/user/update documents an object_permission param; it must actually be stored.
+
+ The grants live in their own table, so the endpoint has to upsert them and hand the user row
+ only the resulting object_permission_id. Passing object_permission through to the user update
+ would not even be a column.
+ """
+ from litellm.proxy.management_endpoints.internal_user_endpoints import (
+ _update_single_user_helper,
+ )
+
+ mock_prisma_client = _object_permission_mocks(mocker)
+ cache = mocker.MagicMock()
+ cache.async_delete_cache = mocker.AsyncMock()
+ mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", cache)
+
+ await _update_single_user_helper(
+ user_request=UpdateUserRequest(
+ user_id="target-user",
+ object_permission={
+ "mcp_servers": ["github"],
+ "mcp_tool_permissions": {"github": ["list_issues"]},
+ },
+ ),
+ user_api_key_dict=UserAPIKeyAuth(
+ user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN
+ ),
+ )
+
+ upsert_kwargs = mock_prisma_client.db.litellm_objectpermissiontable.upsert.call_args.kwargs
+ created = upsert_kwargs["data"]["create"]
+ assert created["mcp_servers"] == ["github"]
+ assert json.loads(created["mcp_tool_permissions"]) == {"github": ["list_issues"]}
+
+ written = mock_prisma_client.update_data.call_args.kwargs["data"]
+ assert written["object_permission_id"] == "perm-new"
+ assert "object_permission" not in written
+
+
+@pytest.mark.asyncio
+async def test_user_update_invalidates_the_cached_entitlement(mocker):
+ """An admin revoking a tool must take effect now, not at the end of the cache TTL.
+
+ Three entries go stale: the permission row (keyed by its own id), the user -> permission link
+ (which carries a "no entitlement" sentinel), and the cached user row.
+ """
+ from litellm.proxy.management_endpoints.internal_user_endpoints import (
+ _update_single_user_helper,
+ )
+
+ _object_permission_mocks(mocker)
+ cache = mocker.MagicMock()
+ cache.async_delete_cache = mocker.AsyncMock()
+ mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", cache)
+
+ await _update_single_user_helper(
+ user_request=UpdateUserRequest(
+ user_id="target-user",
+ object_permission={"mcp_tool_permissions": {"github": []}},
+ ),
+ user_api_key_dict=UserAPIKeyAuth(
+ user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN
+ ),
+ )
+
+ deleted = {call.kwargs["key"] for call in cache.async_delete_cache.call_args_list}
+ assert deleted == {
+ "object_permission_id:perm-new",
+ "user_object_permission_id:target-user",
+ "target-user",
+ }
+
+
+@pytest.mark.asyncio
+async def test_admin_can_clear_a_users_mcp_entitlement(mocker):
+ """An explicit empty object_permission means "no object permission", so it must unlink.
+
+ The merge-based upsert cannot express this: merging an empty grant set over the existing row
+ leaves every grant in place, and the empty-value filter drops the field before the upsert runs,
+ so without the explicit clear path the documented operation silently returns success unchanged.
+
+ A clear also leaves no incoming permission id, so invalidation keyed off one would skip it and
+ the gateway would keep enforcing the cleared grants until the cache expired.
+ """
+ from litellm.proxy.management_endpoints.internal_user_endpoints import (
+ _update_single_user_helper,
+ )
+
+ mock_prisma_client = _object_permission_mocks(mocker, "perm-existing")
+ cache = mocker.MagicMock()
+ cache.async_delete_cache = mocker.AsyncMock()
+ mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", cache)
+
+ await _update_single_user_helper(
+ user_request=UpdateUserRequest(user_id="target-user", object_permission={}),
+ user_api_key_dict=UserAPIKeyAuth(
+ user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN
+ ),
+ )
+
+ written = mock_prisma_client.update_data.call_args.kwargs["data"]
+ assert written["object_permission_id"] is None
+ assert "object_permission" not in written
+ mock_prisma_client.db.litellm_objectpermissiontable.upsert.assert_not_called()
+
+ deleted = {call.kwargs["key"] for call in cache.async_delete_cache.call_args_list}
+ assert deleted == {
+ "object_permission_id:perm-existing",
+ "user_object_permission_id:target-user",
+ "target-user",
+ }
+
+
+@pytest.mark.asyncio
+async def test_user_update_invalidates_both_the_old_and_new_permission_rows(mocker):
+ """An upsert can mint a new permission row, which leaves the outgoing one cached under its id.
+
+ Only the link cache knows the user moved; the old row's own entry still holds the pre-update
+ grants, so anything still resolving that id keeps reading them.
+ """
+ from litellm.proxy.management_endpoints.internal_user_endpoints import (
+ _update_single_user_helper,
+ )
+
+ _object_permission_mocks(mocker, "perm-existing")
+ cache = mocker.MagicMock()
+ cache.async_delete_cache = mocker.AsyncMock()
+ mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", cache)
+
+ await _update_single_user_helper(
+ user_request=UpdateUserRequest(
+ user_id="target-user",
+ object_permission={"mcp_tool_permissions": {"github": ["list_issues"]}},
+ ),
+ user_api_key_dict=UserAPIKeyAuth(
+ user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN
+ ),
+ )
+
+ deleted = {call.kwargs["key"] for call in cache.async_delete_cache.call_args_list}
+ assert deleted == {
+ "object_permission_id:perm-existing",
+ "object_permission_id:perm-new",
+ "user_object_permission_id:target-user",
+ "target-user",
+ }
+
+
+@pytest.mark.asyncio
+async def test_non_admin_cannot_clear_their_own_mcp_entitlement(mocker):
+ """The empty-value filter drops `object_permission: {}` before the guard saw it, so a non-admin
+ could clear the very ceiling an admin placed on them. The guard reads the fields the caller SENT.
+ """
+ from fastapi import HTTPException
+
+ from litellm.proxy.management_endpoints.internal_user_endpoints import (
+ _update_single_user_helper,
+ )
+
+ mock_prisma_client = _object_permission_mocks(mocker, "perm-existing")
+ cache = mocker.MagicMock()
+ cache.async_delete_cache = mocker.AsyncMock()
+ mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", cache)
+
+ with pytest.raises(HTTPException) as exc:
+ await _update_single_user_helper(
+ user_request=UpdateUserRequest(user_id="target-user", object_permission={}),
+ user_api_key_dict=UserAPIKeyAuth(
+ user_id="target-user", user_role=LitellmUserRoles.INTERNAL_USER
+ ),
+ )
+
+ assert exc.value.status_code == 403
+ mock_prisma_client.update_data.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_non_admin_cannot_rewrite_their_own_mcp_entitlement(mocker):
+ """The entitlement bounds the human, so a self-write is an escalation path: an empty grant list
+ means "no restriction" and would lift a ceiling the admin placed on them."""
+ from fastapi import HTTPException
+
+ from litellm.proxy.management_endpoints.internal_user_endpoints import (
+ _update_single_user_helper,
+ )
+
+ mock_prisma_client = _object_permission_mocks(mocker, "perm-existing")
+ cache = mocker.MagicMock()
+ cache.async_delete_cache = mocker.AsyncMock()
+ mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", cache)
+
+ with pytest.raises(HTTPException) as exc:
+ await _update_single_user_helper(
+ user_request=UpdateUserRequest(
+ user_id="target-user",
+ object_permission={"mcp_servers": [], "mcp_tool_permissions": {}},
+ ),
+ user_api_key_dict=UserAPIKeyAuth(
+ user_id="target-user", user_role=LitellmUserRoles.INTERNAL_USER
+ ),
+ )
+
+ assert exc.value.status_code == 403
+ mock_prisma_client.db.litellm_objectpermissiontable.upsert.assert_not_called()
+ mock_prisma_client.update_data.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_new_user_persists_the_requested_mcp_entitlement(mocker):
+ """generate_key_helper_fn only forwards object_permission_id, so /user/new has to create the
+ grants row itself; otherwise the entitlement the admin sent is silently dropped."""
+ mock_prisma_client = mocker.MagicMock()
+ mock_prisma_client.db.litellm_objectpermissiontable.create = mocker.AsyncMock(
+ return_value=SimpleNamespace(object_permission_id="perm-created")
+ )
+ mock_prisma_client.db.litellm_usertable.find_first = mocker.AsyncMock(
+ return_value=None
+ )
+ mock_prisma_client.db.litellm_usertable.count = mocker.AsyncMock(return_value=0)
+ mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
+ mocker.patch(
+ "litellm.proxy.management_endpoints.internal_user_endpoints.check_if_default_team_set",
+ return_value=None,
+ )
+ mock_generate = mocker.patch(
+ "litellm.proxy.management_endpoints.internal_user_endpoints.generate_key_helper_fn",
+ new=mocker.AsyncMock(
+ return_value={"user_id": "new-human", "token": "sk-x", "expires": None}
+ ),
+ )
+ mocker.patch(
+ "litellm.proxy.hooks.user_management_event_hooks.UserManagementEventHooks.async_user_created_hook",
+ new=mocker.AsyncMock(),
+ )
+
+ await new_user(
+ data=NewUserRequest(
+ user_id="new-human",
+ object_permission={"mcp_tool_permissions": {"github": ["list_issues"]}},
+ ),
+ user_api_key_dict=UserAPIKeyAuth(
+ user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN
+ ),
+ )
+
+ created = mock_prisma_client.db.litellm_objectpermissiontable.create.call_args.kwargs["data"]
+ assert json.loads(created["mcp_tool_permissions"]) == {"github": ["list_issues"]}
+ forwarded = mock_generate.call_args.kwargs
+ assert forwarded["object_permission_id"] == "perm-created"
+ assert "object_permission" not in forwarded
+
+
+@pytest.mark.asyncio
+async def test_user_info_v2_returns_the_mcp_entitlement(mocker):
+ """The admin UI reads the current entitlement off this endpoint, so the grants have to come back
+ with the user row rather than only their id."""
+ from litellm.proxy.management_endpoints.internal_user_endpoints import user_info_v2
+
+ user_row = SimpleNamespace(
+ object_permission=SimpleNamespace(
+ object_permission_id="perm-1",
+ mcp_servers=["github"],
+ mcp_access_groups=[],
+ mcp_tool_permissions={"github": ["list_issues"]},
+ ),
+ )
+ user_row.model_dump = lambda: {
+ "user_id": "human-1",
+ "object_permission": {
+ "object_permission_id": "perm-1",
+ "mcp_servers": ["github"],
+ "mcp_access_groups": [],
+ "mcp_tool_permissions": {"github": ["list_issues"]},
+ },
+ }
+ mocker.patch("litellm.proxy.proxy_server.prisma_client", mocker.MagicMock())
+ mocker.patch(
+ "litellm.proxy.management_endpoints.internal_user_endpoints._check_user_info_v2_access",
+ new=mocker.AsyncMock(return_value=user_row),
+ )
+
+ response = await user_info_v2(
+ request=SimpleNamespace(query_params={}),
+ user_id="human-1",
+ user_api_key_dict=UserAPIKeyAuth(
+ user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN
+ ),
+ )
+
+ assert response.object_permission is not None
+ assert response.object_permission.mcp_servers == ["github"]
+ assert response.object_permission.mcp_tool_permissions == {
+ "github": ["list_issues"]
+ }
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.tsx
index 54392adf885..c83a8e48e43 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/user_edit_view.tsx
@@ -1,11 +1,14 @@
import { InfoCircleOutlined } from "@ant-design/icons";
import { Button, SelectItem, TextInput, Textarea } from "@tremor/react";
-import { Checkbox, Form, Select, Tooltip } from "antd";
+import { Checkbox, Form, Input, Select, Tooltip } from "antd";
import React, { useState } from "react";
import { all_admin_roles } from "@/utils/roles";
import BudgetDurationDropdown from "@/components/common_components/budget_duration_dropdown";
import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key";
import NumericalInput from "@/components/shared/numerical_input";
+import MCPServerSelector from "@/components/mcp_server_management/MCPServerSelector";
+import MCPToolPermissions from "@/components/mcp_server_management/MCPToolPermissions";
+import type { ObjectPermission } from "@/components/object_permission_types";
interface UserEditViewProps {
userData: any;
@@ -18,8 +21,18 @@ interface UserEditViewProps {
userModels: string[];
possibleUIRoles: Record> | null;
isBulkEdit?: boolean;
+ objectPermission?: ObjectPermission | null;
}
+const buildMcpFieldValues = (objectPermission: ObjectPermission | null | undefined) => ({
+ mcp_servers_and_groups: {
+ servers: objectPermission?.mcp_servers ?? [],
+ accessGroups: objectPermission?.mcp_access_groups ?? [],
+ toolsets: objectPermission?.mcp_toolsets ?? [],
+ },
+ mcp_tool_permissions: objectPermission?.mcp_tool_permissions ?? {},
+});
+
export function UserEditView({
userData,
onCancel,
@@ -31,9 +44,11 @@ export function UserEditView({
userModels,
possibleUIRoles,
isBulkEdit = false,
+ objectPermission,
}: UserEditViewProps) {
const [form] = Form.useForm();
const [unlimitedBudget, setUnlimitedBudget] = useState(false);
+ const canEditMcpPermissions = !isBulkEdit && all_admin_roles.includes(userRole || "");
// Set initial form values
React.useEffect(() => {
@@ -50,8 +65,9 @@ export function UserEditView({
max_budget: isUnlimited ? "" : maxBudget,
budget_duration: userData.user_info?.budget_duration,
metadata: userData.user_info?.metadata ? JSON.stringify(userData.user_info.metadata, null, 2) : undefined,
+ ...(canEditMcpPermissions ? buildMcpFieldValues(objectPermission) : {}),
});
- }, [userData, form]);
+ }, [userData, objectPermission, canEditMcpPermissions, form]);
const handleUnlimitedBudgetChange = (e: any) => {
const checked = e.target.checked;
@@ -186,6 +202,52 @@ export function UserEditView({
+ {canEditMcpPermissions && (
+ <>
+
+ MCP Servers / Access Groups{" "}
+
+
+
+
+ }
+ name="mcp_servers_and_groups"
+ >
+ form.setFieldValue("mcp_servers_and_groups", val)}
+ value={form.getFieldValue("mcp_servers_and_groups")}
+ accessToken={accessToken || ""}
+ placeholder="Select MCP servers or access groups (optional)"
+ />
+
+
+
+
+
+
+
+ prevValues.mcp_servers_and_groups !== currentValues.mcp_servers_and_groups ||
+ prevValues.mcp_tool_permissions !== currentValues.mcp_tool_permissions
+ }
+ >
+ {() => (
+
+ form.setFieldsValue({ mcp_tool_permissions: toolPerms })}
+ />
+
+ )}
+
+ >
+ )}
+
Cancel
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.test.tsx
index 681e3905026..0a5c9523614 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.test.tsx
@@ -1,13 +1,18 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi, beforeEach } from "vitest";
-import UserInfoView from "./user_info_view";
+import UserInfoView, { extractMcpEntitlement } from "./user_info_view";
const mockTeamMemberAddCall = vi.fn();
const mockTeamMemberDeleteCall = vi.fn();
const mockTeamListCall = vi.fn();
const mockUserGetInfoV2 = vi.fn();
const mockTeamInfoCall = vi.fn();
+const mockUserUpdateUserCall = vi.fn();
+const mockFetchMCPServers = vi.fn();
+const mockListMCPTools = vi.fn();
+
+const MCP_SERVER = { server_id: "srv-1", server_name: "GitHub MCP", alias: "GitHub MCP" };
const MOCK_USER_DATA = {
user_id: "user-123",
@@ -24,6 +29,11 @@ const MOCK_USER_DATA = {
updated_at: "2025-01-02T00:00:00.000Z",
sso_user_id: null,
teams: ["team-1", "team-2"],
+ object_permission: {
+ mcp_servers: ["srv-1"],
+ mcp_access_groups: ["dev-group"],
+ mcp_tool_permissions: { "srv-1": ["list_issues"] },
+ },
};
const MOCK_USER_DATA_NO_TEAMS = {
@@ -35,7 +45,7 @@ vi.mock("@/components/networking", () => {
return {
userGetInfoV2: (...args: any[]) => mockUserGetInfoV2(...args),
userDeleteCall: vi.fn(),
- userUpdateUserCall: vi.fn(),
+ userUpdateUserCall: (...args: unknown[]) => mockUserUpdateUserCall(...args),
modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }),
invitationCreateCall: vi.fn(),
teamInfoCall: (...args: any[]) => mockTeamInfoCall(...args),
@@ -43,9 +53,22 @@ vi.mock("@/components/networking", () => {
teamMemberAddCall: (...args: any[]) => mockTeamMemberAddCall(...args),
teamMemberDeleteCall: (...args: any[]) => mockTeamMemberDeleteCall(...args),
getProxyBaseUrl: () => "https://litellm.test",
+ fetchMCPServers: (...args: unknown[]) => mockFetchMCPServers(...args),
+ fetchMCPToolsets: vi.fn().mockResolvedValue([]),
+ listMCPTools: (...args: unknown[]) => mockListMCPTools(...args),
};
});
+vi.mock("@/app/(dashboard)/hooks/mcpServers/useMCPServers", () => ({
+ useMCPServers: () => ({ data: [MCP_SERVER], isLoading: false }),
+}));
+vi.mock("@/app/(dashboard)/hooks/mcpServers/useMCPAccessGroups", () => ({
+ useMCPAccessGroups: () => ({ data: ["dev-group"], isLoading: false }),
+}));
+vi.mock("@/app/(dashboard)/hooks/mcpServers/useMCPToolsets", () => ({
+ useMCPToolsets: () => ({ data: [], isLoading: false }),
+}));
+
describe("UserInfoView", () => {
const defaultProps = {
userId: "user-123",
@@ -73,6 +96,9 @@ describe("UserInfoView", () => {
]);
mockTeamMemberAddCall.mockResolvedValue({});
mockTeamMemberDeleteCall.mockResolvedValue({});
+ mockUserUpdateUserCall.mockResolvedValue({});
+ mockFetchMCPServers.mockResolvedValue([MCP_SERVER]);
+ mockListMCPTools.mockResolvedValue({ tools: [{ name: "list_issues", description: "List issues" }] });
});
it("should render the loading state", () => {
@@ -215,4 +241,227 @@ describe("UserInfoView", () => {
});
});
});
+
+ describe("MCP permissions", () => {
+ it("should render the user's MCP entitlements in read mode", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText("MCP Permissions")).toBeInTheDocument();
+ });
+
+ const grantedServer = await screen.findByText("GitHub MCP (srv-1)");
+ expect(screen.getByText("dev-group")).toBeInTheDocument();
+ expect(screen.queryByText("list_issues")).not.toBeInTheDocument();
+
+ await user.click(grantedServer);
+
+ expect(await screen.findByText("list_issues")).toBeInTheDocument();
+ });
+
+ it("should nest MCP entitlements under object_permission when an admin saves", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ const saveButton = await screen.findByText("Save Changes");
+ await user.click(saveButton);
+
+ await waitFor(() => {
+ expect(mockUserUpdateUserCall).toHaveBeenCalledTimes(1);
+ });
+
+ const [token, payload, roleArg] = mockUserUpdateUserCall.mock.calls[0];
+ expect(token).toBe("test-token");
+ expect(roleArg).toBeNull();
+ expect(payload.user_id).toBe("user-123");
+ const expectedObjectPermission = {
+ mcp_servers: ["srv-1"],
+ mcp_access_groups: ["dev-group"],
+ mcp_toolsets: [],
+ mcp_tool_permissions: { "srv-1": ["list_issues"] },
+ };
+ expect(payload.object_permission).toEqual(expectedObjectPermission);
+ expect(payload).not.toHaveProperty("mcp_servers_and_groups");
+ expect(payload).not.toHaveProperty("mcp_tool_permissions");
+ expect(payload).not.toHaveProperty("mcp_servers");
+ });
+
+ it("should send tool selections made in the edit form", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await screen.findByText("Save Changes");
+ await waitFor(() => {
+ expect(mockListMCPTools).toHaveBeenCalledWith("test-token", "srv-1");
+ });
+ await waitFor(() => {
+ expect(screen.queryByText("Loading tools...")).not.toBeInTheDocument();
+ });
+
+ await user.click(screen.getByRole("button", { name: "Deselect All" }));
+ await user.click(screen.getByText("Save Changes"));
+
+ await waitFor(() => {
+ expect(mockUserUpdateUserCall).toHaveBeenCalledTimes(1);
+ });
+
+ const [, payload] = mockUserUpdateUserCall.mock.calls[0];
+ expect(payload.object_permission.mcp_tool_permissions).toEqual({ "srv-1": [] });
+ });
+
+ it("should preserve every tool allowlist when the granted servers are unchanged", async () => {
+ const user = userEvent.setup();
+ mockUserGetInfoV2.mockResolvedValue({
+ ...MOCK_USER_DATA,
+ object_permission: {
+ mcp_servers: ["srv-1"],
+ mcp_access_groups: ["group-a"],
+ mcp_tool_permissions: { "srv-1": ["list_issues"], "srv-via-group": ["read_only"] },
+ },
+ });
+ render( );
+
+ const saveButton = await screen.findByText("Save Changes");
+ await user.click(saveButton);
+
+ await waitFor(() => {
+ expect(mockUserUpdateUserCall).toHaveBeenCalledTimes(1);
+ });
+
+ const [, payload] = mockUserUpdateUserCall.mock.calls[0];
+ expect(payload.object_permission.mcp_tool_permissions).toEqual({
+ "srv-1": ["list_issues"],
+ "srv-via-group": ["read_only"],
+ });
+ });
+
+ it("should not send object_permission for a non-admin editor", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ const saveButton = await screen.findByText("Save Changes");
+ await user.click(saveButton);
+
+ await waitFor(() => {
+ expect(mockUserUpdateUserCall).toHaveBeenCalledTimes(1);
+ });
+
+ const [, payload] = mockUserUpdateUserCall.mock.calls[0];
+ expect(payload).not.toHaveProperty("object_permission");
+ expect(screen.queryByText("MCP Servers / Access Groups")).not.toBeInTheDocument();
+ });
+ });
+});
+
+describe("extractMcpEntitlement", () => {
+ const CATALOG = [
+ { server_id: "srv-1", server_name: "deploy_tracker", alias: "deploy" },
+ { server_id: "srv-2", server_name: "issue_tracker", alias: null },
+ { server_id: "srv-via-group", server_name: "audit_log", alias: null },
+ ] as any;
+
+ const form = (
+ selection: { servers?: string[]; accessGroups?: string[]; toolsets?: string[] },
+ toolPermissions: Record,
+ ) => ({
+ mcp_servers_and_groups: {
+ servers: selection.servers ?? [],
+ accessGroups: selection.accessGroups ?? [],
+ toolsets: selection.toolsets ?? [],
+ },
+ mcp_tool_permissions: toolPermissions,
+ });
+
+ it("drops the tool allowlist of a server the admin just deselected", () => {
+ const result = extractMcpEntitlement(
+ form({ servers: ["srv-1"] }, { "srv-1": ["read"], "srv-2": ["delete"] }),
+ CATALOG,
+ );
+ expect(result?.mcp_tool_permissions).toEqual({ "srv-1": ["read"] });
+ });
+
+ it("drops the allowlist of a server reached only through an access group the admin removed", () => {
+ const result = extractMcpEntitlement(form({}, { "srv-via-group": ["read"] }), CATALOG);
+ expect(result?.mcp_tool_permissions).toEqual({});
+ });
+
+ it("keeps a name-keyed allowlist for a server that is still selected by id", () => {
+ // The gateway resolves a tool-permission key by id, name OR alias, so an entry written by the
+ // API or by config may be keyed by name. Comparing keys to the selector's ids alone drops it
+ // while the server stays granted, which removes the restriction entirely.
+ const result = extractMcpEntitlement(form({ servers: ["srv-1"] }, { deploy_tracker: ["create_issue"] }), CATALOG);
+ expect(result?.mcp_tool_permissions).toEqual({ deploy_tracker: ["create_issue"] });
+ });
+
+ it("keeps an alias-keyed allowlist for a server that is still selected by id", () => {
+ const result = extractMcpEntitlement(form({ servers: ["srv-1"] }, { deploy: ["create_issue"] }), CATALOG);
+ expect(result?.mcp_tool_permissions).toEqual({ deploy: ["create_issue"] });
+ });
+
+ it("drops a name-keyed allowlist once its server is deselected", () => {
+ const result = extractMcpEntitlement(form({ servers: ["srv-2"] }, { deploy_tracker: ["create_issue"] }), CATALOG);
+ expect(result?.mcp_tool_permissions).toEqual({});
+ });
+
+ it("prunes nothing when the server catalog has not loaded", () => {
+ // Every key is unresolvable without the catalog, and pruning while under-informed is the
+ // direction that widens.
+ const result = extractMcpEntitlement(form({ servers: ["srv-2"] }, { "srv-1": ["create_issue"] }), []);
+ expect(result?.mcp_tool_permissions).toEqual({ "srv-1": ["create_issue"] });
+ });
+
+ it("keeps an entry whose key names no known server", () => {
+ const result = extractMcpEntitlement(form({ servers: ["srv-1"] }, { "srv-deleted": ["read"] }), CATALOG);
+ expect(result?.mcp_tool_permissions).toEqual({ "srv-deleted": ["read"] });
+ });
+
+ it.each([
+ ["selected server first", ["srv-shared-a", "srv-shared-b"]],
+ ["deselected server first", ["srv-shared-b", "srv-shared-a"]],
+ ])("keeps a shared-name allowlist while any server it names is granted (%s)", (_label, order) => {
+ // Names are not unique and the gateway unions a name key into EVERY server answering to it, so
+ // this one entry restricts both. Resolving to the first match would drop it whenever the catalog
+ // happened to return the deselected server first, stripping the restriction from the one still
+ // granted, which is a widening that reproduces on one deployment and not another.
+ const catalog = [
+ { server_id: "srv-shared-a", server_name: "shared", alias: null },
+ { server_id: "srv-shared-b", server_name: "shared", alias: null },
+ ] as any;
+ const ordered = order.map((id) => catalog.find((server: any) => server.server_id === id));
+
+ const result = extractMcpEntitlement(form({ servers: ["srv-shared-a"] }, { shared: ["read"] }), ordered as any);
+ expect(result?.mcp_tool_permissions).toEqual({ shared: ["read"] });
+ });
+
+ it("drops a shared-name allowlist once no server it names is granted", () => {
+ const catalog = [
+ { server_id: "srv-shared-a", server_name: "shared", alias: null },
+ { server_id: "srv-shared-b", server_name: "shared", alias: null },
+ ] as any;
+ const result = extractMcpEntitlement(form({ servers: [] }, { shared: ["read"] }), catalog);
+ expect(result?.mcp_tool_permissions).toEqual({});
+ });
+
+ it("keeps the allowlist of a server granted through a retained access group", () => {
+ const result = extractMcpEntitlement(
+ form({ servers: ["srv-1"], accessGroups: ["ops_readonly"] }, { "srv-1": ["read"], "srv-via-group": ["read"] }),
+ CATALOG,
+ );
+ expect(result?.mcp_tool_permissions).toEqual({ "srv-1": ["read"], "srv-via-group": ["read"] });
+ });
+
+ it("keeps the allowlist of a deselected server that a retained access group still supplies", () => {
+ const result = extractMcpEntitlement(form({ accessGroups: ["ops_readonly"] }, { "srv-1": ["read"] }), CATALOG);
+ expect(result?.mcp_tool_permissions).toEqual({ "srv-1": ["read"] });
+ });
+
+ it("keeps the allowlist of a deselected server when a toolset is retained", () => {
+ const result = extractMcpEntitlement(form({ toolsets: ["ts-1"] }, { "srv-1": ["read"] }), CATALOG);
+ expect(result?.mcp_tool_permissions).toEqual({ "srv-1": ["read"] });
+ });
+
+ it("returns null when the MCP section was not rendered", () => {
+ expect(extractMcpEntitlement({ user_email: "a@b.c" }, CATALOG)).toBeNull();
+ });
});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.tsx
index 72e8b050d48..5572c4dc4a9 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.tsx
@@ -41,6 +41,78 @@ import { CopyIcon, CheckIcon } from "lucide-react";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { getBudgetDurationLabel } from "@/components/common_components/budget_duration_dropdown";
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
+import MCPServerPermissions from "@/components/permissions/MCPServerPermissions";
+import { MCPServer } from "@/components/mcp_tools/types";
+import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
+
+interface McpEntitlementUpdate {
+ mcp_servers: string[];
+ mcp_access_groups: string[];
+ mcp_toolsets: string[];
+ mcp_tool_permissions: Record;
+}
+
+const asStringArray = (value: unknown): string[] =>
+ Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
+
+const asToolPermissions = (value: unknown): Record => {
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return {};
+ return Object.fromEntries(
+ Object.entries(value as Record).map(([serverId, tools]) => [serverId, asStringArray(tools)]),
+ );
+};
+
+const mcpServerMatchesIdentifier = (server: MCPServer, identifier: string): boolean =>
+ server.server_id === identifier || server.server_name === identifier || server.alias === identifier;
+
+/**
+ * The `object_permission` a save sends, derived from what the editor currently shows.
+ *
+ * A tool allowlist is what narrows a grant and an absent one reads as no restriction, so dropping
+ * an entry is the direction that widens. An entry is kept when an access group or toolset the admin
+ * retained could still supply its server, and dropped once nothing indirect survives, which is what
+ * makes removing a grant actually remove it.
+ *
+ * A tool-permission key may be a server id, a name or an alias: the gateway normalizes all three
+ * before looking up the allowlist, so an entry written by the API or by config can use any of them.
+ * `allServers` is what resolves a key to its servers, plural: names and aliases are not unique, and
+ * the gateway unions such a key into EVERY server answering to it, so the entry is kept while any
+ * one of them is still granted. Resolving to the first match instead would make the outcome depend
+ * on catalog order and could drop a restriction that was also covering a server still granted. A key
+ * that resolves to nothing is kept too, since a server we cannot identify is one we cannot confirm
+ * was deselected; that also covers a catalog that has not loaded or failed to load, where every key
+ * is unresolvable and nothing is pruned.
+ */
+export const extractMcpEntitlement = (
+ formValues: Record,
+ allServers: MCPServer[],
+): McpEntitlementUpdate | null => {
+ const selection = formValues.mcp_servers_and_groups;
+ if (selection === null || typeof selection !== "object") return null;
+
+ const { servers, accessGroups, toolsets } = selection as Record;
+ const mcpServers = asStringArray(servers);
+ const mcpAccessGroups = asStringArray(accessGroups);
+ const mcpToolsets = asStringArray(toolsets);
+ const retainsIndirectGrant = mcpAccessGroups.length > 0 || mcpToolsets.length > 0;
+
+ const grantsServerNamedBy = (permissionKey: string): boolean => {
+ const named = allServers.filter((candidate) => mcpServerMatchesIdentifier(candidate, permissionKey));
+ if (named.length === 0) return true;
+ return named.some((server) => mcpServers.some((identifier) => mcpServerMatchesIdentifier(server, identifier)));
+ };
+
+ return {
+ mcp_servers: mcpServers,
+ mcp_access_groups: mcpAccessGroups,
+ mcp_toolsets: mcpToolsets,
+ mcp_tool_permissions: Object.fromEntries(
+ Object.entries(asToolPermissions(formValues.mcp_tool_permissions)).filter(
+ ([permissionKey]) => retainsIndirectGrant || grantsServerNamedBy(permissionKey),
+ ),
+ ),
+ };
+};
interface UserInfoViewProps {
userId: string;
@@ -91,6 +163,7 @@ export default function UserInfoView({
const [selectedTeamId, setSelectedTeamId] = useState("");
const [selectedRole, setSelectedRole] = useState("user");
const [isLoadingTeams, setIsLoadingTeams] = useState(false);
+ const { data: allMcpServers = [] } = useMCPServers();
React.useEffect(() => {
setBaseUrl(getProxyBaseUrl());
@@ -292,7 +365,18 @@ export default function UserInfoView({
try {
if (!accessToken || !userData) return;
- const response = await userUpdateUserCall(accessToken, formValues, null);
+ const mcpEntitlement = extractMcpEntitlement(formValues, allMcpServers);
+ const userFields = Object.fromEntries(
+ Object.entries(formValues).filter(
+ ([field]) => field !== "mcp_servers_and_groups" && field !== "mcp_tool_permissions",
+ ),
+ );
+
+ await userUpdateUserCall(
+ accessToken,
+ mcpEntitlement ? { ...userFields, object_permission: mcpEntitlement } : userFields,
+ null,
+ );
// Update local state with new values
setUserData({
@@ -303,6 +387,9 @@ export default function UserInfoView({
max_budget: formValues.max_budget ?? userData.max_budget,
budget_duration: formValues.budget_duration ?? userData.budget_duration,
metadata: formValues.metadata ?? userData.metadata,
+ object_permission: mcpEntitlement
+ ? { ...userData.object_permission, ...mcpEntitlement }
+ : userData.object_permission,
});
NotificationsManager.success("User updated successfully");
@@ -531,6 +618,7 @@ export default function UserInfoView({
userRole={userRole}
userModels={userModels}
possibleUIRoles={possibleUIRoles}
+ objectPermission={userData.object_permission}
/>
) : (
@@ -612,6 +700,17 @@ export default function UserInfoView({
{JSON.stringify(userData.metadata || {}, null, 2)}
+
+
+ MCP Permissions
+
+
)}
diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx
index 7c83e62ff2e..1a0951db967 100644
--- a/ui/litellm-dashboard/src/components/networking.tsx
+++ b/ui/litellm-dashboard/src/components/networking.tsx
@@ -993,6 +993,7 @@ export interface UserInfoV2Response {
updated_at: string | null;
sso_user_id: string | null;
teams: string[];
+ object_permission?: ObjectPermission | null;
}
/**
diff --git a/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.tsx b/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.tsx
index 4dfe41bb515..b16b094e0e1 100644
--- a/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.tsx
+++ b/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.tsx
@@ -90,7 +90,7 @@ export function MCPServerPermissions({
const serverDetail = mcpServerDetails.find((server) => server.server_id === serverId);
if (serverDetail) {
const truncatedId = serverId.length > 7 ? `${serverId.slice(0, 3)}...${serverId.slice(-4)}` : serverId;
- return `${serverDetail.alias} (${truncatedId})`;
+ return `${serverDetail.alias || serverDetail.server_name || serverId} (${truncatedId})`;
}
return serverId;
};
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index 380c2616c5d..bc3110d4d3d 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -14806,7 +14806,7 @@ export interface paths {
* - duration: Optional[str] - Duration for the key auto-created on `/user/new`. Default is None.
* - key_alias: Optional[str] - Alias for the key auto-created on `/user/new`. Default is None.
* - sso_user_id: Optional[str] - The id of the user in the SSO provider.
- * - object_permission: Optional[LiteLLM_ObjectPermissionBase] - internal user-specific object permission. Example - {"vector_stores": ["vector_store_1", "vector_store_2"]}. IF null or {} then no object permission.
+ * - object_permission: Optional[LiteLLM_ObjectPermissionBase] - internal user-specific object permission. Example - {"vector_stores": ["vector_store_1"], "mcp_servers": ["github"], "mcp_tool_permissions": {"github": ["list_issues"]}}. The MCP grants act as a ceiling on every key this user holds. IF null or {} then no object permission.
* - prompts: Optional[List[str]] - List of allowed prompts for the user. If specified, the user will only be able to use these specific prompts.
* - organizations: List[str] - List of organization id's the user is a member of
* - budget_limits: Optional[list] - List of concurrent budget windows for the user. Each window specifies a budget_limit, time_period, and optional budget_duration. Example - [{"budget_limit": 10.0, "time_period": "1d"}, {"budget_limit": 50.0, "time_period": "7d"}].
@@ -14887,7 +14887,7 @@ export interface paths {
* - team_id: Optional[str] - [DEPRECATED PARAM] The team id of the user. Default is None.
* - duration: Optional[str] - [NOT IMPLEMENTED].
* - key_alias: Optional[str] - [NOT IMPLEMENTED].
- * - object_permission: Optional[LiteLLM_ObjectPermissionBase] - internal user-specific object permission. Example - {"vector_stores": ["vector_store_1", "vector_store_2"]}. IF null or {} then no object permission.
+ * - object_permission: Optional[LiteLLM_ObjectPermissionBase] - internal user-specific object permission. Example - {"vector_stores": ["vector_store_1"], "mcp_servers": ["github"], "mcp_tool_permissions": {"github": ["list_issues"]}}. The MCP grants act as a ceiling on every key this user holds. IF null or {} then no object permission.
* - prompts: Optional[List[str]] - List of allowed prompts for the user. If specified, the user will only be able to use these specific prompts.
* - budget_limits: Optional[list] - List of concurrent budget windows for the user. Each window specifies a budget_limit, time_period, and optional budget_duration. Example - [{"budget_limit": 10.0, "time_period": "1d"}, {"budget_limit": 50.0, "time_period": "7d"}].
*/
@@ -33541,6 +33541,7 @@ export interface components {
* @default []
*/
models: string[];
+ object_permission?: components["schemas"]["LiteLLM_ObjectPermissionTable"] | null;
/**
* Spend
* @default 0
From d2e99a9220301f7bc5a28672c50b7c7504d30a0d Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Thu, 30 Jul 2026 12:07:03 -0700
Subject: [PATCH 16/33] fix(proxy): run post_call guardrails on /v1/messages
streaming via unified guardrail translation
---
.../unified_guardrail/unified_guardrail.py | 7 +-
litellm/proxy/utils.py | 32 ++-
.../test_proxy_logging_hook_detection.py | 219 ++++++++++++++++++
3 files changed, 254 insertions(+), 4 deletions(-)
diff --git a/litellm/proxy/guardrails/guardrail_hooks/unified_guardrail/unified_guardrail.py b/litellm/proxy/guardrails/guardrail_hooks/unified_guardrail/unified_guardrail.py
index d4d23cd2e37..5cbd05dedfc 100644
--- a/litellm/proxy/guardrails/guardrail_hooks/unified_guardrail/unified_guardrail.py
+++ b/litellm/proxy/guardrails/guardrail_hooks/unified_guardrail/unified_guardrail.py
@@ -804,6 +804,8 @@ class UnifiedLLMGuardrails(CustomLogger):
user_api_key_dict: UserAPIKeyAuth,
response: Any,
request_data: dict,
+ guardrail_to_apply: Union[CustomGuardrail, None] = None,
+ buffer_until_moderated_default: bool = False,
) -> AsyncGenerator[Any, None]:
"""
Passes the entire stream to the guardrail
@@ -824,7 +826,8 @@ class UnifiedLLMGuardrails(CustomLogger):
# litellm.integrations.custom_guardrail.
from litellm.integrations.custom_guardrail import ModifyResponseException
- guardrail_to_apply: CustomGuardrail = request_data.pop("guardrail_to_apply", None)
+ if guardrail_to_apply is None:
+ guardrail_to_apply = request_data.pop("guardrail_to_apply", None)
# Get streaming configuration. Resolution order (later wins): default
# < guardrail attribute < guardrail_config dict < this callback's
@@ -852,7 +855,7 @@ class UnifiedLLMGuardrails(CustomLogger):
# release the original chunks are replayed as-is, so a
# content-rewriting guardrail (e.g. PII masking) would leak
# unredacted content. Guarded below via mask_response_content.
- buffer_until_moderated = _streaming_flag("streaming_buffer_until_moderated", False)
+ buffer_until_moderated = _streaming_flag("streaming_buffer_until_moderated", buffer_until_moderated_default)
if (
buffer_until_moderated
diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py
index 924189fed4b..39045e155d6 100644
--- a/litellm/proxy/utils.py
+++ b/litellm/proxy/utils.py
@@ -185,6 +185,8 @@ else:
unified_guardrail = UnifiedLLMGuardrails()
+NON_OPENAI_STREAM_GUARDRAIL_TRANSLATION_CALL_TYPES: "frozenset[CallTypes]" = frozenset({CallTypes.anthropic_messages})
+
def print_verbose(print_statement):
"""
@@ -1760,6 +1762,20 @@ class ProxyLogging:
cache[sig] = caps
return caps
+ @staticmethod
+ def _stream_requires_guardrail_translation(user_api_key_dict: UserAPIKeyAuth) -> bool:
+ from litellm.litellm_core_utils.api_route_to_call_types import (
+ get_call_types_for_route,
+ )
+
+ route = user_api_key_dict.request_route
+ if not route:
+ return False
+ call_types = get_call_types_for_route(route)
+ if not call_types:
+ return False
+ return call_types[0] in NON_OPENAI_STREAM_GUARDRAIL_TRANSLATION_CALL_TYPES
+
@staticmethod
def has_post_call_response_headers_callbacks() -> bool:
return ProxyLogging._callback_capabilities().has_post_call_response_headers
@@ -2668,6 +2684,7 @@ class ProxyLogging:
request_data = _check_and_merge_model_level_guardrails(data=request_data, llm_router=llm_router)
current_response = response
+ stream_needs_translation = ProxyLogging._stream_requires_guardrail_translation(user_api_key_dict)
for resolved_callback, kind in caps.iterator_overrides:
if isinstance(resolved_callback, CustomGuardrail):
@@ -2676,7 +2693,17 @@ class ProxyLogging:
is not True
):
continue
- if kind == "override":
+ effective_kind = (
+ "apply_guardrail"
+ if (
+ kind == "override"
+ and stream_needs_translation
+ and isinstance(resolved_callback, CustomGuardrail)
+ and "apply_guardrail" in type(resolved_callback).__dict__
+ )
+ else kind
+ )
+ if effective_kind == "override":
current_response = self._wrap_streaming_iterator_with_enrichment(
resolved_callback,
resolved_callback.async_post_call_streaming_iterator_hook(
@@ -2687,13 +2714,14 @@ class ProxyLogging:
)
else:
# kind == "apply_guardrail": route through unified_guardrail
- request_data["guardrail_to_apply"] = resolved_callback
current_response = self._wrap_streaming_iterator_with_enrichment(
resolved_callback,
unified_guardrail.async_post_call_streaming_iterator_hook(
user_api_key_dict=user_api_key_dict,
request_data=request_data,
response=current_response,
+ guardrail_to_apply=resolved_callback,
+ buffer_until_moderated_default=(kind == "override"),
),
)
diff --git a/tests/test_litellm/proxy/test_proxy_logging_hook_detection.py b/tests/test_litellm/proxy/test_proxy_logging_hook_detection.py
index f5967030561..032dc5c4df7 100644
--- a/tests/test_litellm/proxy/test_proxy_logging_hook_detection.py
+++ b/tests/test_litellm/proxy/test_proxy_logging_hook_detection.py
@@ -148,3 +148,222 @@ def test_callback_capabilities_cache_invalidates_on_list_change(monkeypatch):
caps = ProxyLogging._callback_capabilities()
assert caps.has_pre_call_override is True
assert pre in caps.resolved_callbacks
+
+
+def _sse_bytes(event: str, payload: dict) -> bytes:
+ import json
+
+ return f"event: {event}\ndata: {json.dumps(payload)}\n\n".encode()
+
+
+def _anthropic_stream_chunks(text_parts):
+ chunks = [
+ _sse_bytes(
+ "message_start",
+ {
+ "type": "message_start",
+ "message": {
+ "model": "claude-sonnet-5",
+ "id": "msg_1",
+ "type": "message",
+ "role": "assistant",
+ "content": [],
+ "stop_reason": None,
+ "usage": {"input_tokens": 20, "output_tokens": 1},
+ },
+ },
+ ),
+ _sse_bytes(
+ "content_block_start",
+ {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
+ ),
+ ]
+ for part in text_parts:
+ chunks.append(
+ _sse_bytes(
+ "content_block_delta",
+ {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": part}},
+ )
+ )
+ chunks.append(_sse_bytes("content_block_stop", {"type": "content_block_stop", "index": 0}))
+ chunks.append(
+ _sse_bytes(
+ "message_delta",
+ {
+ "type": "message_delta",
+ "delta": {"stop_reason": "end_turn", "stop_sequence": None},
+ "usage": {"input_tokens": 20, "output_tokens": 8},
+ },
+ )
+ )
+ chunks.append(_sse_bytes("message_stop", {"type": "message_stop"}))
+ return chunks
+
+
+def _content_filter_guardrail(action: str):
+ from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import (
+ ContentFilterGuardrail,
+ )
+ from litellm.types.guardrails import BlockedWord, ContentFilterAction
+
+ return ContentFilterGuardrail(
+ guardrail_name="output-filter",
+ blocked_words=[BlockedWord(keyword="zebra", action=ContentFilterAction(action))],
+ event_hook="post_call",
+ default_on=True,
+ )
+
+
+def _streaming_logging_obj():
+ import datetime
+ import uuid
+
+ from litellm.litellm_core_utils.litellm_logging import Logging
+
+ return Logging(
+ model="claude-sonnet-5",
+ messages=[{"role": "user", "content": "Reply with exactly: the zebra runs"}],
+ stream=True,
+ call_type="anthropic_messages",
+ start_time=datetime.datetime.now(),
+ litellm_call_id=str(uuid.uuid4()),
+ function_id="test",
+ )
+
+
+def test_stream_requires_guardrail_translation_route_detection():
+ from litellm.proxy._types import UserAPIKeyAuth
+
+ assert (
+ ProxyLogging._stream_requires_guardrail_translation(
+ UserAPIKeyAuth(api_key="sk-1234", request_route="/v1/messages")
+ )
+ is True
+ )
+ assert (
+ ProxyLogging._stream_requires_guardrail_translation(
+ UserAPIKeyAuth(api_key="sk-1234", request_route="/chat/completions")
+ )
+ is False
+ )
+ assert ProxyLogging._stream_requires_guardrail_translation(UserAPIKeyAuth(api_key="sk-1234")) is False
+
+
+@pytest.mark.asyncio
+async def test_post_call_stream_guardrail_blocks_anthropic_messages_stream(monkeypatch):
+ """
+ Regression test for https://github.com/BerriAI/litellm/issues/35257.
+
+ /v1/messages streams raw Anthropic SSE bytes. A guardrail whose custom
+ iterator hook only understands OpenAI ModelResponseStream chunks used to
+ receive those bytes directly and silently pass every chunk through
+ unscanned. The dispatch must route apply_guardrail-capable guardrails
+ through unified_guardrail's anthropic translation so blocked output
+ raises instead of streaming to the client. Because the guardrail's own
+ iterator hook withheld content until scanned, the rerouted invocation
+ defaults to buffer_until_moderated, so nothing may reach the client
+ before the block fires.
+ """
+ from fastapi import HTTPException
+
+ from litellm.caching.caching import DualCache
+ from litellm.proxy._types import UserAPIKeyAuth
+
+ guardrail = _content_filter_guardrail("BLOCK")
+ monkeypatch.setattr(litellm, "callbacks", [guardrail])
+
+ proxy_logging = ProxyLogging(user_api_key_cache=DualCache())
+ request_data = {
+ "model": "claude-sonnet-5",
+ "litellm_logging_obj": _streaming_logging_obj(),
+ "metadata": {},
+ }
+
+ async def fake_stream():
+ for chunk in _anthropic_stream_chunks(["the", " zebra runs"]):
+ yield chunk
+
+ delivered = []
+ with pytest.raises(HTTPException) as exc_info:
+ async for chunk in proxy_logging.async_post_call_streaming_iterator_hook(
+ response=fake_stream(),
+ user_api_key_dict=UserAPIKeyAuth(api_key="sk-1234", request_route="/v1/messages"),
+ request_data=request_data,
+ ):
+ delivered.append(chunk)
+
+ detail = exc_info.value.detail
+ assert detail["guardrail_name"] == "output-filter"
+ assert detail["keyword"] == "zebra"
+ assert delivered == []
+
+
+@pytest.mark.asyncio
+async def test_post_call_stream_guardrail_keeps_own_iterator_on_chat_completions(monkeypatch):
+ """
+ On /chat/completions the guardrail's own iterator hook must keep running:
+ it masks incrementally inside ModelResponseStream chunks, which the
+ unified block_only path never does. Masked output proves the own-hook
+ path was used.
+ """
+ from litellm.caching.caching import DualCache
+ from litellm.proxy._types import UserAPIKeyAuth
+ from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices
+
+ guardrail = _content_filter_guardrail("MASK")
+ monkeypatch.setattr(litellm, "callbacks", [guardrail])
+
+ proxy_logging = ProxyLogging(user_api_key_cache=DualCache())
+
+ async def fake_stream():
+ yield ModelResponseStream(
+ choices=[StreamingChoices(index=0, delta=Delta(content="the zebra runs"))]
+ )
+ yield ModelResponseStream(
+ choices=[StreamingChoices(index=0, delta=Delta(content=""), finish_reason="stop")]
+ )
+
+ delivered_text = ""
+ async for chunk in proxy_logging.async_post_call_streaming_iterator_hook(
+ response=fake_stream(),
+ user_api_key_dict=UserAPIKeyAuth(api_key="sk-1234", request_route="/chat/completions"),
+ request_data={"model": "gpt-4o-mini", "metadata": {}},
+ ):
+ for choice in chunk.choices:
+ delivered_text += choice.delta.content or ""
+
+ assert "zebra" not in delivered_text
+ assert delivered_text != ""
+
+
+@pytest.mark.asyncio
+async def test_unified_guardrail_iterator_accepts_explicit_guardrail(monkeypatch):
+ """
+ The dispatch passes each guardrail explicitly instead of through a shared
+ request_data key, so chaining two unified-routed guardrails cannot drop
+ all but the last one.
+ """
+ from fastapi import HTTPException
+
+ from litellm.proxy._types import UserAPIKeyAuth
+ from litellm.proxy.utils import unified_guardrail
+
+ guardrail = _content_filter_guardrail("BLOCK")
+ request_data = {
+ "model": "claude-sonnet-5",
+ "litellm_logging_obj": _streaming_logging_obj(),
+ "metadata": {},
+ }
+
+ async def fake_stream():
+ for chunk in _anthropic_stream_chunks(["the", " zebra runs"]):
+ yield chunk
+
+ with pytest.raises(HTTPException):
+ async for _ in unified_guardrail.async_post_call_streaming_iterator_hook(
+ user_api_key_dict=UserAPIKeyAuth(api_key="sk-1234", request_route="/v1/messages"),
+ response=fake_stream(),
+ request_data=request_data,
+ guardrail_to_apply=guardrail,
+ ):
+ pass
From 8bb8628ab57ae018833d06913e9c830218dcafe6 Mon Sep 17 00:00:00 2001
From: Yassin Kortam
Date: Thu, 30 Jul 2026 12:11:26 -0700
Subject: [PATCH 17/33] fix(otel): record the GenAI duration metric on failed
requests (#35152)
* feat(otel): record the GenAI duration metric on failed requests
`_record_metrics` ran only from `async_log_success_event`, so
`gen_ai.client.operation.duration` counted only the requests that worked.
Latency read off it during an incident was the latency of the surviving
traffic, and with no error dimension anywhere there was no way to build a
failure-rate panel or a success/failure split per model.
A failed call now records the same duration histogram, tagged with the
semconv `error.type` (the mapped provider exception's class name, bounded by
construction; the message stays on the span). Success attributes are
untouched, so an existing query can still isolate the old series with
`error_type=""`. The other five instruments describe a completed generation
and are skipped rather than filled with a fabricated zero: litellm hands the
failure callback no `response_obj`, so there is no usage to split and no
completion-token count, and it zeroes `response_cost` on failure. A
proxy-gate rejection (auth / rate limit) records nothing, for the same
reason it gets no span; no upstream call happened.
`error.type` is stamped after the cardinality filter, like
`gen_ai.token.type`, so an `otel.attributes` include/exclude list cannot
strip the discriminator and silently merge failures into the success series.
Resolves LIT-4955
* fix(otel): bound the failure metric's attribute set
The failure datapoint reused the success path's full attribute set, which
carries client-supplied fields (`metadata.requester_metadata`,
`metadata.spend_logs_metadata`, the end-user id taken from the request's
`user` field) and per-request ones (the `hidden_params` blob holding the
provider's response headers). A failed request needs no provider spend, so
nothing rate-limits a caller who puts a unique value in a field they control
and mints one histogram series per request.
A failure now carries a bounded allowlist: the operation enum, provider,
request model, framework, the key/alias/team/org/user identifiers, and
`error.type`. Every entry is a fixed enum or an operator-provisioned
identifier, so the failure series count is bounded by the deployment's own
key, team and user count while the labels still answer which team on which
model is failing and how. The user email is left out as PII duplicating the
user id already on the series. The operator's `otel.attributes` filter layers
on top, so it narrows the allowlist further and never widens it.
* fix(otel): cap metric attributes so series count does not grow with traffic (#35166)
`GenAIMetricRecorder._common_attributes` dumped the whole `hidden_params` object
onto every metric datapoint as one label value. That object is per-request by
construction: `response_cost`, `litellm_overhead_time_ms`, `cache_key`,
`usage_object` and the provider's `additional_headers` rate-limit counters all
move on every call. A unique label value is a new time series, and all six GenAI
instruments share those attributes, so one request minted up to six series that
would never be written to again
That is the steady-state behavior of the feature rather than an abuse case, and
it is wrong twice over. Hosted backends bill on series count, so recommending
metrics be enabled would have meant a bill proportional to traffic. And a
histogram whose every datapoint sits in its own series cannot be aggregated, so
the dashboards would have looked populated while answering nothing
Both paths now cap their attributes at METRIC_ATTRIBUTE_CEILING, which replaces
the failure-only allowlist so the two paths cannot drift. The cap runs before the
operator's `otel.attributes` filter, so an operator can narrow it and never widen
it back to an unbounded label. Client-supplied and per-request metadata
(`requester_metadata`, `spend_logs_metadata`, `user_api_key_end_user_id`,
`requester_ip_address`) is metric-ineligible and stays on the span, which already
carries it and where cardinality is free. `hidden_params` survives as a label but
carries only `model_id` and `api_base`, which are bounded by the router's own
deployment list and are the part a per-deployment panel reads
Four tests fail against the previous behavior, the load-bearing one being that
two requests differing only in per-request fields must land in one series rather
than two
---
litellm/integrations/otel/README.md | 24 +-
litellm/integrations/otel/logger.py | 25 +-
litellm/integrations/otel/plumbing/metrics.py | 158 ++++++-
.../integrations/otel/test_otel_v2_metrics.py | 415 +++++++++++++++++-
4 files changed, 593 insertions(+), 29 deletions(-)
diff --git a/litellm/integrations/otel/README.md b/litellm/integrations/otel/README.md
index f318bb42b82..338afe04e5e 100644
--- a/litellm/integrations/otel/README.md
+++ b/litellm/integrations/otel/README.md
@@ -222,7 +222,29 @@ lives in [`plumbing/`](./plumbing):
otherwise the operator's globally configured `MeterProvider` is reused so its
readers/exporters receive them alongside the server metrics, and one is built
and registered as the global only when none is set (mirroring how V2 owns trace
- export).
+ export). A **failed** call records `gen_ai.client.operation.duration` too,
+ carrying the semconv `error.type` (the mapped provider exception's class name),
+ so the histogram covers the whole traffic and failure-rate panels are buildable;
+ the other five instruments describe a completed generation and are skipped
+ rather than filled with a fabricated zero. `error.type` is stamped after the
+ cardinality filter, so an `otel.attributes` list cannot merge the failure series
+ back into the success series. A proxy-gate rejection (auth / rate limit) records
+ nothing, for the same reason it gets no span: no upstream call happened.
+ Both paths cap their attributes at `METRIC_ATTRIBUTE_CEILING` before the
+ operator's own `otel.attributes` filter runs, so the filter can narrow the set
+ but never widen it. The ceiling is what keeps series count bounded by the
+ deployment's own key/team/user/deployment count instead of by its traffic: a
+ label value that moves per request mints a time series per request, which both
+ bills per request on a hosted backend and leaves a histogram that cannot be
+ aggregated. So client-supplied and per-request metadata (`requester_metadata`,
+ `spend_logs_metadata`, `user_api_key_end_user_id`, `requester_ip_address`) is
+ metric-ineligible and stays on the span, where cardinality is free, and the
+ `hidden_params` label carries only `model_id`, the deployment identity a
+ per-deployment panel joins on. `api_base` is excluded despite naming the same
+ deployment, because it is a documented per-call parameter and so is caller-chosen
+ in SDK use. Because the shared validator accepts every span attribute name, a
+ filter that names a metric-ineligible one logs a warning once when the filter
+ resolves rather than silently emitting nothing for it.
- [`events.py`](./plumbing/events.py) — GenAI client events. Gated on
`enable_events` (`LITELLM_OTEL_INTEGRATION_ENABLE_EVENTS`), a failed LLM call
records the semconv `gen_ai.client.operation.exception` log event at severity
diff --git a/litellm/integrations/otel/logger.py b/litellm/integrations/otel/logger.py
index b33973f0676..b8908696547 100644
--- a/litellm/integrations/otel/logger.py
+++ b/litellm/integrations/otel/logger.py
@@ -281,13 +281,29 @@ class OpenTelemetryV2(CustomLogger):
self._record_metrics(kwargs, response_obj, start_time, end_time)
def _record_metrics(self, kwargs, response_obj, start_time, end_time) -> None:
- """Record the GenAI metrics for a successful LLM call. Best-effort: a
- recording failure (e.g. a malformed payload) must never break the span
- close or the request itself."""
+ """Record the GenAI metrics for a successful LLM call."""
+ self._guarded_record(lambda recorder: recorder.record(kwargs, response_obj, start_time, end_time))
+
+ def _record_failure_metrics(self, kwargs, start_time, end_time) -> None:
+ """Record the GenAI metrics for a failed LLM call, so the duration
+ histogram covers the whole traffic rather than only what survived.
+
+ A synthetic proxy-gate log (auth / rate-limit rejection) is skipped for the
+ same reason it gets no span: no upstream call happened, so its duration is
+ not a GenAI operation's duration and would pull the histogram down."""
+ if LLMCallEvent.from_dict(kwargs).is_no_upstream_call:
+ return
+ self._guarded_record(lambda recorder: recorder.record_failure(kwargs, start_time, end_time))
+
+ def _guarded_record(self, record: "Callable[[GenAIMetricRecorder], None]") -> None:
+ """Run one metric recording. Best-effort: a recording failure (e.g. a
+ malformed payload) must never break the span close or the request itself. A
+ misconfigured attribute filter is operator-fixable, so it is surfaced once
+ at ERROR instead of being swallowed."""
if self._metrics_recorder is None:
return
try:
- self._metrics_recorder.record(kwargs, response_obj, start_time, end_time)
+ record(self._metrics_recorder)
except ValueError as exc:
if not self._metric_filter_error_logged:
verbose_logger.error(
@@ -304,6 +320,7 @@ class OpenTelemetryV2(CustomLogger):
if self._emit_mcp_list_tools(kwargs, start_time, end_time):
return
self._close_llm_call(kwargs, start_time, end_time)
+ self._record_failure_metrics(kwargs, start_time, end_time)
def _seed_identity_baggage(self, identity: RequestIdentity, model: str | None, context: Context) -> Context:
"""Seed authenticated request-identity Baggage onto ``context`` so the Baggage
diff --git a/litellm/integrations/otel/plumbing/metrics.py b/litellm/integrations/otel/plumbing/metrics.py
index 50d0fb75962..4d3c39e33d4 100644
--- a/litellm/integrations/otel/plumbing/metrics.py
+++ b/litellm/integrations/otel/plumbing/metrics.py
@@ -1,6 +1,6 @@
"""GenAI client metrics: the six ``gen_ai.client.*`` histograms plus the
recorder that builds attributes, applies the shared cardinality filter, and
-records a request's metrics in the success path.
+records a request's metrics on both the success and the failure path.
The instrument names/units/descriptions and the recording + timing math mirror
the v1 :mod:`litellm.integrations.opentelemetry` integration so both engines emit
@@ -10,11 +10,12 @@ identical metrics. The attribute cardinality filter is reused from v1 by import
from dataclasses import dataclass
from datetime import datetime
-from typing import Any, FrozenSet, Mapping, Optional
+from typing import Any, Final, FrozenSet, Mapping, Optional, TypeAlias
from opentelemetry.metrics import Histogram, Meter
import litellm
+from litellm._logging import verbose_logger
from litellm.integrations.opentelemetry import (
METRIC_METADATA_KEYS,
TOKEN_TYPE_ATTRIBUTE,
@@ -22,7 +23,7 @@ from litellm.integrations.opentelemetry import (
_resolve_metric_attribute_filter,
)
from litellm.integrations.otel.model.metadata import time_to_first_chunk_seconds
-from litellm.integrations.otel.model.semconv import Metric, resolve_operation
+from litellm.integrations.otel.model.semconv import Error, Metric, resolve_operation
from litellm.integrations.otel.model.utils import to_seconds
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
@@ -72,8 +73,82 @@ def create_genai_metrics(meter: Meter) -> GenAIMetrics:
)
+# A metric datapoint's attributes. Values are the strings the recorder builds, except
+# the request model, which is whatever the caller passed and may be absent.
+MetricAttributes: TypeAlias = Mapping[str, "str | None"]
+
+ERROR_TYPE_FALLBACK: Final = "_OTHER"
+
+# Every attribute a metric datapoint may carry, on either path. A label value that
+# is unique per request is a new time series that will never be written to again, so
+# this set is what keeps the series count bounded by the deployment's own
+# key/team/user/deployment count rather than by its traffic. Each entry is a fixed
+# enum or an operator-provisioned identifier.
+#
+# Deliberately excluded is everything the *client* supplies or that moves per
+# request: ``metadata.requester_metadata`` and ``metadata.spend_logs_metadata`` (both
+# free-form from the request body), ``metadata.user_api_key_end_user_id`` (the body's
+# ``user`` field), and ``metadata.requester_ip_address``. Those stay on the span,
+# where cardinality is free and where they already are.
+# ``metadata.user_api_key_user_email`` is left out too: it is bounded, but it is PII
+# duplicating the user id already here.
+#
+# This is a CEILING, applied before the operator's own include/exclude filter, so an
+# operator can narrow it but never widen it back to an unbounded attribute.
+METRIC_ATTRIBUTE_CEILING: Final[frozenset[str]] = frozenset(
+ (
+ "gen_ai.operation.name",
+ "gen_ai.system",
+ "gen_ai.request.model",
+ "gen_ai.framework",
+ "metadata.user_api_key_hash",
+ "metadata.user_api_key_alias",
+ "metadata.user_api_key_team_id",
+ "metadata.user_api_key_team_alias",
+ "metadata.user_api_key_org_id",
+ "metadata.user_api_key_user_id",
+ "hidden_params",
+ )
+)
+
+# The only ``hidden_params`` field that becomes part of the ``hidden_params`` label.
+# The object as a whole is per-request by construction -- ``response_cost``,
+# ``litellm_overhead_time_ms``, ``cache_key``, ``usage_object`` and the provider's
+# ``additional_headers`` rate-limit counters all move on every call -- so dumping it
+# whole made one series per request out of every instrument.
+#
+# ``model_id`` is the router's own deployment id, so it is bounded by the deployment
+# list and is what a per-deployment panel joins on. ``api_base`` is deliberately NOT
+# here even though it names the same thing: it is a documented per-call parameter, so
+# in SDK use it is chosen by the caller rather than provisioned by the operator, and a
+# caller varying it would put the per-request cardinality straight back.
+BOUNDED_HIDDEN_PARAM_KEYS: Final[tuple[str, ...]] = ("model_id",)
+
+
+def resolve_error_type(kwargs: Mapping[str, Any]) -> str:
+ """The ``error.type`` value for a failed request.
+
+ Bounded by construction: the mapped provider exception's class name (the same
+ ``error_information.error_class`` the failure span stamps), else the provider
+ status code, else the raw exception's class name, else ``_OTHER`` — the value
+ the convention reserves for a failure the instrumentation cannot classify. The
+ exception *message* is unbounded and never becomes a label; it stays on the
+ span and its exception event, where high cardinality is free.
+ """
+ std_log = kwargs.get("standard_logging_object")
+ info = getattr(std_log, "error_information", None) or (std_log or {}).get("error_information") or {}
+ error_class = info.get("error_class") or info.get("error_code")
+ if error_class:
+ return str(error_class)
+ exception = kwargs.get("exception")
+ if exception is not None:
+ return type(exception).__name__
+ return ERROR_TYPE_FALLBACK
+
+
class GenAIMetricRecorder:
- """Records the six GenAI histograms for one successful LLM call.
+ """Records the six GenAI histograms for one successful LLM call, and the
+ duration histogram alone for one failed LLM call (see :meth:`record_failure`).
The cardinality filter is resolved lazily on the first record: the proxy
populates ``callback_settings.otel.attributes`` after the logger is built, so
@@ -96,7 +171,7 @@ class GenAIMetricRecorder:
start_time: datetime,
end_time: datetime,
) -> None:
- common_attrs = self._filter_attributes(self._common_attributes(kwargs))
+ common_attrs = self._filter_attributes(self._bounded_attributes(kwargs))
duration_s = (end_time - start_time).total_seconds()
self._metrics.operation_duration.record(duration_s, attributes=common_attrs)
@@ -110,6 +185,38 @@ class GenAIMetricRecorder:
self._record_time_per_output_token(kwargs, response_obj, end_time, duration_s, common_attrs)
self._record_response_duration(kwargs, end_time, common_attrs)
+ def record_failure(
+ self,
+ kwargs: Mapping[str, Any],
+ start_time: datetime,
+ end_time: datetime,
+ ) -> None:
+ """Record the one metric a failed request can honestly report: the
+ operation's duration, tagged with ``error.type``.
+
+ The other five instruments all describe a completed generation and have
+ nothing to measure here. litellm hands the failure callback no
+ ``response_obj`` at all, so there is no usage to split into input/output
+ tokens and no completion-token count to divide generation time by; it also
+ zeroes ``response_cost`` on failure. Recording them anyway would put a
+ fabricated zero into series that dashboards average.
+
+ The attribute set is :data:`METRIC_ATTRIBUTE_CEILING`, the same cap the
+ success path uses. A failure needs no provider spend, so a caller who can put
+ a unique value into a client-supplied attribute could mint one histogram
+ series per request for free; the cap is what makes that impossible on either
+ path.
+
+ ``error.type`` is stamped after both filters, exactly like
+ ``gen_ai.token.type``, so an operator's include/exclude list cannot strip
+ the discriminator and silently merge failures back into the success series.
+ """
+ attributes = {
+ **self._filter_attributes(self._bounded_attributes(kwargs)),
+ Error.TYPE: resolve_error_type(kwargs),
+ }
+ self._metrics.operation_duration.record((end_time - start_time).total_seconds(), attributes=attributes)
+
# ------------------------------------------------------------------ #
# Attribute building + cardinality filter
# ------------------------------------------------------------------ #
@@ -136,11 +243,25 @@ class GenAIMetricRecorder:
common_attrs[f"metadata.{key}"] = str(value)
hidden_params = getattr(std_log, "hidden_params", None) or (std_log or {}).get("hidden_params", {})
- if hidden_params:
- common_attrs["hidden_params"] = safe_dumps(hidden_params)
+ bounded_hidden_params = {
+ key: hidden_params[key]
+ for key in BOUNDED_HIDDEN_PARAM_KEYS
+ if isinstance(hidden_params, Mapping) and hidden_params.get(key) is not None
+ }
+ if bounded_hidden_params:
+ common_attrs["hidden_params"] = safe_dumps(bounded_hidden_params)
return common_attrs
+ def _bounded_attributes(self, kwargs: Mapping[str, Any]) -> MetricAttributes:
+ """The datapoint attributes, capped at :data:`METRIC_ATTRIBUTE_CEILING`.
+
+ The cap runs BEFORE the operator's include/exclude filter so the filter can
+ only narrow it. An operator who names an excluded attribute in an include
+ list gets nothing for it rather than reintroducing an unbounded label.
+ """
+ return {k: v for k, v in self._common_attributes(kwargs).items() if k in METRIC_ATTRIBUTE_CEILING}
+
def _ensure_filter(self) -> None:
if self._filter_resolved:
return
@@ -157,8 +278,29 @@ class GenAIMetricRecorder:
# without reconstructing the recorder.
self._include, self._exclude = _resolve_metric_attribute_filter(attributes)
self._filter_resolved = True
+ self._warn_about_metric_ineligible_names()
- def _filter_attributes(self, attrs: dict) -> dict:
+ def _warn_about_metric_ineligible_names(self) -> None:
+ """Say so when the operator's filter names an attribute the ceiling removes.
+
+ The shared validator accepts every span attribute name, so a name that is
+ legal on a span but metric-ineligible would otherwise be a silent no-op: an
+ ``include_list`` naming it emits nothing for it and an ``exclude_list`` naming
+ it looks like it worked. Logged once, when the filter resolves, rather than
+ per request.
+ """
+ named = (self._include or frozenset()) | (self._exclude or frozenset())
+ ineligible = sorted(named - METRIC_ATTRIBUTE_CEILING - {TOKEN_TYPE_ATTRIBUTE})
+ if ineligible:
+ verbose_logger.warning(
+ "OTel metrics: %s cannot be a metric attribute and is being ignored; it varies "
+ "per request or is client-supplied, so it would make one time series per request. "
+ "It is still on the span. Metric attributes are limited to: %s",
+ ", ".join(ineligible),
+ ", ".join(sorted(METRIC_ATTRIBUTE_CEILING)),
+ )
+
+ def _filter_attributes(self, attrs: MetricAttributes) -> MetricAttributes:
self._ensure_filter()
if self._include is not None:
return {k: v for k, v in attrs.items() if k in self._include}
diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_metrics.py b/tests/test_litellm/integrations/otel/test_otel_v2_metrics.py
index b2d89053ba3..56607414de4 100644
--- a/tests/test_litellm/integrations/otel/test_otel_v2_metrics.py
+++ b/tests/test_litellm/integrations/otel/test_otel_v2_metrics.py
@@ -12,9 +12,17 @@ raises out of ``GenAIMetricRecorder.record`` -- asserted directly at the recorde
layer -- and the logger turns that raise into a single ERROR ("metrics disabled")
plus a quiet no-op for the rest of the process, asserted at the logger layer so
the misconfig never breaks a request nor spams a log line per request.
+
+The failure path is driven the same way, through the real
+``OpenTelemetryV2.async_log_failure_event``: a failed call records
+``gen_ai.client.operation.duration`` and nothing else, tagged with ``error.type``,
+and a success driven through the same reader keeps a datapoint whose attributes are
+byte-for-byte what it had before the failure path existed -- the guard for every
+dashboard already querying that histogram.
"""
import asyncio
+import json
from datetime import datetime, timedelta
import pytest
@@ -25,6 +33,9 @@ from opentelemetry.sdk.metrics import MeterProvider # noqa: E402
from opentelemetry.sdk.metrics.export import InMemoryMetricReader # noqa: E402
import litellm # noqa: E402
+from litellm.constants import ( # noqa: E402
+ LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL,
+)
from litellm.integrations.otel.logger import OpenTelemetryV2 # noqa: E402
from litellm.integrations.otel.model.config import ( # noqa: E402
OpenTelemetryV2Config,
@@ -58,14 +69,13 @@ ALL_METRICS = frozenset(
TOKEN_TYPE = "gen_ai.token.type"
MODEL_KEY = "gen_ai.request.model"
-# Each is a member of VALID_METRIC_ATTRIBUTE_NAMES and is stamped on the metric
-# by default (proven by the no-filter test below).
-HIGH_CARDINALITY_KEYS = (
+# Keys inside the ceiling that an operator's filter must still be able to remove.
+# Every one is bounded, so it survives the ceiling and only the operator's own
+# exclude_list takes it off; that is what makes the filter tests non-vacuous.
+FILTERABLE_KEYS = (
"hidden_params",
"metadata.user_api_key_hash",
- "metadata.requester_ip_address",
- "metadata.requester_metadata",
- "metadata.applied_guardrails",
+ "metadata.user_api_key_team_id",
)
PROMPT_TOKENS = 137
@@ -93,6 +103,7 @@ def _build_call(stream: bool = True):
"standard_logging_object": {
"metadata": {
"user_api_key_hash": "hash-abc123",
+ "user_api_key_team_id": "team-1",
"requester_ip_address": "10.0.0.7",
"requester_metadata": {"team": "alpha", "tier": "gold"},
"applied_guardrails": ["pii", "toxicity"],
@@ -205,14 +216,14 @@ def test_metrics_off_by_default_records_nothing():
def test_exclude_list_strips_high_cardinality_across_metrics():
- """exclude_list set AFTER construction (the proxy path) removes every
- high-cardinality key from more than one metric while the low-cardinality
- model attribute survives."""
+ """exclude_list set AFTER construction (the proxy path) removes every listed
+ key from more than one metric while the low-cardinality model attribute
+ survives."""
metrics = _drive_success(
InMemoryMetricReader(),
- callback_settings_attributes={"exclude_list": list(HIGH_CARDINALITY_KEYS)},
+ callback_settings_attributes={"exclude_list": list(FILTERABLE_KEYS)},
)
- excluded = set(HIGH_CARDINALITY_KEYS)
+ excluded = set(FILTERABLE_KEYS)
for name in (OPERATION_DURATION, TOKEN_USAGE):
points = metrics[name]
@@ -241,18 +252,132 @@ def test_include_list_allows_only_listed_attributes():
assert set(dp.attributes.keys()) - {TOKEN_TYPE} == allowed
-def test_no_filter_keeps_high_cardinality_keys():
- """Backward compatibility: without an attributes config every high-cardinality
- key the call carries is still stamped, so the filter tests above prove a real
- removal rather than a key that was never present."""
+def test_no_filter_still_keeps_the_filterable_keys():
+ """Without an attributes config every key the filter tests remove is present,
+ so those tests prove a real removal rather than a key that was never there."""
metrics = _drive_success(InMemoryMetricReader())
- expected = set(HIGH_CARDINALITY_KEYS)
+ expected = set(FILTERABLE_KEYS)
for name in (OPERATION_DURATION, TOKEN_USAGE):
for dp in metrics[name]:
assert expected.issubset(set(dp.attributes.keys()))
+def test_a_metric_ineligible_filter_name_is_reported_not_silently_dropped(caplog):
+ """Naming a metric-ineligible attribute in a filter has to say so.
+
+ The shared validator accepts every span attribute name, so an operator can put
+ one in an ``include_list``, get nothing for it, and have no way to tell that from
+ a value that happened to be absent. The ceiling is deliberate, but silent is what
+ makes it a support ticket.
+ """
+ with caplog.at_level("WARNING"):
+ _drive_success(
+ InMemoryMetricReader(),
+ callback_settings_attributes={
+ "include_list": [MODEL_KEY, "metadata.requester_ip_address"]
+ },
+ )
+
+ reported = [
+ r.getMessage().split(" cannot be a metric attribute")[0].removeprefix("OTel metrics: ")
+ for r in caplog.records
+ if r.levelname == "WARNING" and "cannot be a metric attribute" in r.getMessage()
+ ]
+ assert reported == ["metadata.requester_ip_address"], reported
+
+
+def test_two_calls_differing_only_per_request_share_one_series():
+ """The whole point of the ceiling: metric cardinality must not grow with traffic.
+
+ Every field here moves on every real request -- the response cost, the call id,
+ the cache key, the provider's remaining-rate-limit headers -- and each one used
+ to reach the datapoint inside a single ``hidden_params`` label. A unique label
+ value is a new time series, so each of the six instruments minted one series per
+ request, which is both a Grafana Cloud bill proportional to traffic and a
+ histogram that cannot be aggregated. Identical attribute sets is what "one
+ series" means to the SDK.
+ """
+ reader = InMemoryMetricReader()
+ logger = _logger(reader, enable_metrics=True)
+
+ for index, cost in enumerate((RESPONSE_COST, RESPONSE_COST * 3)):
+ kwargs, response_obj, start, end = _build_call()
+ kwargs["response_cost"] = cost
+ kwargs["standard_logging_object"]["hidden_params"] = {
+ "model_id": "m-1",
+ # A documented per-call parameter, so it varies here on purpose: the same
+ # deployment reached under a caller-chosen base must not split the series.
+ "api_base": f"https://proxy-{index}.example.com/v1",
+ "litellm_call_id": f"call-{index}",
+ "cache_key": f"cache-{index}",
+ "response_cost": cost,
+ "litellm_overhead_time_ms": 1.5 + index,
+ "usage_object": {"prompt_tokens": index, "completion_tokens": index},
+ "additional_headers": {"x_ratelimit_remaining_requests": 100 - index},
+ }
+ asyncio.run(logger.async_log_success_event(kwargs, response_obj, start, end))
+
+ for name in ALL_METRICS:
+ attribute_sets = {
+ tuple(sorted((k, v) for k, v in dp.attributes.items() if k != TOKEN_TYPE))
+ for dp in _metrics_by_name(reader)[name]
+ }
+ assert len(attribute_sets) == 1, f"{name} split into {len(attribute_sets)} series across 2 requests"
+
+
+def test_hidden_params_label_carries_only_bounded_deployment_fields():
+ """``hidden_params`` survives the ceiling, but only as the deployment identity.
+
+ ``model_id`` is the router's deployment id, bounded by the deployment list, and is
+ what a per-deployment dashboard reads. Everything else in the object is
+ per-request or caller-chosen and belongs on the span, which already carries it.
+ ``api_base`` is excluded despite naming the same deployment: it is a documented
+ per-call parameter, so a caller varying it would restore the per-request
+ cardinality this cap exists to remove.
+ """
+ kwargs, response_obj, start, end = _build_call()
+ kwargs["standard_logging_object"]["hidden_params"] = {
+ "model_id": "m-1",
+ "api_base": "https://api.openai.com/v1",
+ "litellm_call_id": "abc",
+ "cache_key": "ck-1",
+ "response_cost": RESPONSE_COST,
+ }
+ reader = InMemoryMetricReader()
+ logger = _logger(reader, enable_metrics=True)
+ asyncio.run(logger.async_log_success_event(kwargs, response_obj, start, end))
+
+ label = _metrics_by_name(reader)[OPERATION_DURATION][0].attributes["hidden_params"]
+ assert json.loads(label) == {"model_id": "m-1"}
+
+
+def test_success_attributes_are_capped_at_the_ceiling():
+ """The success path carries exactly the ceiling, no client-supplied attributes.
+
+ The fixture deliberately sets every excluded key, so this asserts a real removal
+ rather than keys that were never present.
+ """
+ kwargs, response_obj, start, end = _build_call()
+ metadata = kwargs["standard_logging_object"]["metadata"]
+ metadata.update(
+ {
+ "spend_logs_metadata": {"cost_center": "abc"},
+ "user_api_key_end_user_id": "end-user-1",
+ "user_api_key_user_email": "someone@example.com",
+ }
+ )
+ reader = InMemoryMetricReader()
+ logger = _logger(reader, enable_metrics=True)
+ asyncio.run(logger.async_log_success_event(kwargs, response_obj, start, end))
+ metrics = _metrics_by_name(reader)
+
+ for name in ALL_METRICS:
+ for dp in metrics[name]:
+ leaked = set(dp.attributes) - set(BOUNDED_KEYS) - {TOKEN_TYPE}
+ assert not leaked, f"{name} leaked {leaked}"
+
+
def test_metrics_reach_operator_configured_global_provider(monkeypatch):
"""Regression: with no meter provider injected, the six gen_ai.client.*
histograms must record through the operator's globally configured
@@ -331,3 +456,261 @@ def test_token_type_rejected_from_either_list(attributes, monkeypatch):
# the specific reason so dropping that guard (and falling through to "unknown
# attribute name") is caught.
assert "discriminator" in str(exc_info.value)
+
+
+# --- failure path ------------------------------------------------------------ #
+
+ERROR_TYPE = "error.type"
+ERROR_CLASS = "RateLimitError"
+FAILURE_DURATION_S = 1.0
+
+# Attributes a failure datapoint must never carry. Each is either supplied by the
+# caller (so a caller could mint a fresh series per request, and a failure costs
+# them no provider spend) or varies per request, or is PII duplicating an id that
+# is already on the series.
+UNBOUNDED_KEYS = (
+ "metadata.requester_metadata",
+ "metadata.requester_ip_address",
+ "metadata.spend_logs_metadata",
+ "metadata.user_api_key_end_user_id",
+ "metadata.user_api_key_user_email",
+)
+
+# The exact set a datapoint may carry on either path: the operation, the
+# operator-provisioned identity, and the deployment that served it.
+BOUNDED_KEYS = (
+ "hidden_params",
+ "gen_ai.operation.name",
+ "gen_ai.system",
+ "gen_ai.request.model",
+ "gen_ai.framework",
+ "metadata.user_api_key_hash",
+ "metadata.user_api_key_alias",
+ "metadata.user_api_key_team_id",
+ "metadata.user_api_key_team_alias",
+ "metadata.user_api_key_org_id",
+ "metadata.user_api_key_user_id",
+)
+
+
+def _build_failure(
+ *,
+ error_information=None,
+ exception=None,
+ no_upstream_call=False,
+):
+ """A captured failure-call ``(kwargs, start, end)``.
+
+ Mirrors what litellm actually hands ``async_log_failure_event``: no
+ ``response_obj`` at all, but the streaming timings and the recovered
+ ``response_cost`` a mid-stream failure still carries -- so routing the failure
+ path through the full success recorder would show up here as extra series
+ rather than passing unnoticed. The metadata carries both the bounded identity
+ keys and every caller-supplied / per-request key, so the allowlist test below
+ proves a real removal rather than a key that was never there.
+ """
+ start = datetime(2026, 6, 12, 12, 0, 0)
+ api_call_start = start + timedelta(seconds=0.1)
+ completion_start = start + timedelta(seconds=0.5)
+ end = start + timedelta(seconds=FAILURE_DURATION_S)
+ standard_logging_object = {
+ "status": "failure",
+ "metadata": {
+ "user_api_key_hash": "hash-abc123",
+ "user_api_key_alias": "alias-abc",
+ "user_api_key_team_id": "team-1",
+ "user_api_key_team_alias": "team-alpha",
+ "user_api_key_org_id": "org-1",
+ "user_api_key_user_id": "user-1",
+ "user_api_key_user_email": "user@example.com",
+ "user_api_key_end_user_id": "end-user-42",
+ "requester_ip_address": "10.0.0.7",
+ "requester_metadata": {"trace": "caller-supplied-unique-value"},
+ "spend_logs_metadata": {"ticket": "caller-supplied-unique-value"},
+ },
+ "hidden_params": {
+ "litellm_call_id": "abc",
+ "model_id": "m-1",
+ "api_base": "https://api.openai.com/v1",
+ },
+ }
+ if error_information is not None:
+ standard_logging_object["error_information"] = error_information
+ kwargs = {
+ "model": "gpt-4o-mini",
+ "call_type": "completion",
+ "litellm_params": {"custom_llm_provider": "openai"},
+ "optional_params": {"stream": True},
+ "response_cost": RESPONSE_COST,
+ "api_call_start_time": api_call_start,
+ "completion_start_time": completion_start,
+ "end_time": end,
+ "standard_logging_object": standard_logging_object,
+ }
+ if exception is not None:
+ kwargs["exception"] = exception
+ if no_upstream_call:
+ kwargs[LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL] = True
+ return kwargs, start, end
+
+
+def _drive_failure(reader, callback_settings_attributes=None, **failure_kwargs):
+ logger = _logger(reader, enable_metrics=True)
+ previous = litellm.callback_settings
+ if callback_settings_attributes is not None:
+ litellm.callback_settings = {"otel": {"attributes": callback_settings_attributes}}
+ try:
+ kwargs, start, end = _build_failure(**failure_kwargs)
+ asyncio.run(logger.async_log_failure_event(kwargs, None, start, end))
+ finally:
+ litellm.callback_settings = previous
+ return _metrics_by_name(reader)
+
+
+def test_failure_records_only_the_duration_histogram():
+ """A failed call contributes to gen_ai.client.operation.duration -- before this
+ existed a failure recorded nothing at all, so the histogram measured only the
+ traffic that survived. It contributes to nothing else: the other five
+ instruments describe a completed generation, and the call carries a streaming
+ timing pair and a recovered response_cost that would light four of them up if
+ the failure were routed through the success recorder."""
+ metrics = _drive_failure(
+ InMemoryMetricReader(),
+ error_information={"error_class": ERROR_CLASS, "error_code": "429"},
+ )
+
+ assert set(metrics.keys()) == {OPERATION_DURATION}
+ points = metrics[OPERATION_DURATION]
+ assert len(points) == 1
+ assert points[0].count == 1
+ assert points[0].sum == pytest.approx(FAILURE_DURATION_S)
+ assert points[0].attributes[ERROR_TYPE] == ERROR_CLASS
+
+
+def test_success_and_failure_are_separable_and_success_attributes_unchanged():
+ """The pooled histogram stays queryable per outcome, and the existing
+ dashboards keep working.
+
+ A success and a failure through one reader must land on two distinct series --
+ one with error.type, one without -- so a failure-rate panel is expressible and
+ an operator can still get success-only latency by filtering error.type="". The
+ success datapoint's attribute map must be byte-for-byte the map a success-only
+ run produces, which is what stops the new attribute from leaking onto the
+ series every current query reads."""
+ baseline_reader = InMemoryMetricReader()
+ baseline = _drive_success(baseline_reader)
+ baseline_points = baseline[OPERATION_DURATION]
+ assert len(baseline_points) == 1
+ baseline_attributes = dict(baseline_points[0].attributes)
+
+ reader = InMemoryMetricReader()
+ logger = _logger(reader, enable_metrics=True)
+ ok_kwargs, response_obj, ok_start, ok_end = _build_call()
+ asyncio.run(logger.async_log_success_event(ok_kwargs, response_obj, ok_start, ok_end))
+ bad_kwargs, bad_start, bad_end = _build_failure(error_information={"error_class": ERROR_CLASS})
+ asyncio.run(logger.async_log_failure_event(bad_kwargs, None, bad_start, bad_end))
+
+ points = metrics = _metrics_by_name(reader)[OPERATION_DURATION]
+ assert len(points) == 2, f"success and failure collapsed into {len(points)} series: {metrics}"
+ succeeded = [dp for dp in points if ERROR_TYPE not in dp.attributes]
+ failed = [dp for dp in points if dp.attributes.get(ERROR_TYPE) == ERROR_CLASS]
+ assert len(succeeded) == 1 and len(failed) == 1
+ assert dict(succeeded[0].attributes) == baseline_attributes
+
+
+def test_failure_attributes_are_a_bounded_allowlist():
+ """A failure datapoint carries exactly the bounded allowlist plus error.type.
+
+ A failed request needs no provider spend, so nothing rate-limits a caller who
+ puts a unique value into an attribute they control and mints one histogram
+ series per request. The same payload is driven through the success path first,
+ which does carry those keys, so this asserts a real removal on the failure path
+ rather than keys that were never present. The exact-set assertion is the guard
+ against the natural refactor of "just reuse _common_attributes"."""
+ reader = InMemoryMetricReader()
+ logger = _logger(reader, enable_metrics=True)
+ kwargs, start, end = _build_failure(error_information={"error_class": ERROR_CLASS})
+ usage = {"usage": {"prompt_tokens": 1, "completion_tokens": 1}}
+ asyncio.run(logger.async_log_success_event(kwargs, usage, start, end))
+ asyncio.run(logger.async_log_failure_event(kwargs, None, start, end))
+
+ points = _metrics_by_name(reader)[OPERATION_DURATION]
+ succeeded = next(dp for dp in points if ERROR_TYPE not in dp.attributes)
+ failed = next(dp for dp in points if ERROR_TYPE in dp.attributes)
+
+ supplied = set(kwargs["standard_logging_object"]["metadata"])
+ missing = {key for key in UNBOUNDED_KEYS if key.removeprefix("metadata.") not in supplied}
+ assert not missing, f"fixture never carried {missing}, so the exclusion below proves nothing"
+ leaked = set(UNBOUNDED_KEYS) & set(failed.attributes)
+ assert not leaked, f"failure datapoint leaked unbounded attributes: {leaked}"
+ assert set(failed.attributes) == set(BOUNDED_KEYS) | {ERROR_TYPE}
+ assert json.loads(failed.attributes["hidden_params"]) == {"model_id": "m-1"}
+
+
+def test_operator_filter_can_still_narrow_the_failure_allowlist():
+ """The allowlist is a ceiling, not a floor: an exclude_list an operator sets
+ still removes a listed key from the failure series."""
+ metrics = _drive_failure(
+ InMemoryMetricReader(),
+ callback_settings_attributes={"exclude_list": ["metadata.user_api_key_hash"]},
+ error_information={"error_class": ERROR_CLASS},
+ )
+ attributes = metrics[OPERATION_DURATION][0].attributes
+ assert "metadata.user_api_key_hash" not in attributes
+ assert attributes[ERROR_TYPE] == ERROR_CLASS
+ assert attributes[MODEL_KEY] == "gpt-4o-mini"
+
+
+@pytest.mark.parametrize(
+ "failure_kwargs, expected",
+ [
+ ({"error_information": {"error_class": ERROR_CLASS, "error_code": "429"}}, ERROR_CLASS),
+ ({"error_information": {"error_code": "429"}}, "429"),
+ ({"exception": ValueError("boom")}, "ValueError"),
+ ({}, "_OTHER"),
+ ],
+ ids=["error_class", "error_code_only", "exception_fallback", "unclassifiable"],
+)
+def test_error_type_is_bounded_and_falls_back(failure_kwargs, expected):
+ """error.type is always a bounded value: the mapped exception's class name, the
+ provider status code, the raw exception's class name, or the semconv _OTHER
+ fallback. Never the exception message, which is unbounded."""
+ metrics = _drive_failure(InMemoryMetricReader(), **failure_kwargs)
+ assert metrics[OPERATION_DURATION][0].attributes[ERROR_TYPE] == expected
+
+
+def test_include_list_cannot_strip_error_type():
+ """error.type is a structural discriminator like gen_ai.token.type: an
+ include_list that does not mention it must not merge the failure series back
+ into the success series, so it is stamped after the filter runs."""
+ metrics = _drive_failure(
+ InMemoryMetricReader(),
+ callback_settings_attributes={"include_list": [MODEL_KEY]},
+ error_information={"error_class": ERROR_CLASS},
+ )
+ attributes = metrics[OPERATION_DURATION][0].attributes
+ assert dict(attributes) == {MODEL_KEY: "gpt-4o-mini", ERROR_TYPE: ERROR_CLASS}
+
+
+def test_proxy_gate_rejection_records_no_duration():
+ """A synthetic proxy-gate failure log (auth / rate-limit rejection) never made
+ an upstream call, so its wall time is not a GenAI operation's duration; it is
+ skipped for the same reason it gets no span. Recording it would pull the
+ histogram toward the proxy's own latency.
+
+ Both failures go through one reader so the assertion is that exactly the
+ upstream one landed, rather than the vacuous "nothing was recorded" a
+ failure path that records nothing at all would also satisfy."""
+ reader = InMemoryMetricReader()
+ logger = _logger(reader, enable_metrics=True)
+ gate_kwargs, gate_start, gate_end = _build_failure(
+ error_information={"error_class": "AuthenticationError"},
+ no_upstream_call=True,
+ )
+ asyncio.run(logger.async_log_failure_event(gate_kwargs, None, gate_start, gate_end))
+ upstream_kwargs, upstream_start, upstream_end = _build_failure(error_information={"error_class": ERROR_CLASS})
+ asyncio.run(logger.async_log_failure_event(upstream_kwargs, None, upstream_start, upstream_end))
+
+ points = _metrics_by_name(reader)[OPERATION_DURATION]
+ assert [dp.attributes[ERROR_TYPE] for dp in points] == [ERROR_CLASS]
+ assert points[0].count == 1
From 4eecf7a050e7acea105b94a819520d85933c4e7c Mon Sep 17 00:00:00 2001
From: yuneng-jiang
Date: Thu, 30 Jul 2026 12:12:13 -0700
Subject: [PATCH 18/33] bump: litellm 1.95.0 -> 1.96.0 (#35254)
---
pyproject.toml | 4 ++--
uv.lock | 4 ++--
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index e15bf1351dd..93fb32da464 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "litellm"
-version = "1.95.0"
+version = "1.96.0"
description = "Library to easily interface with LLM API providers"
readme = "README.md"
requires-python = ">=3.10, <3.15"
@@ -302,7 +302,7 @@ members = ["enterprise", "litellm-proxy-extras"]
profile = "black"
[tool.commitizen]
-version = "1.95.0"
+version = "1.96.0"
version_files = [
"pyproject.toml:^version",
]
diff --git a/uv.lock b/uv.lock
index 08d10667fb1..d30f2df0a0e 100644
--- a/uv.lock
+++ b/uv.lock
@@ -10,7 +10,7 @@ resolution-markers = [
]
[options]
-exclude-newer = "2026-07-24T16:43:28.506903Z"
+exclude-newer = "2026-07-27T18:40:42.08538Z"
exclude-newer-span = "P3D"
[manifest]
@@ -4116,7 +4116,7 @@ wheels = [
[[package]]
name = "litellm"
-version = "1.95.0"
+version = "1.96.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
From 23bf657112f1cc9c6caf58ba02e1fdf519590fb6 Mon Sep 17 00:00:00 2001
From: mateo
Date: Thu, 30 Jul 2026 19:36:36 +0000
Subject: [PATCH 19/33] docs(claude): require 15-25 word human-readable replies
to AI PR review bots
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
CLAUDE.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/CLAUDE.md b/CLAUDE.md
index 1a4826d51e9..20610ca9586 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -61,6 +61,8 @@ Do not add `Co-Authored-By: Claude` or any Claude attribution to commit messages
When working on a PR, keep the PR description in sync with new commits being made
+When replying to or rebutting an AI PR review bot (Devin Review, CodeRabbit, Copilot, etc.), keep each reply between 15 and 25 words of plain, human-readable prose; no walls of text, no bullet lists, no restating the bot's comment back at it
+
Monkeypatching attributes of a class to do testing is an anti-pattern. Prefer dependency-injecting things into classes. That way, at unit test time, you can pass a mocked dependency in
Do not put names of customers or customer company names in code, PR descriptions, issue bodies, etc. This means never mention literally any company name. Especially if you're about to say a sentence mentioning that the reason the PR exists was a feature/model/bug fix/etc. requested by a company. That's the indication that you should replace that company name with "the customer". e.g. not "Model request from Acme (Pylon #1234)" but "Model request from a customer (Pylon #1234)". This is because the codebase is public. The only exception is for publicly known providers or vendors such as OpenAI, Anthropic, AWS Bedrock, etc. only IF we're adding support for that provider/vendor in general and NOT if that PR or whatnot was a request by one of them, and they're actually one of our customers.
From d33e6fe16c7d2185dc9016608b329c4913f7ad15 Mon Sep 17 00:00:00 2001
From: Mateo Wang <277851410+mateo-berri@users.noreply.github.com>
Date: Thu, 30 Jul 2026 12:37:58 -0700
Subject: [PATCH 20/33] chore: make it concise
---
CLAUDE.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 20610ca9586..c3c8138d2ac 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -61,7 +61,7 @@ Do not add `Co-Authored-By: Claude` or any Claude attribution to commit messages
When working on a PR, keep the PR description in sync with new commits being made
-When replying to or rebutting an AI PR review bot (Devin Review, CodeRabbit, Copilot, etc.), keep each reply between 15 and 25 words of plain, human-readable prose; no walls of text, no bullet lists, no restating the bot's comment back at it
+Replies/rebuttals to AI PR review bots must be 15-25 word human-readable replies
Monkeypatching attributes of a class to do testing is an anti-pattern. Prefer dependency-injecting things into classes. That way, at unit test time, you can pass a mocked dependency in
From 2756695258f75607e05a03ac39b3bfd46a66b83f Mon Sep 17 00:00:00 2001
From: Yuneng Jiang
Date: Thu, 30 Jul 2026 12:41:36 -0700
Subject: [PATCH 21/33] fix(ui): clamp table ID cells to the cell box instead
of a fixed 15ch
IdCell truncated with `block max-w-[15ch]`, a character-count clamp that
ignores how much room the column actually has. On the budgets table the
Budget ID column renders 509px wide at a 1400px container while the ID
itself was pinned to 108px, so every UUID showed an ellipsis with roughly
400px of empty space beside it. The same held at 900px and 520px
containers; the clamp never moved because it was never a function of the
available width
Switch to `inline-block max-w-full truncate`, the standard CSS idiom for
shrink-to-fit text that ellipsizes at its container. IDs now render in
full whenever the column has room and clip at the cell edge when it does
not. `inline-block` keeps the pill variant sized to its content rather
than stretching the blue background across the column, which a plain
`block` would do once the character clamp is gone
Measured in Chrome across 1400/900/520px containers and both variants:
row height is unchanged, short IDs shrink from a padded 108px to 51px
(plain) and 67px (pill), and the 36-char UUID renders fully at 260px
---
.../src/components/shared/table_cells/id_cell.test.tsx | 10 +++++++++-
.../src/components/shared/table_cells/id_cell.tsx | 2 +-
2 files changed, 10 insertions(+), 2 deletions(-)
diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/id_cell.test.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/id_cell.test.tsx
index 1a87f17d50b..41da4519018 100644
--- a/ui/litellm-dashboard/src/components/shared/table_cells/id_cell.test.tsx
+++ b/ui/litellm-dashboard/src/components/shared/table_cells/id_cell.test.tsx
@@ -28,10 +28,18 @@ describe("IdCell", () => {
expect(el.tagName).toBe("SPAN");
expect(el.className).toContain("bg-blue-50");
expect(el.className).toContain("font-mono");
- expect(el.className).toContain("max-w-[15ch]");
+ expect(el.className).toContain("max-w-full");
expect(el.className).toContain("truncate");
});
+ it("clamps to the containing cell rather than a fixed character count", () => {
+ render( );
+ const el = screen.getByText("ecc1869c-6231-4380-a56d-1a0be457477d");
+ expect(el.className).not.toMatch(/max-w-\[\d+(ch|rem|px)\]/);
+ expect(el.className).toContain("inline-block");
+ expect(el.className).toContain("max-w-full");
+ });
+
it("renders plain mono text without pill styling for the plain variant", () => {
render( );
const el = screen.getByText("req-123");
diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/id_cell.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/id_cell.tsx
index 6fbd2e2f9ed..c8b75ee96e9 100644
--- a/ui/litellm-dashboard/src/components/shared/table_cells/id_cell.tsx
+++ b/ui/litellm-dashboard/src/components/shared/table_cells/id_cell.tsx
@@ -54,7 +54,7 @@ export function IdCell({
const classes = cn(
VARIANT_CLASS[variant].base,
clickable && VARIANT_CLASS[variant].clickable,
- truncate && "block max-w-[15ch] truncate",
+ truncate && "inline-block max-w-full truncate",
disabled && "opacity-50",
className,
);
From 6f1625d23bb1649394ad59c6a98c31633a1ba924 Mon Sep 17 00:00:00 2001
From: yuneng-jiang
Date: Thu, 30 Jul 2026 13:19:49 -0700
Subject: [PATCH 22/33] revert(proxy)!: stop enforcing user budget on team keys
(#35271)
Reverts #32005. Team-scoped keys are governed by the team and team-member
budgets only; the key owner personal max_budget no longer applies to them,
restoring the hierarchy that existed before that PR.
The skip_user_budget_on_team_key opt-out existed solely to turn the new
behavior back off, so it is removed along with the behavior: the
ConfigGeneralSettings field, the /config/list allowed_args entry that
surfaced it as an Admin UI toggle, and the argument threaded through
reserve_budget_for_request and _get_budget_counters.
Regression tests cover both enforcement points in the restored direction:
test_common_checks_personal_user_budget_skipped_for_team_key for the
read-time check and test_should_not_reserve_user_budget_counter_for_team_key
for the optimistic reservation path.
---
litellm/proxy/_types.py | 10 ----
litellm/proxy/auth/auth_checks.py | 41 ++++++-------
litellm/proxy/auth/user_api_key_auth.py | 1 -
litellm/proxy/proxy_server.py | 1 -
.../spend_tracking/budget_reservation.py | 6 +-
.../test_user_api_key_auth.py | 11 ++--
.../proxy/auth/test_auth_checks.py | 58 +++----------------
.../proxy/test_budget_reservation.py | 49 ++--------------
tests/test_litellm/proxy/test_proxy_server.py | 32 ----------
ui/litellm-dashboard/src/lib/http/schema.d.ts | 5 --
10 files changed, 42 insertions(+), 172 deletions(-)
diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py
index dc84a4ca705..9e98cb46b9a 100644
--- a/litellm/proxy/_types.py
+++ b/litellm/proxy/_types.py
@@ -2461,16 +2461,6 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
"is active as a reminder that hard enforcement is relaxed."
),
)
- skip_user_budget_on_team_key: bool | None = Field(
- None,
- description=(
- "If True, restores the legacy behavior where a user's personal "
- "max_budget is NOT enforced when their key belongs to a team; only "
- "the team (and team-member) budgets apply. Defaults to False, meaning "
- "the user's personal max_budget is always enforced regardless of "
- "whether the key belongs to a team (see GitHub issue #12905)."
- ),
- )
user_url_validation: Optional[bool] = Field(
None,
description=(
diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py
index 03e5e80288e..0472b496b78 100644
--- a/litellm/proxy/auth/auth_checks.py
+++ b/litellm/proxy/auth/auth_checks.py
@@ -633,31 +633,28 @@ async def common_checks(
)
async def _user_max_budget_check() -> None:
- if user_object is None or user_object.max_budget is None:
- return
- skip_for_team = (
- general_settings.get("skip_user_budget_on_team_key") is True
- and team_object is not None
- and team_object.team_id is not None
- )
- if skip_for_team:
- return
- from litellm.proxy.proxy_server import get_current_spend
+ # 4.1 personal budget, if personal key
+ if (
+ (team_object is None or team_object.team_id is None)
+ and user_object is not None
+ and user_object.max_budget is not None
+ ):
+ from litellm.proxy.proxy_server import get_current_spend
- user_budget = user_object.max_budget
- user_spend = await get_current_spend(
- counter_key=f"spend:user:{user_object.user_id}",
- fallback_spend=user_object.spend or 0.0,
- max_budget=user_budget,
- )
- if math.isfinite(user_budget) and user_spend >= user_budget:
- raise litellm.BudgetExceededError(
- current_cost=user_spend,
+ user_budget = user_object.max_budget
+ user_spend = await get_current_spend(
+ counter_key=f"spend:user:{user_object.user_id}",
+ fallback_spend=user_object.spend or 0.0,
max_budget=user_budget,
- message=f"ExceededBudget: User={user_object.user_id} over budget. Spend={user_spend}, Budget={user_budget}",
- entity_type=Litellm_EntityType.USER.value,
- entity_id=user_object.user_id,
)
+ if math.isfinite(user_budget) and user_spend >= user_budget:
+ raise litellm.BudgetExceededError(
+ current_cost=user_spend,
+ max_budget=user_budget,
+ message=f"ExceededBudget: User={user_object.user_id} over budget. Spend={user_spend}, Budget={user_budget}",
+ entity_type=Litellm_EntityType.USER.value,
+ entity_id=user_object.user_id,
+ )
# Each scope reads a distinct counter key with no cross-scope ordering
# dependency, so the per-scope Redis-first reads run concurrently instead
diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py
index b268d2f8840..f4d07c1a674 100644
--- a/litellm/proxy/auth/user_api_key_auth.py
+++ b/litellm/proxy/auth/user_api_key_auth.py
@@ -2450,7 +2450,6 @@ async def _reserve_budget_after_common_checks(
proxy_logging_obj=proxy_logging_obj,
end_user_id=end_user_id,
end_user_object=end_user_object,
- skip_user_budget_on_team_key=general_settings.get("skip_user_budget_on_team_key") is True,
fail_closed_budget_enforcement=general_settings.get("fail_closed_budget_enforcement") is True,
)
diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py
index 63748705f0d..c72e3d4ee5b 100644
--- a/litellm/proxy/proxy_server.py
+++ b/litellm/proxy/proxy_server.py
@@ -15258,7 +15258,6 @@ async def get_config_list(
"forward_client_headers_to_llm_api": {"type": "Boolean"},
"mcp_required_fields": {"type": "List"},
"cancel_on_disconnect": {"type": "Boolean"},
- "skip_user_budget_on_team_key": {"type": "Boolean"},
"disable_auto_add_proxy_admin_to_teams": {"type": "Boolean"},
}
diff --git a/litellm/proxy/spend_tracking/budget_reservation.py b/litellm/proxy/spend_tracking/budget_reservation.py
index 013873179c6..12ecd2ec300 100644
--- a/litellm/proxy/spend_tracking/budget_reservation.py
+++ b/litellm/proxy/spend_tracking/budget_reservation.py
@@ -155,7 +155,6 @@ async def reserve_budget_for_request(
proxy_logging_obj: ProxyLogging,
end_user_id: Optional[str] = None,
end_user_object: Optional[Any] = None,
- skip_user_budget_on_team_key: bool = False,
fail_closed_budget_enforcement: bool = False,
) -> Optional[dict]:
if valid_token is None or not RouteChecks.is_llm_api_route(route=route):
@@ -175,7 +174,6 @@ async def reserve_budget_for_request(
proxy_logging_obj=proxy_logging_obj,
end_user_id=end_user_id,
end_user_object=end_user_object,
- skip_user_budget_on_team_key=skip_user_budget_on_team_key,
)
if not counters:
return None
@@ -333,7 +331,6 @@ async def _get_budget_counters(
proxy_logging_obj: ProxyLogging,
end_user_id: Optional[str] = None,
end_user_object: Optional[Any] = None,
- skip_user_budget_on_team_key: bool = False,
) -> List[_BudgetCounter]:
counters: List[_BudgetCounter] = []
@@ -382,9 +379,8 @@ async def _get_budget_counters(
)
)
- is_team_key = team_object is not None and team_object.team_id is not None
if (
- not (is_team_key and skip_user_budget_on_team_key)
+ (team_object is None or team_object.team_id is None)
and user_object is not None
and user_object.user_id is not None
and user_object.max_budget is not None
diff --git a/tests/proxy_unit_tests/test_user_api_key_auth.py b/tests/proxy_unit_tests/test_user_api_key_auth.py
index 59c4caefa33..01dbb65a648 100644
--- a/tests/proxy_unit_tests/test_user_api_key_auth.py
+++ b/tests/proxy_unit_tests/test_user_api_key_auth.py
@@ -219,8 +219,8 @@ async def test_aaauser_personal_budgets(key_ownership):
"""
Set a personal budget on a user
- User budget is enforced regardless of key ownership (personal or team).
- Both cases should raise BudgetExceededError when the user is over budget.
+ - have it only apply when key belongs to user -> raises BudgetExceededError
+ - if key belongs to team, have key respect team budget -> allows call to go through
"""
import asyncio
import time
@@ -278,9 +278,12 @@ async def test_aaauser_personal_budgets(key_ownership):
== valid_token
)
- with pytest.raises(ProxyException) as exc_info:
+ if key_ownership == "user_key":
+ with pytest.raises(ProxyException) as exc_info:
+ await user_api_key_auth(request=request, api_key="Bearer " + user_key)
+ assert exc_info.value.type == ProxyErrorTypes.budget_exceeded
+ else:
await user_api_key_auth(request=request, api_key="Bearer " + user_key)
- assert exc_info.value.type == ProxyErrorTypes.budget_exceeded
@pytest.mark.asyncio
diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py
index 34a353966bf..4d0ef58b7f8 100644
--- a/tests/test_litellm/proxy/auth/test_auth_checks.py
+++ b/tests/test_litellm/proxy/auth/test_auth_checks.py
@@ -4835,66 +4835,26 @@ async def test_common_checks_personal_user_budget_blocks_in_gather():
@pytest.mark.asyncio
-async def test_user_budget_enforced_on_team_key():
- """User budget must be enforced even when the key belongs to a team.
+async def test_common_checks_personal_user_budget_skipped_for_team_key():
+ """A user's personal max_budget does not apply to a team-scoped key.
- Previously _user_max_budget_check skipped enforcement for team keys,
- letting a user with a $100 personal budget spend unlimited through a
- team key. This regression test ensures that is no longer the case.
+ Team keys are governed by the team (and team-member) budgets only; the key
+ owner's personal budget is deliberately out of scope. This asserts the read
+ path lets a team key through even when the user is far over their personal
+ budget, and fails if personal enforcement is reintroduced for team keys.
"""
from fastapi import Request
from litellm.proxy.auth.auth_checks import common_checks
user = LiteLLM_UserTable(user_id="u1", spend=0.0, max_budget=100.0)
- team = LiteLLM_TeamTable(team_id="t1", max_budget=2100.0)
+ team = LiteLLM_TeamTable(team_id="t1", spend=0.0, max_budget=1000.0)
token = UserAPIKeyAuth(token="k1", user_id="u1", team_id="t1")
async def _spend_by_counter(counter_key, fallback_spend, max_budget=None, **kwargs):
return 999.0 if counter_key == "spend:user:u1" else 0.0
- async def _no_membership(*a, **kw):
- return None
-
- with patch("litellm.proxy.proxy_server.prisma_client", None), patch(
- "litellm.proxy.proxy_server.get_current_spend", _spend_by_counter
- ), patch("litellm.proxy.auth.auth_checks.get_team_membership", _no_membership):
- with pytest.raises(litellm.BudgetExceededError) as over:
- await common_checks(
- request_body={"messages": [{"role": "user", "content": "hi"}]},
- team_object=team,
- user_object=user,
- end_user_object=None,
- global_proxy_spend=None,
- general_settings={},
- route="/chat/completions",
- llm_router=None,
- proxy_logging_obj=MagicMock(),
- valid_token=token,
- request=MagicMock(spec=Request),
- )
- assert "User=u1" in str(over.value)
-
-
-@pytest.mark.asyncio
-async def test_skip_user_budget_on_team_key_flag_restores_old_behavior():
- """Setting skip_user_budget_on_team_key=True skips user budget for team keys.
-
- This is the opt-in escape hatch that restores the legacy behavior where
- user budgets were not enforced when the key belonged to a team.
- """
- from fastapi import Request
-
- from litellm.proxy.auth.auth_checks import common_checks
-
- user = LiteLLM_UserTable(user_id="u1", spend=0.0, max_budget=100.0)
- team = LiteLLM_TeamTable(team_id="t1", max_budget=2100.0)
- token = UserAPIKeyAuth(token="k1", user_id="u1", team_id="t1")
-
- async def _spend_by_counter(counter_key, fallback_spend, max_budget=None, **kwargs):
- return 999.0 if counter_key == "spend:user:u1" else 0.0
-
- async def _no_membership(*a, **kw):
+ async def _no_membership(*args, **kwargs):
return None
with patch("litellm.proxy.proxy_server.prisma_client", None), patch(
@@ -4906,7 +4866,7 @@ async def test_skip_user_budget_on_team_key_flag_restores_old_behavior():
user_object=user,
end_user_object=None,
global_proxy_spend=None,
- general_settings={"skip_user_budget_on_team_key": True},
+ general_settings={},
route="/chat/completions",
llm_router=None,
proxy_logging_obj=MagicMock(),
diff --git a/tests/test_litellm/proxy/test_budget_reservation.py b/tests/test_litellm/proxy/test_budget_reservation.py
index e6ccbc579b5..1a584423fac 100644
--- a/tests/test_litellm/proxy/test_budget_reservation.py
+++ b/tests/test_litellm/proxy/test_budget_reservation.py
@@ -611,12 +611,12 @@ async def test_should_reserve_team_member_and_org_budget_counters(spend_counter_
@pytest.mark.asyncio
-async def test_should_reserve_user_budget_counter_for_team_key(spend_counter_state):
- """A user's personal budget must be reserved even when the key belongs to a team.
+async def test_should_not_reserve_user_budget_counter_for_team_key(spend_counter_state):
+ """The reservation path mirrors the read path: no personal user counter for a team key.
- Regression for GitHub issue #12905: previously the reservation path skipped the
- user spend counter whenever the key had a team, so a team key could overshoot the
- user's personal max_budget under concurrency.
+ A team-scoped key reserves against the key and team counters only, so the key
+ owner's personal max_budget never gates a team request. Fails if the user
+ counter is reserved for team keys again.
"""
counter_cache, key_cache = spend_counter_state
proxy_logging_obj = ProxyLogging(user_api_key_cache=key_cache)
@@ -645,44 +645,7 @@ async def test_should_reserve_user_budget_counter_for_team_key(spend_counter_sta
proxy_logging_obj=proxy_logging_obj,
)
- assert counter_cache.in_memory_cache.get_cache(key="spend:user:user-on-team") == pytest.approx(0.3)
-
- await release_budget_reservation(reservation)
-
-
-@pytest.mark.asyncio
-async def test_should_skip_user_budget_counter_for_team_key_when_flag_set(spend_counter_state):
- """skip_user_budget_on_team_key=True restores the legacy behavior where a user's
- personal budget is not reserved for a team key."""
- counter_cache, key_cache = spend_counter_state
- proxy_logging_obj = ProxyLogging(user_api_key_cache=key_cache)
- valid_token = UserAPIKeyAuth(
- token="key-user-on-team-skip",
- spend=0.0,
- user_id="user-on-team-skip",
- team_id="team-no-budget-skip",
- )
- team_object = LiteLLM_TeamTable(team_id="team-no-budget-skip", spend=0.0, max_budget=None)
- user_object = LiteLLM_UserTable(user_id="user-on-team-skip", spend=0.0, max_budget=5.0)
-
- with patch(
- "litellm.proxy.spend_tracking.budget_reservation.estimate_request_max_cost",
- return_value=0.3,
- ):
- reservation = await reserve_budget_for_request(
- request_body=_request_body(),
- route="/chat/completions",
- llm_router=None,
- valid_token=valid_token,
- team_object=team_object,
- user_object=user_object,
- prisma_client=None,
- user_api_key_cache=key_cache,
- proxy_logging_obj=proxy_logging_obj,
- skip_user_budget_on_team_key=True,
- )
-
- assert counter_cache.in_memory_cache.get_cache(key="spend:user:user-on-team-skip") is None
+ assert counter_cache.in_memory_cache.get_cache(key="spend:user:user-on-team") is None
await release_budget_reservation(reservation)
diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py
index 62f5ced7a39..5646d202e31 100644
--- a/tests/test_litellm/proxy/test_proxy_server.py
+++ b/tests/test_litellm/proxy/test_proxy_server.py
@@ -9201,38 +9201,6 @@ def test_get_config_list_includes_cancel_on_disconnect(monkeypatch):
app.dependency_overrides.clear()
-def test_get_config_list_includes_skip_user_budget_on_team_key(monkeypatch):
- """Related to #12905: the opt-out flag must be discoverable via /config/list so
- it renders as a Boolean toggle on the Admin UI General Settings table. This
- requires both the ConfigGeneralSettings field and the allowed_args entry."""
- import types
- from unittest.mock import AsyncMock, MagicMock
-
- from fastapi.testclient import TestClient
-
- import litellm.proxy.proxy_server as ps
- from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
- from litellm.proxy.proxy_server import app
-
- mock_prisma = MagicMock()
- mock_config_table = MagicMock()
- mock_config_table.find_first = AsyncMock(return_value=None)
- mock_prisma.db = types.SimpleNamespace(litellm_config=mock_config_table)
- monkeypatch.setattr(ps, "prisma_client", mock_prisma)
- app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
- user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN
- )
- try:
- client = TestClient(app)
- resp = client.get("/config/list", params={"config_type": "general_settings"})
- assert resp.status_code == 200, resp.text
- fields = {item["field_name"]: item for item in resp.json()}
- assert "skip_user_budget_on_team_key" in fields
- assert fields["skip_user_budget_on_team_key"]["field_type"] == "Boolean"
- finally:
- app.dependency_overrides.clear()
-
-
def test_get_config_list_includes_budget_exceeded_throttle_percentage(monkeypatch):
"""The throttle fraction is a litellm_settings scalar surfaced on the General
Settings table as a Float field so it sits with the other global limits; it
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index bc3110d4d3d..380b6545da8 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -22820,11 +22820,6 @@ export interface components {
* @description When set to True, rejects requests that contain client-side 'metadata.tags' to prevent users from influencing budgets by sending different tags. Tags can only be inherited from the API key metadata.
*/
reject_clientside_metadata_tags?: boolean | null;
- /**
- * Skip User Budget On Team Key
- * @description If True, restores the legacy behavior where a user's personal max_budget is NOT enforced when their key belongs to a team; only the team (and team-member) budgets apply. Defaults to False, meaning the user's personal max_budget is always enforced regardless of whether the key belongs to a team (see GitHub issue #12905).
- */
- skip_user_budget_on_team_key?: boolean | null;
/**
* Store Model In Db
* @description If True, models and config are stored in and loaded from the database. Default is False.
From abd239f9035b587df77d05dc8043153b8c95f581 Mon Sep 17 00:00:00 2001
From: Yassin Kortam
Date: Thu, 30 Jul 2026 13:48:59 -0700
Subject: [PATCH 23/33] fix(otel): label retrieval and agent metrics correctly
and emit gen_ai.provider.name (#35151)
* fix(otel): label retrieval and agent metrics correctly and emit gen_ai.provider.name
The GenAI metric attribute builder mapped only chat, text completion, embedding,
responses and MCP tool calls to an operation name, so vector-store searches and
A2A agent sends fell through to the "chat" default. Their duration and cost then
landed in the same series a Grafana GenAI dashboard reads chat latency off, with
no way to tell them apart. Both now map to the operation names the convention
defines for them, retrieval and invoke_agent, and an unmapped call type says so
at debug instead of silently becoming chat.
The provider label used gen_ai.system, which the convention deprecated in favor
of gen_ai.provider.name; the dashboards built on that vocabulary find nothing
under the old key. Metrics now carry gen_ai.provider.name with the semconv
provider value (bedrock -> aws.bedrock) via the resolve_provider helper the span
path already uses, and keep dual-emitting gen_ai.system with its raw value so a
dashboard already querying it keeps matching. A request litellm cannot attribute
to a provider gets no provider label at all rather than a placeholder "Unknown"
that minted a permanent series nobody can act on.
Resolves LIT-4954
Resolves LIT-4959
* fix(otel): map the rest of the vector-store call types off the chat default
Mapping only the search left the store lifecycle (create, retrieve, list,
update, delete) and the file operations (create, list, retrieve, content,
update, delete) falling through to chat, so vector-store admin traffic kept
polluting the same series a dashboard reads chat latency off. A live run
confirmed it: all 20 metric datapoints from a create, retrieve, list, file-list
and delete came out labelled chat.
The convention names no operation for vector-store management, so these take
vendor values under the litellm. prefix, litellm.vector_store_management and
litellm.vector_store_file_management, one per REST resource. Its note on
gen_ai.operation.name directs instrumentation to use a system-specific name
when no predefined value applies, which is the same allowance resolve_provider
already relies on for unmapped providers. Excluding them from the GenAI metrics
altogether was the alternative; it deletes series an operator may be watching
today and is far harder to reverse than a rename, so it stays available as a
follow-up rather than being decided here. Mapping them onto the semconv memory
store family was rejected: litellm vector stores hold documents, not agent
memory records, and borrowing those names would put document admin calls into
whatever charts agent-memory operations, which is the bug this fixes.
/rag/query reaches the same recorder and is the same operation as a vector-store
search, so query and aquery map to retrieval too; leaving them would have left
the defect alive on a second retrieval surface. /rag/ingest is a write with no
semconv equivalent and no retrieval or agent confusion, so it is left for the
RAG owners to name.
Resolves LIT-4954
* fix(otel): give the streaming A2A path a call type so it labels as invoke_agent
The streaming logging object is built by hand and never runs through
update_environment_variables, the only place call_type reaches
model_call_details, so every streamed agent turn arrived at the recorder
with no call type and fell back to chat. Stamp it, and map the streaming
spelling alongside the non-streaming ones.
---
litellm/a2a_protocol/main.py | 1 +
litellm/integrations/opentelemetry.py | 1 +
litellm/integrations/otel/model/semconv.py | 72 ++++++++++-
litellm/integrations/otel/plumbing/metrics.py | 33 ++++-
tests/test_litellm/a2a_protocol/test_main.py | 29 +++++
.../integrations/otel/test_otel_v2_metrics.py | 117 +++++++++++++++++-
.../otel/test_otel_v2_sources_of_truth.py | 102 +++++++++++++++
7 files changed, 339 insertions(+), 16 deletions(-)
diff --git a/litellm/a2a_protocol/main.py b/litellm/a2a_protocol/main.py
index 4c23ecfed54..f04edf2579b 100644
--- a/litellm/a2a_protocol/main.py
+++ b/litellm/a2a_protocol/main.py
@@ -568,6 +568,7 @@ def _build_streaming_logging_obj(
logging_obj.custom_llm_provider = "a2a_agent"
logging_obj.model_call_details["model"] = model
logging_obj.model_call_details["custom_llm_provider"] = "a2a_agent"
+ logging_obj.model_call_details["call_type"] = logging_obj.call_type
if agent_id:
logging_obj.model_call_details["agent_id"] = agent_id
diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py
index 11e7ab062b6..60eb960be4e 100644
--- a/litellm/integrations/opentelemetry.py
+++ b/litellm/integrations/opentelemetry.py
@@ -118,6 +118,7 @@ TOKEN_TYPE_ATTRIBUTE: str = "gen_ai.token.type"
VALID_METRIC_ATTRIBUTE_NAMES: FrozenSet[str] = frozenset(
(
"gen_ai.operation.name",
+ "gen_ai.provider.name",
"gen_ai.system",
"gen_ai.request.model",
"gen_ai.framework",
diff --git a/litellm/integrations/otel/model/semconv.py b/litellm/integrations/otel/model/semconv.py
index a0994d1948a..3b38b3d3655 100644
--- a/litellm/integrations/otel/model/semconv.py
+++ b/litellm/integrations/otel/model/semconv.py
@@ -6,17 +6,30 @@ without a semconv equivalent lives under the ``litellm.*`` vendor namespace.
from enum import Enum
from typing import Final
+from litellm._logging import verbose_logger
+
class GenAIOperation(str, Enum):
- """Values for ``gen_ai.operation.name``."""
+ """Values for ``gen_ai.operation.name``.
+
+ The first block is the convention's own vocabulary. The ``LITELLM_`` members
+ are vendor values for operations the convention names nothing for; its note
+ on this attribute directs instrumentation to use a system-specific name in
+ exactly that case, the same allowance :func:`resolve_provider` relies on for
+ unmapped providers. They stay under the ``litellm.`` prefix so a value the
+ convention adds later can never collide with one of ours.
+ """
CHAT = "chat"
TEXT_COMPLETION = "text_completion"
EMBEDDINGS = "embeddings"
GENERATE_CONTENT = "generate_content"
+ RETRIEVAL = "retrieval" # vector-store search / RAG query spans
CREATE_AGENT = "create_agent" # reserved for future agent spans
- INVOKE_AGENT = "invoke_agent" # reserved for future agent spans
+ INVOKE_AGENT = "invoke_agent" # agent (A2A) message spans
EXECUTE_TOOL = "execute_tool" # MCP tool-call spans
+ LITELLM_VECTOR_STORE_MANAGEMENT = "litellm.vector_store_management"
+ LITELLM_VECTOR_STORE_FILE_MANAGEMENT = "litellm.vector_store_file_management"
class GenAIProvider(str, Enum):
@@ -49,11 +62,17 @@ class MCPMethod(str, Enum):
class GenAI:
- """Canonical OTel GenAI span-attribute keys."""
+ """Canonical OTel GenAI attribute keys.
+
+ ``SYSTEM`` is the one exception: the convention deprecated it in favor of
+ ``PROVIDER_NAME``, and it survives here only so already-shipped series keep
+ resolving for consumers that query it. Nothing new should use it.
+ """
# request
OPERATION_NAME: Final = "gen_ai.operation.name"
PROVIDER_NAME: Final = "gen_ai.provider.name"
+ SYSTEM: Final = "gen_ai.system"
REQUEST_MODEL: Final = "gen_ai.request.model"
REQUEST_TEMPERATURE: Final = "gen_ai.request.temperature"
REQUEST_TOP_P: Final = "gen_ai.request.top_p"
@@ -316,6 +335,35 @@ _OPERATION_BY_CALL_TYPE: dict[str, GenAIOperation] = {
"responses": GenAIOperation.CHAT,
"aresponses": GenAIOperation.CHAT,
"call_mcp_tool": GenAIOperation.EXECUTE_TOOL,
+ "vector_store_search": GenAIOperation.RETRIEVAL,
+ "avector_store_search": GenAIOperation.RETRIEVAL,
+ "query": GenAIOperation.RETRIEVAL,
+ "aquery": GenAIOperation.RETRIEVAL,
+ "send_message": GenAIOperation.INVOKE_AGENT,
+ "asend_message": GenAIOperation.INVOKE_AGENT,
+ "asend_message_streaming": GenAIOperation.INVOKE_AGENT,
+ "vector_store_create": GenAIOperation.LITELLM_VECTOR_STORE_MANAGEMENT,
+ "avector_store_create": GenAIOperation.LITELLM_VECTOR_STORE_MANAGEMENT,
+ "vector_store_retrieve": GenAIOperation.LITELLM_VECTOR_STORE_MANAGEMENT,
+ "avector_store_retrieve": GenAIOperation.LITELLM_VECTOR_STORE_MANAGEMENT,
+ "vector_store_list": GenAIOperation.LITELLM_VECTOR_STORE_MANAGEMENT,
+ "avector_store_list": GenAIOperation.LITELLM_VECTOR_STORE_MANAGEMENT,
+ "vector_store_update": GenAIOperation.LITELLM_VECTOR_STORE_MANAGEMENT,
+ "avector_store_update": GenAIOperation.LITELLM_VECTOR_STORE_MANAGEMENT,
+ "vector_store_delete": GenAIOperation.LITELLM_VECTOR_STORE_MANAGEMENT,
+ "avector_store_delete": GenAIOperation.LITELLM_VECTOR_STORE_MANAGEMENT,
+ "vector_store_file_create": GenAIOperation.LITELLM_VECTOR_STORE_FILE_MANAGEMENT,
+ "avector_store_file_create": GenAIOperation.LITELLM_VECTOR_STORE_FILE_MANAGEMENT,
+ "vector_store_file_list": GenAIOperation.LITELLM_VECTOR_STORE_FILE_MANAGEMENT,
+ "avector_store_file_list": GenAIOperation.LITELLM_VECTOR_STORE_FILE_MANAGEMENT,
+ "vector_store_file_retrieve": GenAIOperation.LITELLM_VECTOR_STORE_FILE_MANAGEMENT,
+ "avector_store_file_retrieve": GenAIOperation.LITELLM_VECTOR_STORE_FILE_MANAGEMENT,
+ "vector_store_file_content": GenAIOperation.LITELLM_VECTOR_STORE_FILE_MANAGEMENT,
+ "avector_store_file_content": GenAIOperation.LITELLM_VECTOR_STORE_FILE_MANAGEMENT,
+ "vector_store_file_update": GenAIOperation.LITELLM_VECTOR_STORE_FILE_MANAGEMENT,
+ "avector_store_file_update": GenAIOperation.LITELLM_VECTOR_STORE_FILE_MANAGEMENT,
+ "vector_store_file_delete": GenAIOperation.LITELLM_VECTOR_STORE_FILE_MANAGEMENT,
+ "avector_store_file_delete": GenAIOperation.LITELLM_VECTOR_STORE_FILE_MANAGEMENT,
}
@@ -332,7 +380,21 @@ def resolve_provider(custom_llm_provider: str | None) -> str:
def resolve_operation(call_type: str | None) -> GenAIOperation:
- """Map a litellm ``call_type`` to a ``gen_ai.operation.name`` value."""
+ """Map a litellm ``call_type`` to a ``gen_ai.operation.name`` value.
+
+ An unmapped call type still falls back to ``chat`` so every series keeps an
+ operation label, but it logs at debug rather than falling through silently:
+ a new call type mislabelled as ``chat`` mixes its latency and cost into
+ everyone's chat charts, which is invisible until someone reads the numbers.
+ """
if not call_type:
return GenAIOperation.CHAT
- return _OPERATION_BY_CALL_TYPE.get(call_type.lower(), GenAIOperation.CHAT)
+ mapped = _OPERATION_BY_CALL_TYPE.get(call_type.lower())
+ if mapped is not None:
+ return mapped
+ verbose_logger.debug(
+ "otel: call_type %r has no gen_ai.operation.name mapping; labelling it %r. Add it to _OPERATION_BY_CALL_TYPE.",
+ call_type,
+ GenAIOperation.CHAT.value,
+ )
+ return GenAIOperation.CHAT
diff --git a/litellm/integrations/otel/plumbing/metrics.py b/litellm/integrations/otel/plumbing/metrics.py
index 4d3c39e33d4..6ebaacafdc4 100644
--- a/litellm/integrations/otel/plumbing/metrics.py
+++ b/litellm/integrations/otel/plumbing/metrics.py
@@ -23,11 +23,34 @@ from litellm.integrations.opentelemetry import (
_resolve_metric_attribute_filter,
)
from litellm.integrations.otel.model.metadata import time_to_first_chunk_seconds
-from litellm.integrations.otel.model.semconv import Error, Metric, resolve_operation
+from litellm.integrations.otel.model.semconv import (
+ Error,
+ GenAI,
+ Metric,
+ resolve_operation,
+ resolve_provider,
+)
from litellm.integrations.otel.model.utils import to_seconds
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
+def _provider_attributes(custom_llm_provider: object) -> Mapping[str, str]:
+ """The provider labels for one call's metrics.
+
+ ``gen_ai.provider.name`` carries the semconv-mapped value; the deprecated
+ ``gen_ai.system`` spelling is dual-emitted with the raw litellm provider
+ string it has always carried, so a dashboard already querying it keeps
+ matching. A call with no provider gets neither label: a placeholder value
+ would mint a permanent series that no operator can act on.
+ """
+ if not isinstance(custom_llm_provider, str) or not custom_llm_provider:
+ return {}
+ return {
+ GenAI.PROVIDER_NAME: resolve_provider(custom_llm_provider),
+ GenAI.SYSTEM: custom_llm_provider,
+ }
+
+
@dataclass(frozen=True)
class GenAIMetrics:
operation_duration: Histogram
@@ -98,6 +121,7 @@ ERROR_TYPE_FALLBACK: Final = "_OTHER"
METRIC_ATTRIBUTE_CEILING: Final[frozenset[str]] = frozenset(
(
"gen_ai.operation.name",
+ "gen_ai.provider.name",
"gen_ai.system",
"gen_ai.request.model",
"gen_ai.framework",
@@ -223,11 +247,10 @@ class GenAIMetricRecorder:
def _common_attributes(self, kwargs: Mapping[str, Any]) -> dict:
params = kwargs.get("litellm_params") or {}
- provider = params.get("custom_llm_provider", "Unknown")
common_attrs: dict = {
- "gen_ai.operation.name": resolve_operation(kwargs.get("call_type")).value,
- "gen_ai.system": provider,
- "gen_ai.request.model": kwargs.get("model"),
+ GenAI.OPERATION_NAME: resolve_operation(kwargs.get("call_type")).value,
+ **_provider_attributes(params.get("custom_llm_provider")),
+ GenAI.REQUEST_MODEL: kwargs.get("model"),
"gen_ai.framework": "litellm",
}
diff --git a/tests/test_litellm/a2a_protocol/test_main.py b/tests/test_litellm/a2a_protocol/test_main.py
index 2a675616245..c5e48620fde 100644
--- a/tests/test_litellm/a2a_protocol/test_main.py
+++ b/tests/test_litellm/a2a_protocol/test_main.py
@@ -104,3 +104,32 @@ async def test_streaming_trace_id_prefers_logging_trace_id():
pass
assert captured["extra_headers"]["X-LiteLLM-Trace-Id"] == "trace-from-logging"
+
+
+def test_streaming_logging_obj_carries_call_type_into_model_call_details():
+ """The streaming logging object is built by hand rather than through
+ ``update_environment_variables``, which is the only place ``call_type`` normally
+ reaches ``model_call_details``. Callbacks read the call type from there, so
+ without this the streamed turn arrives at every logger with no call type at all
+ and OTel's GenAI metrics label it ``chat`` instead of ``invoke_agent``."""
+ from a2a.compat.v0_3.types import MessageSendParams, SendStreamingMessageRequest
+
+ from litellm.a2a_protocol.main import _build_streaming_logging_obj
+
+ request = SendStreamingMessageRequest(
+ id="rpc-call-type",
+ params=MessageSendParams(
+ message={"messageId": "m1", "role": "user", "parts": [{"kind": "text", "text": "hi"}]}
+ ),
+ )
+
+ logging_obj = _build_streaming_logging_obj(
+ request=request,
+ agent_name="some-agent",
+ agent_id=None,
+ litellm_params=None,
+ metadata=None,
+ proxy_server_request=None,
+ )
+
+ assert logging_obj.model_call_details["call_type"] == "asend_message_streaming"
diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_metrics.py b/tests/test_litellm/integrations/otel/test_otel_v2_metrics.py
index 56607414de4..e1b8e4b5721 100644
--- a/tests/test_litellm/integrations/otel/test_otel_v2_metrics.py
+++ b/tests/test_litellm/integrations/otel/test_otel_v2_metrics.py
@@ -68,6 +68,9 @@ ALL_METRICS = frozenset(
TOKEN_TYPE = "gen_ai.token.type"
MODEL_KEY = "gen_ai.request.model"
+OPERATION_KEY = "gen_ai.operation.name"
+PROVIDER_NAME_KEY = "gen_ai.provider.name"
+SYSTEM_KEY = "gen_ai.system"
# Keys inside the ceiling that an operator's filter must still be able to remove.
# Every one is bounded, so it survives the ceiling and only the operator's own
@@ -83,18 +86,25 @@ COMPLETION_TOKENS = 89
RESPONSE_COST = 0.0023
-def _build_call(stream: bool = True):
+def _build_call(
+ stream: bool = True,
+ provider: str | None = "openai",
+ call_type: str = "completion",
+):
"""A captured success-call (kwargs, response_obj, start, end) that exercises
every one of the six metrics: usage for token.usage, response_cost for cost,
- streaming + timing for the response-time histograms."""
+ streaming + timing for the response-time histograms.
+
+ ``provider=None`` omits ``custom_llm_provider`` entirely, reproducing a call
+ litellm could not attribute to a provider."""
start = datetime(2026, 6, 12, 12, 0, 0)
api_call_start = start + timedelta(seconds=0.1)
completion_start = start + timedelta(seconds=0.5)
end = start + timedelta(seconds=1.0)
kwargs = {
"model": "gpt-4o-mini",
- "call_type": "completion",
- "litellm_params": {"custom_llm_provider": "openai"},
+ "call_type": call_type,
+ "litellm_params": ({"custom_llm_provider": provider} if provider is not None else {}),
"optional_params": {"stream": stream},
"response_cost": RESPONSE_COST,
"api_call_start_time": api_call_start,
@@ -142,7 +152,7 @@ def _metrics_by_name(reader):
return out
-def _drive_success(reader, callback_settings_attributes=None):
+def _drive_success(reader, callback_settings_attributes=None, **call_overrides):
"""Construct a metrics-on logger, optionally populate callback_settings AFTER
construction (mirroring the proxy ordering), run the real success hook."""
logger = _logger(reader, enable_metrics=True)
@@ -152,7 +162,7 @@ def _drive_success(reader, callback_settings_attributes=None):
"otel": {"attributes": callback_settings_attributes}
}
try:
- kwargs, response_obj, start, end = _build_call()
+ kwargs, response_obj, start, end = _build_call(**call_overrides)
asyncio.run(logger.async_log_success_event(kwargs, response_obj, start, end))
finally:
litellm.callback_settings = previous
@@ -376,6 +386,100 @@ def test_success_attributes_are_capped_at_the_ceiling():
for dp in metrics[name]:
leaked = set(dp.attributes) - set(BOUNDED_KEYS) - {TOKEN_TYPE}
assert not leaked, f"{name} leaked {leaked}"
+def test_provider_is_labelled_with_semconv_provider_name():
+ """Every recorded point carries gen_ai.provider.name holding the semconv
+ provider value (bedrock -> aws.bedrock), the key the GenAI convention and the
+ dashboards built on it query. The deprecated gen_ai.system spelling alone is
+ unreadable to them."""
+ metrics = _drive_success(InMemoryMetricReader(), provider="bedrock")
+
+ for name in ALL_METRICS:
+ points = metrics[name]
+ assert points, f"{name} was not recorded"
+ for dp in points:
+ assert dp.attributes[PROVIDER_NAME_KEY] == "aws.bedrock"
+
+
+def test_deprecated_gen_ai_system_is_dual_emitted_verbatim():
+ """gen_ai.system keeps its raw litellm provider value alongside the new key
+ for one release, so a dashboard already filtering on it keeps matching. Its
+ value must not be swapped for the mapped one, which would break exactly the
+ queries the dual emission exists to protect."""
+ metrics = _drive_success(InMemoryMetricReader(), provider="bedrock")
+
+ for dp in metrics[OPERATION_DURATION]:
+ assert dp.attributes[SYSTEM_KEY] == "bedrock"
+ assert dp.attributes[PROVIDER_NAME_KEY] == "aws.bedrock"
+
+
+def test_no_provider_attribute_when_provider_is_absent():
+ """A call litellm could not attribute to a provider carries no provider label
+ at all. A placeholder value ("Unknown") would mint a permanent series that
+ aggregates every unattributable request and that no operator can act on."""
+ metrics = _drive_success(InMemoryMetricReader(), provider=None)
+
+ for name in ALL_METRICS:
+ points = metrics[name]
+ assert points, f"{name} was not recorded"
+ for dp in points:
+ keys = set(dp.attributes.keys())
+ assert PROVIDER_NAME_KEY not in keys
+ assert SYSTEM_KEY not in keys
+ assert "Unknown" not in set(dp.attributes.values())
+
+
+def test_vector_store_search_is_not_labelled_as_chat():
+ """A vector-store search records under gen_ai.operation.name=retrieval, so its
+ latency and cost stay out of the chat series."""
+ metrics = _drive_success(InMemoryMetricReader(), call_type="avector_store_search")
+
+ for name in (OPERATION_DURATION, TOKEN_COST):
+ for dp in metrics[name]:
+ assert dp.attributes[OPERATION_KEY] == "retrieval"
+
+
+@pytest.mark.parametrize(
+ "call_type,expected",
+ [
+ ("avector_store_create", "litellm.vector_store_management"),
+ ("avector_store_delete", "litellm.vector_store_management"),
+ ("avector_store_file_create", "litellm.vector_store_file_management"),
+ ("avector_store_file_list", "litellm.vector_store_file_management"),
+ ],
+)
+def test_vector_store_management_is_not_labelled_as_chat(call_type, expected):
+ """Store and file management reach the recorder through the same success hook as a
+ completion, so leaving them unmapped kept billing- and latency-relevant admin calls
+ inside the chat series."""
+ metrics = _drive_success(InMemoryMetricReader(), call_type=call_type)
+
+ for dp in metrics[OPERATION_DURATION]:
+ assert dp.attributes[OPERATION_KEY] == expected
+
+
+@pytest.mark.parametrize("call_type", ["asend_message", "asend_message_streaming"])
+def test_agent_message_is_not_labelled_as_chat(call_type):
+ """An A2A agent send records under gen_ai.operation.name=invoke_agent, streamed or
+ not. The streaming iterator dispatches the same success handlers under its own
+ ``asend_message_streaming`` call type, so an unmapped streaming spelling puts every
+ streamed agent turn's latency and cost back into the chat series."""
+ metrics = _drive_success(InMemoryMetricReader(), call_type=call_type)
+
+ for name in (OPERATION_DURATION, TOKEN_COST):
+ for dp in metrics[name]:
+ assert dp.attributes[OPERATION_KEY] == "invoke_agent"
+
+
+def test_provider_name_is_filterable():
+ """gen_ai.provider.name is a member of the metric-attribute allowlist, so an
+ operator can include or exclude it; an unlisted name raises instead."""
+ metrics = _drive_success(
+ InMemoryMetricReader(),
+ callback_settings_attributes={"include_list": [PROVIDER_NAME_KEY]},
+ )
+
+ for dp in metrics[OPERATION_DURATION]:
+ assert set(dp.attributes.keys()) == {PROVIDER_NAME_KEY}
def test_metrics_reach_operator_configured_global_provider(monkeypatch):
@@ -481,6 +585,7 @@ UNBOUNDED_KEYS = (
BOUNDED_KEYS = (
"hidden_params",
"gen_ai.operation.name",
+ "gen_ai.provider.name",
"gen_ai.system",
"gen_ai.request.model",
"gen_ai.framework",
diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_sources_of_truth.py b/tests/test_litellm/integrations/otel/test_otel_v2_sources_of_truth.py
index 71be28ea485..612ac1e5113 100644
--- a/tests/test_litellm/integrations/otel/test_otel_v2_sources_of_truth.py
+++ b/tests/test_litellm/integrations/otel/test_otel_v2_sources_of_truth.py
@@ -1,8 +1,13 @@
"""Tests for the OTel v2 sources of truth: span registry, semconv keys, config,
and the typed StandardLoggingPayload adapter. These need no OTel SDK."""
+import logging
+import re
+from pathlib import Path
+
import pytest
+import litellm
from litellm.integrations.otel import (
BAGGAGE_PROMOTED_KEYS,
DB,
@@ -208,6 +213,103 @@ def test_operation_resolution():
assert resolve_operation("call_mcp_tool") is GenAIOperation.EXECUTE_TOOL
+@pytest.mark.parametrize("call_type", ["vector_store_search", "avector_store_search"])
+def test_vector_store_search_is_a_retrieval_operation(call_type):
+ """A vector-store search is a retrieval, so its duration and cost must not
+ land in the chat series that dashboards read latency off."""
+ assert resolve_operation(call_type) is GenAIOperation.RETRIEVAL
+ assert resolve_operation(call_type).value == "retrieval"
+
+
+@pytest.mark.parametrize("call_type", ["query", "aquery"])
+def test_rag_query_is_a_retrieval_operation(call_type):
+ """``/rag/query`` reaches the same recorder as a vector-store search and is the
+ same operation, so it must not be the one retrieval surface left reading as chat."""
+ assert resolve_operation(call_type) is GenAIOperation.RETRIEVAL
+
+
+@pytest.mark.parametrize(
+ "call_type",
+ [
+ f"{prefix}vector_store_{verb}"
+ for verb in ("create", "retrieve", "list", "update", "delete")
+ for prefix in ("", "a")
+ ],
+)
+def test_vector_store_management_is_not_chat(call_type):
+ """The store lifecycle calls are not GenAI client operations and the convention
+ names nothing for them, so they take a vendor value rather than defaulting into
+ the chat series."""
+ assert resolve_operation(call_type) is GenAIOperation.LITELLM_VECTOR_STORE_MANAGEMENT
+ assert resolve_operation(call_type).value == "litellm.vector_store_management"
+
+
+@pytest.mark.parametrize(
+ "call_type",
+ [
+ f"{prefix}vector_store_file_{verb}"
+ for verb in ("create", "list", "retrieve", "content", "update", "delete")
+ for prefix in ("", "a")
+ ],
+)
+def test_vector_store_file_management_is_not_chat(call_type):
+ """The file operations are a distinct REST resource from the store lifecycle, so
+ they get their own vendor value instead of sharing one bucket."""
+ assert resolve_operation(call_type) is GenAIOperation.LITELLM_VECTOR_STORE_FILE_MANAGEMENT
+ assert resolve_operation(call_type).value == "litellm.vector_store_file_management"
+
+
+def test_vendor_operation_values_are_namespaced():
+ """A vendor value must stay under the ``litellm.`` prefix: an unprefixed invented
+ name could collide with a value the convention adds later, silently changing what
+ a conformant consumer thinks it is reading."""
+ vendor = [op for op in GenAIOperation if op.name.startswith("LITELLM_")]
+ assert vendor, "no vendor operation values defined"
+ assert all(op.value.startswith("litellm.") for op in vendor)
+
+
+@pytest.mark.parametrize("call_type", ["send_message", "asend_message", "asend_message_streaming"])
+def test_agent_message_is_an_invoke_agent_operation(call_type):
+ """An agent (A2A) message send is an agent invocation, not a chat completion.
+
+ The streaming spelling counts: ``_build_streaming_logging_obj`` in
+ ``litellm/a2a_protocol/main.py`` stamps ``asend_message_streaming`` on the
+ logging object the streaming iterator dispatches success handlers with, so a
+ missing entry sends every streamed agent turn into the chat series. There is
+ no sync spelling because A2A streaming is async-only.
+ """
+ assert resolve_operation(call_type) is GenAIOperation.INVOKE_AGENT
+ assert resolve_operation(call_type).value == "invoke_agent"
+
+
+def test_every_call_type_the_a2a_package_stamps_is_an_agent_operation():
+ """Pins the map to the call types the A2A code actually stamps on its logging
+ objects. A new spelling added there without a map entry fails here instead of
+ quietly landing in the chat series, which is how the streaming one was missed."""
+ a2a_package = Path(litellm.__file__).parent / "a2a_protocol"
+ stamped = {
+ call_type
+ for source in a2a_package.rglob("*.py")
+ for call_type in re.findall(r'call_type="([^"]+)"', source.read_text())
+ }
+ assert stamped, "no call_type literals found in litellm/a2a_protocol"
+ unmapped = {
+ call_type: resolve_operation(call_type).value
+ for call_type in stamped
+ if resolve_operation(call_type) is not GenAIOperation.INVOKE_AGENT
+ }
+ assert not unmapped, f"add these to _OPERATION_BY_CALL_TYPE: {unmapped}"
+
+
+def test_unmapped_call_type_falls_back_to_chat_loudly(caplog):
+ """The fallback still labels the series ``chat`` so it is never unlabelled,
+ but it says so at debug: a silent default is how retrieval and agent calls
+ ended up in the chat charts in the first place."""
+ with caplog.at_level(logging.DEBUG, logger="LiteLLM"):
+ assert resolve_operation("some_future_call_type") is GenAIOperation.CHAT
+ assert any("some_future_call_type" in record.getMessage() for record in caplog.records)
+
+
# --- MCP tool-call (source of truth #1/#2/#3) ------------------------------- #
From 7eee260ca84e5aa4b1f8281c8b905deb961b7c95 Mon Sep 17 00:00:00 2001
From: Yuneng Jiang
Date: Thu, 30 Jul 2026 14:04:13 -0700
Subject: [PATCH 24/33] fix(ui): stop clamping the budgets Budget ID column at
15 characters
Reverts the shared IdCell change from the previous commit and scopes the
fix to the budgets table instead
IdCell truncates with `block max-w-[15ch]`, a character-count clamp with
no relationship to the column's width. On budgets the Budget ID column
renders 509px wide at a 1400px container while the ID stays pinned at
108px, so UUIDs ellipsize with ~400px of empty space beside them
Changing that clamp in IdCell itself is wrong today because nothing else
bounds the column. DataTable emits `width: px` on each cell but
leaves the table in `table-auto`, where `width` is only a hint and
`max-width` on a cell is ignored outright (measured: a 120px request
yields a 938px column). Only `table-fixed` binds `size`, and DataTable
enables it solely under `enableColumnResizing`, which 4 of 40 tables use.
So an unbounded IdCell lets content drive the column: Request Logs would
render a 64-char key hash in full, taking its key_hash column from 124px
to 494px and pushing the table from 1918px to 2326px, introducing
horizontal scroll at 1920 where there was none
Scope it to the call site instead. `cn` is tailwind-merge backed, so a
`max-w-*` passed via className dissolves the base clamp while leaving
`truncate` in place; budget IDs render in full and still ellipsize at the
cell edge if one ever outgrows the column. No other table moves
This is a workaround. The real fix is to make column `size` authoritative
by separating a fixed-layout option from `enableColumnResizing`, then
dropping the per-cell clamps; 307 of 321 column defs already declare a
size, so the mechanical gap is small, but ~20 tables would gain
horizontal scroll at 1440 and that needs its own review
---
.../budgets/_components/BudgetTable.test.tsx | 9 +++++++++
.../budgets/_components/BudgetTableColumns.tsx | 2 +-
.../src/components/shared/table_cells/id_cell.test.tsx | 10 +---------
.../src/components/shared/table_cells/id_cell.tsx | 2 +-
4 files changed, 12 insertions(+), 11 deletions(-)
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTable.test.tsx
index 9a7a7bd2eb9..2b97bcbc072 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTable.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTable.test.tsx
@@ -35,6 +35,15 @@ describe("BudgetTable", () => {
expect(screen.getByText("10")).toBeInTheDocument();
});
+ it("should render the budget id without a fixed character-count clamp", () => {
+ const budgetId = "ecc1869c-6231-4380-a56d-1a0be457477d";
+ renderWithProviders( );
+ const idCell = screen.getByText(budgetId);
+ expect(idCell.className).not.toMatch(/max-w-\[\d+(ch|rem|px)\]/);
+ expect(idCell.className).toContain("max-w-full");
+ expect(idCell.className).toContain("truncate");
+ });
+
it("should show n/a for missing rate limits and Unlimited for a missing max budget", () => {
renderWithProviders(
,
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTableColumns.tsx
index 456ab9d6b68..e3fbc9dba08 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTableColumns.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/BudgetTableColumns.tsx
@@ -75,7 +75,7 @@ export const getBudgetTableColumns = ({
header: "Budget ID",
size: 220,
enableSorting: false,
- cell: ({ row }) => ,
+ cell: ({ row }) => ,
},
{
id: "max_budget",
diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/id_cell.test.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/id_cell.test.tsx
index 41da4519018..1a87f17d50b 100644
--- a/ui/litellm-dashboard/src/components/shared/table_cells/id_cell.test.tsx
+++ b/ui/litellm-dashboard/src/components/shared/table_cells/id_cell.test.tsx
@@ -28,18 +28,10 @@ describe("IdCell", () => {
expect(el.tagName).toBe("SPAN");
expect(el.className).toContain("bg-blue-50");
expect(el.className).toContain("font-mono");
- expect(el.className).toContain("max-w-full");
+ expect(el.className).toContain("max-w-[15ch]");
expect(el.className).toContain("truncate");
});
- it("clamps to the containing cell rather than a fixed character count", () => {
- render( );
- const el = screen.getByText("ecc1869c-6231-4380-a56d-1a0be457477d");
- expect(el.className).not.toMatch(/max-w-\[\d+(ch|rem|px)\]/);
- expect(el.className).toContain("inline-block");
- expect(el.className).toContain("max-w-full");
- });
-
it("renders plain mono text without pill styling for the plain variant", () => {
render( );
const el = screen.getByText("req-123");
diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/id_cell.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/id_cell.tsx
index c8b75ee96e9..6fbd2e2f9ed 100644
--- a/ui/litellm-dashboard/src/components/shared/table_cells/id_cell.tsx
+++ b/ui/litellm-dashboard/src/components/shared/table_cells/id_cell.tsx
@@ -54,7 +54,7 @@ export function IdCell({
const classes = cn(
VARIANT_CLASS[variant].base,
clickable && VARIANT_CLASS[variant].clickable,
- truncate && "inline-block max-w-full truncate",
+ truncate && "block max-w-[15ch] truncate",
disabled && "opacity-50",
className,
);
From eb8870065bd96061d69445f2ec5b3f1ea6fd891c Mon Sep 17 00:00:00 2001
From: yuneng-jiang
Date: Thu, 30 Jul 2026 14:09:50 -0700
Subject: [PATCH 25/33] test(e2e): align budget e2e with the team-key budget
hierarchy (#35276)
#35271 restored the hierarchy where a team-scoped key is governed by the
team and team-member budgets only; the owner's personal max_budget applies
to their personal keys. Three places in the e2e suite still encoded the
old direction and would fail against a proxy built from staging.
test_user_budget_enforced_across_all_their_keys asserted that the owner's
team-member key is refused once their personal budget is exhausted. It now
asserts only the personal keys are refused, and keeps the team key as the
control that must keep serving, which pins the restored direction instead
of leaving it unasserted. Renamed to match what it now covers.
test_team_member_key_user_budget_resets_after_window drove a team key to a
block off the owner's personal budget, so nothing can block it any more and
_drive_to_block could never succeed. Its premise is gone rather than moved,
so it is removed; the sibling personal-key test still covers
quota_management.budget.internal_user.resets_after_window.
The registry rationale for that row dropped its "and team-member keys"
clause for the same reason.
---
.../coverage_registry/quota_management.yaml | 2 +-
.../budgets/test_budget_enforcement_e2e.py | 24 +++++++++++++------
.../budgets/test_budget_reset_e2e.py | 15 ------------
3 files changed, 18 insertions(+), 23 deletions(-)
diff --git a/tests/e2e/coverage_registry/quota_management.yaml b/tests/e2e/coverage_registry/quota_management.yaml
index a8d0749cd8d..eb620395c46 100644
--- a/tests/e2e/coverage_registry/quota_management.yaml
+++ b/tests/e2e/coverage_registry/quota_management.yaml
@@ -23,7 +23,7 @@
- {id: quota_management.budget.key.resets_after_window, module: quota_management, tier: P1, behavior: budget, variant: key, assertions: [resets_after_window], exercised_on: [chat_completions], source: "proxy/common_utils/reset_budget_job.py", rationale: "budget_duration zeroes key spend after the window; a blocked key serves again"}
- {id: quota_management.budget.team.resets_after_window, module: quota_management, tier: P1, behavior: budget, variant: team, assertions: [resets_after_window], exercised_on: [chat_completions], source: "proxy/common_utils/reset_budget_job.py", rationale: "budget_duration zeroes a team's spend after the window; every key on the team serves again"}
- {id: quota_management.budget.organization.resets_after_window, module: quota_management, tier: P1, behavior: budget, variant: organization, assertions: [resets_after_window], exercised_on: [chat_completions], source: "proxy/common_utils/reset_budget_job.py", rationale: "An org budget resets after its window; keys under the org serve again"}
-- {id: quota_management.budget.internal_user.resets_after_window, module: quota_management, tier: P1, behavior: budget, variant: internal_user, assertions: [resets_after_window], exercised_on: [chat_completions], source: "proxy/common_utils/reset_budget_job.py", rationale: "An internal user's budget resets after its window; their personal and team-member keys serve again"}
+- {id: quota_management.budget.internal_user.resets_after_window, module: quota_management, tier: P1, behavior: budget, variant: internal_user, assertions: [resets_after_window], exercised_on: [chat_completions], source: "proxy/common_utils/reset_budget_job.py", rationale: "An internal user's budget resets after its window; their personal keys serve again"}
- {id: quota_management.budget.team_member.resets_after_window, module: quota_management, tier: P1, behavior: budget, variant: team_member, assertions: [resets_after_window], exercised_on: [chat_completions], source: "proxy/common_utils/reset_budget_job.py", rationale: "Member per-team budget reset keeps advancing window after window"}
- {id: quota_management.budget.key_multi_window.blocks_then_resets, module: quota_management, tier: P1, behavior: budget, variant: key_multi_window, assertions: [blocks_then_resets], exercised_on: [chat_completions], source: "proxy/common_utils/reset_budget_job.py", rationale: "budget_limits enforce within a short window and serve again in the next"}
- {id: quota_management.budget.key_multi_window.resets_windows_independently, module: quota_management, tier: P2, behavior: budget, variant: key_multi_window, assertions: [resets_windows_independently], exercised_on: [chat_completions], source: "proxy/common_utils/reset_budget_job.py", rationale: "Each window of a multi-window budget resets on its own schedule"}
diff --git a/tests/e2e/quota_management/budgets/test_budget_enforcement_e2e.py b/tests/e2e/quota_management/budgets/test_budget_enforcement_e2e.py
index 8b93afb4752..918739863ce 100644
--- a/tests/e2e/quota_management/budgets/test_budget_enforcement_e2e.py
+++ b/tests/e2e/quota_management/budgets/test_budget_enforcement_e2e.py
@@ -79,9 +79,14 @@ class TestBudgetBlocksPerLevel:
)
@pytest.mark.covers("quota_management.budget.internal_user.blocks_over_limit")
- def test_user_budget_enforced_across_all_their_keys(
+ def test_user_budget_enforced_across_their_personal_keys(
self, client: BudgetClient, resources: ResourceManager
) -> None:
+ """A user's max_budget follows the person across their personal keys, so a
+ second untouched key is not a fresh allowance. It stops at the team
+ boundary: the same user's team-scoped key is governed by the team and
+ team-member budgets, both uncapped here, so it is the control that must
+ keep serving while the personal keys are refused."""
user_id = client.create_user(max_budget=TINY_CAP)
resources.defer(lambda: client.delete_user(user_id))
first_key = client.generate_key(user_id=user_id)
@@ -95,12 +100,17 @@ class TestBudgetBlocksPerLevel:
resources.defer(lambda: client.delete_key(team_key))
_assert_blocked_429(client, first_key)
- for label, key in (("second personal key", second_key), ("team-member key", team_key)):
- result = _chat(client, key)
- assert is_budget_block(result) and result.status_code == 429, (
- f"the {label} of a user over budget must get the same 429 budget_exceeded, "
- f"got {result.status_code}: {result.body[:200]}"
- )
+ second = _chat(client, second_key)
+ assert is_budget_block(second) and second.status_code == 429, (
+ f"the second personal key of a user over budget must get the same 429 budget_exceeded, "
+ f"got {second.status_code}: {second.body[:200]}"
+ )
+ team_result = _chat(client, team_key)
+ assert not is_budget_block(team_result), (
+ f"the team-scoped key of a user over their personal budget must keep serving; "
+ f"got {team_result.status_code}: {team_result.body[:200]}"
+ )
+ require_successful_call(team_result)
@pytest.mark.covers("quota_management.budget.end_user.blocks_over_limit")
def test_end_user_budget_blocks_attributed_calls(
diff --git a/tests/e2e/quota_management/budgets/test_budget_reset_e2e.py b/tests/e2e/quota_management/budgets/test_budget_reset_e2e.py
index 793b22a47c7..b7b7f269c47 100644
--- a/tests/e2e/quota_management/budgets/test_budget_reset_e2e.py
+++ b/tests/e2e/quota_management/budgets/test_budget_reset_e2e.py
@@ -102,21 +102,6 @@ class TestBudgetResetPerLevel:
_drive_to_block(client, key)
_poll_until_serves_again(client, key)
- @pytest.mark.covers("quota_management.budget.internal_user.resets_after_window")
- def test_team_member_key_user_budget_resets_after_window(
- self, client: BudgetClient, resources: ResourceManager
- ) -> None:
- user_id = client.create_user(max_budget=TINY_CAP, budget_duration=WINDOW)
- resources.defer(lambda: client.delete_user(user_id))
- team_id = client.create_team(alias=f"e2e-user-team-reset-{unique_marker()}")
- resources.defer(lambda: client.delete_team(team_id))
- client.add_team_member(team_id, user_id, max_budget_in_team=100.0)
- key = client.generate_key(team_id=team_id, user_id=user_id)
- resources.defer(lambda: client.delete_key(key))
-
- _drive_to_block(client, key)
- _poll_until_serves_again(client, key)
-
class TestKeyBudgetResetAcrossKeyKinds:
"""The tiny max_budget and its 30s window sit on the key itself while the user,
From 5c161320745f98ac71b772def93352c8d8f79c1c Mon Sep 17 00:00:00 2001
From: Yassin Kortam
Date: Thu, 30 Jul 2026 14:10:26 -0700
Subject: [PATCH 26/33] feat(guardrails): scan and mask MCP tool results via
post_mcp_call (#35155)
Guardrails could only see the MCP tool call request (pre_mcp_call /
during_mcp_call); the tool result went back to the client unscanned, so a tool
that returns sensitive data bypassed every configured guardrail.
Adds a `post_mcp_call` event hook that runs after the tool executes and routes
the result through the unified apply_guardrail seam, so a text guardrail (e.g.
presidio) can mask sensitive values in the tool output or reject the result
without any MCP-specific code of its own.
- MCPGuardrailTranslationHandler.process_output_response now extracts the tool
result's text content into GenericGuardrailAPIInputs["texts"], calls
apply_guardrail with input_type="response", and writes the returned text back
into the content list in place (the logging payload already references that
object, so a copy would leave the unmasked text in the spend log)
- ProxyLogging.post_mcp_call_hook dispatches guardrails that implement
apply_guardrail, gated on should_run_guardrail(post_mcp_call); guardrails
implementing async_post_mcp_tool_call_hook keep their existing dispatch and
are not run twice
- both MCP tool-call paths (mcp_server and the Responses API handler) now honor
the rewritten result, and the REST path no longer swallows a guardrail
rejection as a logging failure
- shared, duck-typed MCP content helpers live in mcp_server/utils.py next to
extract_mcp_tool_result_error_message
- documents that async_post_mcp_tool_call_hook's return value is discarded by
every call site, so that hook only takes effect by mutating in place
---
litellm/integrations/custom_logger.py | 8 +-
.../guardrail_translation/handler.py | 101 +++++-
.../mcp_server/rest_endpoints.py | 44 ++-
.../proxy/_experimental/mcp_server/server.py | 53 ++-
.../proxy/_experimental/mcp_server/utils.py | 160 +++++++++
.../guardrails/guardrail_hooks/presidio.py | 1 +
litellm/proxy/utils.py | 55 ++++
.../mcp/litellm_proxy_mcp_handler.py | 19 +-
litellm/types/guardrails.py | 1 +
.../code_coverage_tests/recursive_detector.py | 3 +
.../test_mcp_guardrail_handler.py | 304 ++++++++++++++++++
.../mcp_server/test_mcp_server.py | 103 +++++-
.../mcp_server/test_rest_endpoints.py | 83 +++++
tests/test_litellm/proxy/test_proxy_utils.py | 138 ++++++++
.../_components/add_guardrail_form.tsx | 1 +
15 files changed, 1040 insertions(+), 34 deletions(-)
diff --git a/litellm/integrations/custom_logger.py b/litellm/integrations/custom_logger.py
index 108928871b0..8b831b55da3 100644
--- a/litellm/integrations/custom_logger.py
+++ b/litellm/integrations/custom_logger.py
@@ -519,7 +519,13 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac
"""
This log gets called after the MCP tool call is made.
- Useful if you want to modiy the standard logging payload after the MCP tool call is made.
+ Useful if you want to modify the standard logging payload after the MCP tool call is made.
+
+ To change what the caller sends back to the MCP client, mutate ``response_obj``
+ in place: every call site discards the returned object, because the
+ dispatcher unwraps it to ``mcp_tool_call_response`` (a raw content list, not
+ a ``CallToolResult``) which the tool-call paths cannot forward. Guardrails
+ that mask or reject tool output should use ``post_mcp_call`` instead.
"""
return None
diff --git a/litellm/proxy/_experimental/mcp_server/guardrail_translation/handler.py b/litellm/proxy/_experimental/mcp_server/guardrail_translation/handler.py
index b668833e638..909925da00a 100644
--- a/litellm/proxy/_experimental/mcp_server/guardrail_translation/handler.py
+++ b/litellm/proxy/_experimental/mcp_server/guardrail_translation/handler.py
@@ -13,11 +13,22 @@ payload (name + arguments) so we just build the tool_call.
from typing import TYPE_CHECKING, Any, Dict, Optional
+from fastapi import HTTPException
from mcp.types import Tool as MCPTool
from litellm._logging import verbose_proxy_logger
from litellm.experimental_mcp_client.tools import transform_mcp_tool_to_openai_tool
from litellm.llms.base_llm.guardrail_translation.base_translation import BaseTranslation
+from litellm.proxy._experimental.mcp_server.utils import (
+ json_string_leaves,
+ json_unrewritable_labels,
+ mcp_content_item_text,
+ mcp_tool_result_content_list,
+ mcp_tool_result_structured_content,
+ set_mcp_tool_result_structured_content,
+ with_json_string_leaves,
+ with_mcp_content_item_text,
+)
from litellm.types.llms.openai import (
ChatCompletionToolParam,
ChatCompletionToolParamFunctionChunk,
@@ -92,7 +103,93 @@ class MCPGuardrailTranslationHandler(BaseTranslation):
user_api_key_dict: Optional[Any] = None,
request_data: Optional[dict] = None,
) -> Any:
- verbose_proxy_logger.debug(
- "MCP Guardrail: Output processing not implemented for MCP tools",
+ """Scan the text content of an MCP tool result and write masked text back.
+
+ The content list is rewritten in place (only the entries the guardrail
+ actually changed) rather than returned as a new result: the same object is
+ already referenced by the logging payload captured before this hook runs,
+ so a copy would leave the unmasked text in the spend log / span. A
+ guardrail that rejects the result raises, and the exception propagates to
+ the caller.
+
+ ``structuredContent`` is scanned and masked too, in the same
+ ``apply_guardrail`` call: it is serialized to the client alongside
+ ``content``, so a value living only there would otherwise reach the
+ client unscanned.
+ """
+ content = mcp_tool_result_content_list(response)
+ text_blocks = (
+ tuple(
+ (index, text) for index, item in enumerate(content) if (text := mcp_content_item_text(item)) is not None
+ )
+ if content is not None
+ else ()
)
+
+ structured = mcp_tool_result_structured_content(response)
+ structured_leaves = json_string_leaves(structured) if structured is not None else ()
+ structured_labels = json_unrewritable_labels(structured) if structured is not None else ()
+ if structured_leaves is None or structured_labels is None:
+ raise HTTPException(
+ status_code=400,
+ detail={
+ "error": (
+ "Content blocked: MCP tool result structuredContent is nested too deeply to be scanned "
+ "by the configured guardrail"
+ )
+ },
+ )
+
+ if not text_blocks and not structured_leaves and not structured_labels:
+ verbose_proxy_logger.debug("MCP Guardrail: tool result has no scannable text, nothing to do")
+ return response
+
+ originals = (
+ tuple(text for _, text in text_blocks) + tuple(text for _, text in structured_leaves) + structured_labels
+ )
+ guardrailed_inputs = await guardrail_to_apply.apply_guardrail(
+ inputs=GenericGuardrailAPIInputs(texts=list(originals)),
+ request_data=request_data if request_data is not None else {},
+ input_type="response",
+ logging_obj=litellm_logging_obj,
+ )
+ masked_texts = guardrailed_inputs.get("texts") if guardrailed_inputs else None
+ if masked_texts is None:
+ return response
+ if len(masked_texts) != len(originals):
+ verbose_proxy_logger.warning(
+ "MCP Guardrail: guardrail returned %d texts for %d tool result texts; leaving the result unmasked",
+ len(masked_texts),
+ len(originals),
+ )
+ return response
+
+ split = len(text_blocks)
+ if content is not None:
+ for (index, original), masked in zip(text_blocks, masked_texts[:split]):
+ if masked != original:
+ content[index] = with_mcp_content_item_text(content[index], masked)
+
+ label_start = split + len(structured_leaves)
+ if any(masked != original for original, masked in zip(structured_labels, masked_texts[label_start:])):
+ raise HTTPException(
+ status_code=400,
+ detail={
+ "error": (
+ "Content blocked: MCP tool result matched a masking rule on a non-rewritable field "
+ "(a structuredContent key or numeric value), which cannot be redacted without changing "
+ "the payload contract"
+ )
+ },
+ )
+
+ structured_replacements = {
+ path: masked
+ for (path, original), masked in zip(structured_leaves, masked_texts[split:label_start])
+ if masked != original
+ }
+ if structured_replacements:
+ set_mcp_tool_result_structured_content(
+ response, with_json_string_leaves(structured, structured_replacements)
+ )
return response
diff --git a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py
index af3d966c95b..9b51513f4ac 100644
--- a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py
+++ b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py
@@ -12,6 +12,11 @@ import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from litellm._logging import verbose_logger
+from litellm.exceptions import (
+ BlockedPiiEntityError,
+ GuardrailRaisedException,
+ ModifyResponseException,
+)
from litellm.proxy._experimental.mcp_server.exceptions import (
MCPServerListError,
MCPUpstreamAuthError,
@@ -33,6 +38,8 @@ from litellm.proxy.auth.ip_address_utils import IPAddressUtils
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
if TYPE_CHECKING:
+ from mcp.types import CallToolResult
+
from litellm.proxy._experimental.mcp_server.db import OAuthCredentialPayload
from litellm.proxy.common_utils.http_parsing_utils import _safe_get_request_headers
from litellm.types.mcp import MCPAuth
@@ -51,6 +58,13 @@ router = APIRouter(
tags=["mcp"],
)
+_MCP_GUARDRAIL_REJECTIONS = (
+ BlockedPiiEntityError,
+ GuardrailRaisedException,
+ ModifyResponseException,
+ HTTPException,
+)
+
def _connection_error_message(exc: BaseException) -> str:
if isinstance(exc, httpx.LocalProtocolError):
@@ -99,9 +113,17 @@ if MCP_AVAILABLE:
end_time: datetime,
user_api_key_auth: UserAPIKeyAuth | None = None,
request_data: Mapping[str, object] | None = None,
- ) -> None:
+ ) -> "CallToolResult":
+ """Fire post-call logging, returning the tool result to send to the client.
+
+ ``post_mcp_call`` guardrails already ran on ``execute_mcp_tool``'s return
+ path, so the result arriving here is the guardrailed one. A guardrail
+ rejection raised by a native ``async_post_mcp_tool_call_hook`` is still
+ re-raised rather than swallowed as a logging failure, which would return
+ the unguarded result.
+ """
if logging_obj is None:
- return
+ return result
logging_results = await asyncio.gather(
_fire_mcp_tool_call_logging(
logging_obj,
@@ -113,11 +135,13 @@ if MCP_AVAILABLE:
),
return_exceptions=True,
)
- logging_error = logging_results[0]
- if isinstance(logging_error, asyncio.CancelledError):
- raise logging_error
- if isinstance(logging_error, BaseException):
- verbose_logger.warning("MCP tool call logging failed (continuing): %s", logging_error)
+ outcome = logging_results[0]
+ if isinstance(outcome, (asyncio.CancelledError, *_MCP_GUARDRAIL_REJECTIONS)):
+ raise outcome
+ if isinstance(outcome, BaseException):
+ verbose_logger.warning("MCP tool call logging failed (continuing): %s", outcome)
+ return result
+ return outcome
def _relay_upstream_auth_http_exception(e: MCPUpstreamAuthError, request: Request) -> HTTPException:
"""Convert a client-forwarded pass-through upstream 401 into an HTTPException that preserves the
@@ -196,7 +220,7 @@ if MCP_AVAILABLE:
raw_headers=virtual_raw_headers,
litellm_logging_obj=virtual_logging_obj,
)
- await _safe_fire_mcp_tool_call_logging(
+ return await _safe_fire_mcp_tool_call_logging(
virtual_logging_obj,
result,
_tool_start_time,
@@ -204,7 +228,6 @@ if MCP_AVAILABLE:
user_api_key_auth=user_api_key_dict,
request_data=data,
)
- return result
def _get_server_auth_header(
server,
@@ -998,7 +1021,7 @@ if MCP_AVAILABLE:
litellm_logging_obj=data.get("litellm_logging_obj"),
requested_server_id=canonical_server_id,
)
- await _safe_fire_mcp_tool_call_logging(
+ return await _safe_fire_mcp_tool_call_logging(
logging_obj,
result,
_tool_start_time,
@@ -1006,7 +1029,6 @@ if MCP_AVAILABLE:
user_api_key_auth=user_api_key_dict,
request_data=data,
)
- return result
except MCPMissingUserEnvVarsError as e:
verbose_logger.info(
"MCP tool call missing per-user env vars: server_id=%s missing=%s",
diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py
index 14673cf12c1..06a3a5a61e4 100644
--- a/litellm/proxy/_experimental/mcp_server/server.py
+++ b/litellm/proxy/_experimental/mcp_server/server.py
@@ -2910,7 +2910,38 @@ if MCP_AVAILABLE:
local_content = await _handle_local_mcp_tool(original_tool_name, arguments)
response = CallToolResult(content=cast(Any, local_content), isError=False)
- return response
+ return await _run_post_mcp_call_guardrails(
+ result=response,
+ litellm_logging_obj=litellm_logging_obj,
+ user_api_key_auth=user_api_key_auth,
+ request_data=kwargs,
+ )
+
+ async def _run_post_mcp_call_guardrails(
+ result: CallToolResult,
+ litellm_logging_obj: LiteLLMLoggingObj | None,
+ user_api_key_auth: UserAPIKeyAuth | None,
+ request_data: Mapping[str, object],
+ ) -> CallToolResult:
+ """Run ``post_mcp_call`` guardrails over an executed tool result.
+
+ Lives on ``execute_mcp_tool``'s return path rather than inside
+ ``_fire_mcp_tool_call_logging`` so enforcement never depends on logging
+ being configured, and so every dispatch route gets it: the MCP protocol
+ handler, the REST endpoint, and tool search all funnel through here.
+ A guardrail that rejects the result raises, matching ``pre_mcp_call``.
+ """
+ from litellm.proxy.proxy_server import proxy_logging_obj
+
+ if proxy_logging_obj is None:
+ return result
+ return await proxy_logging_obj.post_mcp_call_hook(
+ response=result,
+ request_data=(
+ litellm_logging_obj.model_call_details if litellm_logging_obj is not None else dict(request_data)
+ ),
+ user_api_key_dict=user_api_key_auth,
+ )
_MCP_CREDENTIAL_REQUEST_FIELDS = frozenset(
{
@@ -2929,8 +2960,14 @@ if MCP_AVAILABLE:
end_time: datetime,
user_api_key_auth: UserAPIKeyAuth | None = None,
request_data: Mapping[str, object] | None = None,
- ) -> None:
- """Fire post-call logging for an executed MCP tool call.
+ ) -> CallToolResult:
+ """Fire post-call logging for an executed MCP tool call, returning the result to send.
+
+ The returned result is what the caller must forward to the client: a
+ ``post_mcp_call`` guardrail may rewrite the tool output (e.g. mask
+ sensitive values) or reject it, in which case its exception propagates.
+ Guardrails run before the success/failure logging so the masked text, not
+ the raw one, is what gets logged.
A result with ``isError=True`` is logged as a failure (``status="failure"``
payload, so OTel marks the span ERROR) while the HTTP wire behavior stays
@@ -2946,6 +2983,8 @@ if MCP_AVAILABLE:
stripped before the dict is handed to ``post_call_failure_hook``
callbacks.
"""
+ from litellm.proxy.proxy_server import proxy_logging_obj
+
logging_obj.post_call(original_response=result)
await logging_obj.async_post_mcp_tool_call_hook(
kwargs=logging_obj.model_call_details,
@@ -2957,7 +2996,7 @@ if MCP_AVAILABLE:
error_message = extract_mcp_tool_result_error_message(result)
if error_message is None:
await logging_obj.async_success_handler(result=result, start_time=start_time, end_time=end_time)
- return
+ return result
logging_obj.has_run_logging(event_type="sync_success")
logging_obj.has_run_logging(event_type="async_success")
@@ -2966,8 +3005,7 @@ if MCP_AVAILABLE:
await logging_obj.async_failure_handler(tool_error, "", start_time, end_time)
if user_api_key_auth is None:
- return
- from litellm.proxy.proxy_server import proxy_logging_obj
+ return result
if proxy_logging_obj:
sanitized_request_data = {
@@ -2979,6 +3017,7 @@ if MCP_AVAILABLE:
user_api_key_dict=user_api_key_auth,
route="/mcp/call_tool",
)
+ return result
@client
async def call_mcp_tool(
@@ -3062,7 +3101,7 @@ if MCP_AVAILABLE:
raise
if litellm_logging_obj:
- await _fire_mcp_tool_call_logging(
+ response = await _fire_mcp_tool_call_logging(
logging_obj=litellm_logging_obj,
result=response,
start_time=start_time,
diff --git a/litellm/proxy/_experimental/mcp_server/utils.py b/litellm/proxy/_experimental/mcp_server/utils.py
index 80a469b8c1a..afd396adc4c 100644
--- a/litellm/proxy/_experimental/mcp_server/utils.py
+++ b/litellm/proxy/_experimental/mcp_server/utils.py
@@ -4,6 +4,7 @@ MCP Server Utilities
import json
import re
+from collections.abc import MutableMapping, MutableSequence
from typing import (
Any,
Dict,
@@ -434,6 +435,56 @@ def extract_mcp_tool_result_error_message(result: object) -> Optional[str]:
return "MCP tool call returned isError=true"
+def mcp_tool_result_content_list(result: object) -> MutableSequence[object] | None: # mutable-ok: see below
+ """The mutable content list of an MCP tool result, or ``None`` when it has none.
+
+ Deliberately mutable: a guardrail masking the result rewrites entries in place,
+ because the logging payload captured before the guardrail runs references this
+ same list, so handing back a copy would leave the unmasked text in the spend log
+ and the OTel span.
+
+ Accepts both ``mcp.types.CallToolResult`` objects and their dict
+ equivalents, duck-typed so the ``mcp`` package is not required.
+ """
+ content: object = result.get("content") if isinstance(result, Mapping) else getattr(result, "content", None)
+ if isinstance(content, MutableSequence):
+ return content
+ return None
+
+
+def mcp_content_item_text(item: object) -> str | None:
+ """The ``text`` of a rewritable MCP content item, or ``None``.
+
+ Only mappings and Pydantic-style models report a text, because those are the
+ only shapes ``with_mcp_content_item_text`` can rewrite; a caller therefore
+ never reads text it would be unable to write back (e.g. masked by a
+ guardrail). Non-text content (images, embedded resources) has no ``text``
+ and is reported as ``None``.
+ """
+ text: object
+ if isinstance(item, Mapping):
+ text = item.get("text")
+ elif callable(getattr(item, "model_copy", None)):
+ text = getattr(item, "text", None)
+ else:
+ return None
+ return text if isinstance(text, str) else None
+
+
+def with_mcp_content_item_text(item: object, text: str) -> object:
+ """A copy of an MCP content item carrying ``text`` instead of its own.
+
+ Only meaningful for items ``mcp_content_item_text`` returned a text for; any
+ other item is returned unchanged.
+ """
+ if isinstance(item, Mapping):
+ return {**item, "text": text}
+ model_copy = getattr(item, "model_copy", None)
+ if callable(model_copy):
+ return model_copy(update={"text": text})
+ return item
+
+
TOOL_DISPLAY_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+$")
@@ -618,3 +669,112 @@ def merge_mcp_headers(
merged.update({str(k): str(v) for k, v in static_headers.items()})
return merged or None
+
+
+# Local rather than litellm.constants: this module deliberately imports no litellm
+# package, so pulling one in for a single integer would drag in litellm/__init__.
+MAX_STRUCTURED_CONTENT_SCAN_DEPTH = 100
+
+
+JSONLeafPath = tuple[str | int, ...]
+
+
+def _flatten_leaf_groups(
+ groups: Iterable[tuple[tuple[JSONLeafPath, str], ...] | None],
+) -> tuple[tuple[JSONLeafPath, str], ...] | None:
+ """Concatenate child leaf groups, propagating the too-deep sentinel."""
+ materialized = tuple(groups)
+ if any(group is None for group in materialized):
+ return None
+ return tuple(leaf for group in materialized if group is not None for leaf in group)
+
+
+def json_string_leaves(value: object, path: JSONLeafPath = ()) -> tuple[tuple[JSONLeafPath, str], ...] | None:
+ """Depth-first, deterministically ordered string leaves of a JSON value.
+
+ Returns ``None`` when the value is nested past ``MAX_STRUCTURED_CONTENT_SCAN_DEPTH``,
+ so the caller blocks rather than letting deeper values through unscanned; an
+ empty tuple means there was simply nothing to scan. A sentinel rather than an
+ exception because this module is reloaded by tests (see the note above the
+ environment-backed constants), which would give a custom exception class a new
+ identity and let it escape a caller's ``except``.
+ """
+ if len(path) > MAX_STRUCTURED_CONTENT_SCAN_DEPTH:
+ return None
+ if isinstance(value, str):
+ return ((path, value),)
+ if isinstance(value, dict):
+ return _flatten_leaf_groups(json_string_leaves(item, (*path, key)) for key, item in value.items())
+ if isinstance(value, list):
+ return _flatten_leaf_groups(json_string_leaves(item, (*path, index)) for index, item in enumerate(value))
+ return ()
+
+
+def with_json_string_leaves(
+ value: object,
+ replacements: Mapping[JSONLeafPath, str],
+ path: JSONLeafPath = (),
+) -> object:
+ """Rebuild a JSON value with the guardrail's rewritten string leaves."""
+ if isinstance(value, str):
+ return replacements.get(path, value)
+ if isinstance(value, dict):
+ return {key: with_json_string_leaves(item, replacements, (*path, key)) for key, item in value.items()}
+ if isinstance(value, list):
+ return [with_json_string_leaves(item, replacements, (*path, index)) for index, item in enumerate(value)]
+ return value
+
+
+def json_unrewritable_labels(value: object, path_depth: int = 0) -> tuple[str, ...] | None:
+ """Strings in a JSON value that carry meaning but cannot be rewritten.
+
+ Dictionary keys and non-string scalars: masking either would change the
+ payload's contract rather than redact a value, so a caller scans these and
+ blocks on a match instead of rewriting, matching what the content filter
+ already does for MCP tool call arguments. ``None`` means the value is nested
+ past the scan depth, same contract as ``json_string_leaves``.
+ """
+ if path_depth > MAX_STRUCTURED_CONTENT_SCAN_DEPTH:
+ return None
+ if isinstance(value, bool) or value is None or isinstance(value, str):
+ return ()
+ if isinstance(value, (int, float)):
+ return (str(value),)
+ if isinstance(value, dict):
+ own = tuple(key for key in value if isinstance(key, str))
+ nested = tuple(json_unrewritable_labels(item, path_depth + 1) for item in value.values())
+ if any(group is None for group in nested):
+ return None
+ return own + tuple(label for group in nested if group is not None for label in group)
+ if isinstance(value, list):
+ nested = tuple(json_unrewritable_labels(item, path_depth + 1) for item in value)
+ if any(group is None for group in nested):
+ return None
+ return tuple(label for group in nested if group is not None for label in group)
+ return ()
+
+
+def mcp_tool_result_structured_content(result: object) -> object:
+ """The ``structuredContent`` of an MCP tool result, or ``None`` when it has none."""
+ if isinstance(result, Mapping):
+ return result.get("structuredContent")
+ return getattr(result, "structuredContent", None)
+
+
+def set_mcp_tool_result_structured_content(result: object, value: object) -> bool:
+ """Replace ``structuredContent`` in place; ``False`` when the shape does not carry it.
+
+ In place for the same reason the content list is: the logging payload captured
+ before the guardrail ran references this object, so a copy would leave the
+ unmasked value in the spend log and the OTel span.
+ """
+ if isinstance(result, MutableMapping):
+ result["structuredContent"] = value
+ return True
+ if not hasattr(result, "structuredContent"):
+ return False
+ try:
+ setattr(result, "structuredContent", value) # attribute name is fixed by the MCP result shape
+ return True
+ except (AttributeError, TypeError, ValueError):
+ return False
diff --git a/litellm/proxy/guardrails/guardrail_hooks/presidio.py b/litellm/proxy/guardrails/guardrail_hooks/presidio.py
index a0c822964a0..60e1947b752 100644
--- a/litellm/proxy/guardrails/guardrail_hooks/presidio.py
+++ b/litellm/proxy/guardrails/guardrail_hooks/presidio.py
@@ -75,6 +75,7 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail):
GuardrailEventHooks.post_call,
GuardrailEventHooks.logging_only,
GuardrailEventHooks.pre_mcp_call,
+ GuardrailEventHooks.post_mcp_call,
]
# Class variables or attributes
diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py
index 924189fed4b..1e80f15a761 100644
--- a/litellm/proxy/utils.py
+++ b/litellm/proxy/utils.py
@@ -109,6 +109,7 @@ from litellm.litellm_core_utils.core_helpers import coerce_token_limit
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
+from litellm.llms import load_guardrail_translation_mappings
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.proxy._types import (
AlertType,
@@ -172,6 +173,7 @@ from litellm.types.proxy.policy_engine.pipeline_types import PipelineExecutionRe
from litellm.types.utils import LLMResponseTypes, LoggedLiteLLMParams
if TYPE_CHECKING:
+ from mcp.types import CallToolResult
from opentelemetry.trace import Span as _Span
from prisma.client import TransactionManager
@@ -2470,6 +2472,59 @@ class ProxyLogging:
if raised:
raise raised[0]
+ async def post_mcp_call_hook(
+ self,
+ response: "CallToolResult",
+ request_data: Mapping[str, Any],
+ user_api_key_dict: UserAPIKeyAuth | None = None,
+ ) -> "CallToolResult":
+ """
+ Run guardrails configured for ``post_mcp_call`` against an MCP tool result.
+
+ The MCP counterpart of ``post_call_success_hook``: guardrails that
+ implement ``apply_guardrail`` see the tool result's text through the
+ unified guardrail seam (``MCPGuardrailTranslationHandler``), so a text
+ guardrail can mask sensitive values in the result without any MCP-specific
+ code of its own. Guardrails that instead implement
+ ``async_post_mcp_tool_call_hook`` are dispatched by
+ ``Logging.async_post_mcp_tool_call_hook`` and are not run here.
+
+ A guardrail that rejects the result raises, and the exception propagates
+ (matching the inbound ``pre_mcp_call`` behavior) rather than being
+ swallowed into an unguarded result.
+ """
+ caps = ProxyLogging._callback_capabilities()
+ if not caps.has_guardrail:
+ return response
+
+ handler_cls = load_guardrail_translation_mappings().get(CallTypes.call_mcp_tool)
+ if handler_cls is None:
+ verbose_proxy_logger.debug("MCP guardrail translation handler unavailable; skipping post_mcp_call hook")
+ return response
+
+ for callback in caps.resolved_callbacks:
+ if not isinstance(callback, CustomGuardrail):
+ continue
+ if "apply_guardrail" not in type(callback).__dict__:
+ continue
+ if (
+ callback.should_run_guardrail(data=request_data, event_type=GuardrailEventHooks.post_mcp_call)
+ is not True
+ ):
+ continue
+ response = await self._run_guardrail_with_metrics(
+ callback,
+ handler_cls().process_output_response(
+ response=response,
+ guardrail_to_apply=callback,
+ litellm_logging_obj=request_data.get("litellm_logging_obj"),
+ user_api_key_dict=user_api_key_dict,
+ request_data=request_data,
+ ),
+ "post_mcp_call",
+ )
+ return response
+
async def post_call_response_headers_hook(
self,
data: dict,
diff --git a/litellm/responses/mcp/litellm_proxy_mcp_handler.py b/litellm/responses/mcp/litellm_proxy_mcp_handler.py
index cb680dc8b86..9d30e40cd54 100644
--- a/litellm/responses/mcp/litellm_proxy_mcp_handler.py
+++ b/litellm/responses/mcp/litellm_proxy_mcp_handler.py
@@ -795,20 +795,33 @@ class LiteLLM_Proxy_MCP_Handler:
proxy_logging_obj=proxy_logging_obj,
)
+ if proxy_logging_obj:
+ result = await proxy_logging_obj.post_mcp_call_hook(
+ response=result,
+ request_data=(
+ litellm_logging_obj.model_call_details
+ if litellm_logging_obj
+ else {"mcp_tool_name": tool_name}
+ ),
+ user_api_key_dict=user_api_key_auth,
+ )
+
if litellm_logging_obj:
try:
litellm_logging_obj.post_call(original_response=result)
- end_time = datetime.now()
await litellm_logging_obj.async_post_mcp_tool_call_hook(
kwargs=litellm_logging_obj.model_call_details,
response_obj=result,
start_time=start_time,
- end_time=end_time,
+ end_time=datetime.now(),
)
+ except Exception:
+ verbose_logger.exception("Failed to run post-call logging for MCP tool call %s", tool_name)
+ try:
await litellm_logging_obj.async_success_handler(
result=result,
start_time=start_time,
- end_time=end_time,
+ end_time=datetime.now(),
)
except Exception:
verbose_logger.exception("Failed to log MCP tool call success for %s", tool_name)
diff --git a/litellm/types/guardrails.py b/litellm/types/guardrails.py
index a324e71e289..af419d8cb6f 100644
--- a/litellm/types/guardrails.py
+++ b/litellm/types/guardrails.py
@@ -1043,6 +1043,7 @@ class GuardrailEventHooks(str, Enum):
logging_only = "logging_only"
pre_mcp_call = "pre_mcp_call"
during_mcp_call = "during_mcp_call"
+ post_mcp_call = "post_mcp_call"
realtime_input_transcription = "realtime_input_transcription"
diff --git a/tests/code_coverage_tests/recursive_detector.py b/tests/code_coverage_tests/recursive_detector.py
index 0bc3cebdd5a..244e17b46a1 100644
--- a/tests/code_coverage_tests/recursive_detector.py
+++ b/tests/code_coverage_tests/recursive_detector.py
@@ -57,6 +57,9 @@ IGNORE_FUNCTIONS = [
"_filter_mcp_argument_value", # max depth set (DEFAULT_MAX_RECURSE_DEPTH); fails closed by blocking the MCP call at the cap.
"_redact_scanned_content", # max depth set (DEFAULT_MAX_RECURSE_DEPTH); fails closed by returning "[REDACTED]" at the cap.
"_iter_fallback_targets", # max depth set (2 * ROUTER_MAX_FALLBACKS); fails closed by raising ValueError at the cap.
+ "json_string_leaves", # max depth set (MAX_STRUCTURED_CONTENT_SCAN_DEPTH); fails closed by raising at the cap so nothing goes unscanned.
+ "with_json_string_leaves", # transitively bounded: only runs on a tree json_string_leaves already walked under the cap.
+ "json_unrewritable_labels", # max depth set (MAX_STRUCTURED_CONTENT_SCAN_DEPTH); returns the None sentinel at the cap so the caller blocks.
]
diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/guardrail_translation/test_mcp_guardrail_handler.py b/tests/test_litellm/proxy/_experimental/mcp_server/guardrail_translation/test_mcp_guardrail_handler.py
index 5dbad53948b..2e286a237c4 100644
--- a/tests/test_litellm/proxy/_experimental/mcp_server/guardrail_translation/test_mcp_guardrail_handler.py
+++ b/tests/test_litellm/proxy/_experimental/mcp_server/guardrail_translation/test_mcp_guardrail_handler.py
@@ -1,11 +1,14 @@
"""Tests for the MCP guardrail translation handler."""
import pytest
+from mcp.types import CallToolResult, ImageContent, TextContent
+from litellm.exceptions import BlockedPiiEntityError
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.proxy._experimental.mcp_server.guardrail_translation.handler import (
MCPGuardrailTranslationHandler,
)
+from litellm.types.utils import GenericGuardrailAPIInputs
class MockGuardrail(CustomGuardrail):
@@ -80,3 +83,304 @@ async def test_process_input_messages_handles_minimal_data():
tools = guardrail.last_inputs.get("tools", [])
assert len(tools) == 1
assert tools[0]["function"]["name"] == "simple_tool"
+
+
+class MaskingGuardrail(CustomGuardrail):
+ """Guardrail that rewrites every scanned text, recording what it saw."""
+
+ def __init__(self, masked_texts=None, raises=None):
+ super().__init__(guardrail_name="masking-mcp-guardrail")
+ self.masked_texts = masked_texts
+ self.raises = raises
+ self.call_count = 0
+ self.last_inputs = None
+ self.last_input_type = None
+ self.last_request_data = None
+
+ async def apply_guardrail(self, inputs, request_data, input_type, **kwargs):
+ self.call_count += 1
+ self.last_inputs = inputs
+ self.last_input_type = input_type
+ self.last_request_data = request_data
+ if self.raises is not None:
+ raise self.raises
+ if self.masked_texts is None:
+ return inputs
+ return GenericGuardrailAPIInputs(texts=list(self.masked_texts))
+
+
+@pytest.mark.asyncio
+async def test_process_output_response_masks_text_content():
+ """Masked text returned by the guardrail must land in the tool result."""
+ handler = MCPGuardrailTranslationHandler()
+ guardrail = MaskingGuardrail(masked_texts=["email ", "call "])
+ result = CallToolResult(
+ content=[
+ TextContent(type="text", text="email jane@example.com"),
+ TextContent(type="text", text="call 415-555-0132"),
+ ],
+ isError=False,
+ )
+
+ returned = await handler.process_output_response(
+ response=result,
+ guardrail_to_apply=guardrail,
+ request_data={"mcp_tool_name": "echo"},
+ )
+
+ assert guardrail.call_count == 1
+ assert guardrail.last_input_type == "response"
+ assert guardrail.last_inputs["texts"] == ["email jane@example.com", "call 415-555-0132"]
+ assert [item.text for item in returned.content] == ["email ", "call "]
+ assert [item.text for item in result.content] == ["email ", "call "]
+
+
+@pytest.mark.asyncio
+async def test_process_output_response_masks_dict_shaped_result():
+ """A dict-shaped tool result (REST/JSON-RPC payload) must be masked too."""
+ handler = MCPGuardrailTranslationHandler()
+ guardrail = MaskingGuardrail(masked_texts=[""])
+ result = {"content": [{"type": "text", "text": "jane@example.com"}], "isError": False}
+
+ returned = await handler.process_output_response(response=result, guardrail_to_apply=guardrail)
+
+ assert returned["content"][0]["text"] == ""
+ assert returned["content"][0]["type"] == "text"
+
+
+@pytest.mark.asyncio
+async def test_process_output_response_propagates_block():
+ """A guardrail rejecting the tool result must not be swallowed."""
+ handler = MCPGuardrailTranslationHandler()
+ guardrail = MaskingGuardrail(
+ raises=BlockedPiiEntityError(entity_type="EMAIL_ADDRESS", guardrail_name="masking-mcp-guardrail")
+ )
+ result = CallToolResult(content=[TextContent(type="text", text="jane@example.com")], isError=False)
+
+ with pytest.raises(BlockedPiiEntityError):
+ await handler.process_output_response(response=result, guardrail_to_apply=guardrail)
+
+
+@pytest.mark.asyncio
+async def test_process_output_response_skips_non_text_content():
+ """A result carrying no text content must not be sent to the guardrail."""
+ handler = MCPGuardrailTranslationHandler()
+ guardrail = MaskingGuardrail(masked_texts=["should not be used"])
+ result = CallToolResult(
+ content=[ImageContent(type="image", data="aGk=", mimeType="image/png")],
+ isError=False,
+ )
+
+ returned = await handler.process_output_response(response=result, guardrail_to_apply=guardrail)
+
+ assert guardrail.call_count == 0
+ assert returned is result
+
+
+@pytest.mark.asyncio
+async def test_process_output_response_handles_result_without_content():
+ """An unexpected result shape must be passed through, not crash the tool call."""
+ handler = MCPGuardrailTranslationHandler()
+ guardrail = MaskingGuardrail(masked_texts=["should not be used"])
+
+ returned = await handler.process_output_response(response={"error": "boom"}, guardrail_to_apply=guardrail)
+
+ assert guardrail.call_count == 0
+ assert returned == {"error": "boom"}
+
+
+@pytest.mark.asyncio
+async def test_process_output_response_leaves_result_unmasked_on_text_count_mismatch():
+ """A guardrail returning the wrong number of texts must not shuffle content."""
+ handler = MCPGuardrailTranslationHandler()
+ guardrail = MaskingGuardrail(masked_texts=[""])
+ result = CallToolResult(
+ content=[
+ TextContent(type="text", text="jane@example.com"),
+ TextContent(type="text", text="415-555-0132"),
+ ],
+ isError=False,
+ )
+
+ returned = await handler.process_output_response(response=result, guardrail_to_apply=guardrail)
+
+ assert [item.text for item in returned.content] == ["jane@example.com", "415-555-0132"]
+
+
+class SubstitutingGuardrail(CustomGuardrail):
+ """Masks one substring wherever it appears, across however many texts it is given."""
+
+ def __init__(self, needle: str, replacement: str):
+ super().__init__(guardrail_name="substituting-mcp-guardrail")
+ self.needle = needle
+ self.replacement = replacement
+ self.seen_texts: list = []
+
+ async def apply_guardrail(self, inputs, request_data, input_type, **kwargs):
+ self.seen_texts = list(inputs.get("texts") or [])
+ return GenericGuardrailAPIInputs(
+ texts=[text.replace(self.needle, self.replacement) for text in self.seen_texts]
+ )
+
+
+@pytest.mark.asyncio
+async def test_structured_content_is_masked_alongside_content():
+ """structuredContent goes to the client too, so it must be masked, not just content."""
+ handler = MCPGuardrailTranslationHandler()
+ guardrail = SubstitutingGuardrail("jane@example.com", "")
+ response = CallToolResult(
+ content=[TextContent(type="text", text="email jane@example.com")],
+ structuredContent={"contact": {"email": "jane@example.com"}, "balance": 42.0},
+ isError=False,
+ )
+
+ returned = await handler.process_output_response(response=response, guardrail_to_apply=guardrail)
+
+ assert returned.content[0].text == "email "
+ assert returned.structuredContent == {"contact": {"email": ""}, "balance": 42.0}
+
+
+@pytest.mark.asyncio
+async def test_value_present_only_in_structured_content_is_masked():
+ """The gap this closes: a sensitive value that never appears in the text content.
+
+ Scanning only content would hand it to the guardrail never, so it would reach
+ the client unscanned behind a result that looks inspected.
+ """
+ handler = MCPGuardrailTranslationHandler()
+ guardrail = SubstitutingGuardrail("jane@example.com", "")
+ response = CallToolResult(
+ content=[TextContent(type="text", text="lookup complete")],
+ structuredContent={"records": [{"email": "jane@example.com"}]},
+ isError=False,
+ )
+
+ returned = await handler.process_output_response(response=response, guardrail_to_apply=guardrail)
+
+ assert "jane@example.com" in guardrail.seen_texts
+ assert returned.structuredContent == {"records": [{"email": ""}]}
+ assert returned.content[0].text == "lookup complete"
+
+
+@pytest.mark.asyncio
+async def test_structured_content_without_a_match_is_untouched():
+ """Unrelated structured data keeps its values and its types."""
+ handler = MCPGuardrailTranslationHandler()
+ guardrail = SubstitutingGuardrail("jane@example.com", "")
+ response = CallToolResult(
+ content=[TextContent(type="text", text="lookup complete")],
+ structuredContent={"record_id": "C-1001", "balance": 42.0, "active": True, "note": None},
+ isError=False,
+ )
+
+ returned = await handler.process_output_response(response=response, guardrail_to_apply=guardrail)
+
+ assert returned.structuredContent == {"record_id": "C-1001", "balance": 42.0, "active": True, "note": None}
+
+
+@pytest.mark.asyncio
+async def test_structured_content_nested_too_deeply_is_blocked():
+ """Too deep to walk must block rather than pass the deeper values unscanned."""
+ from fastapi import HTTPException
+
+ from litellm.proxy._experimental.mcp_server.utils import MAX_STRUCTURED_CONTENT_SCAN_DEPTH
+
+ handler = MCPGuardrailTranslationHandler()
+ guardrail = SubstitutingGuardrail("jane@example.com", "")
+ nested: dict = {"leaf": "jane@example.com"}
+ for _ in range(MAX_STRUCTURED_CONTENT_SCAN_DEPTH + 1):
+ nested = {"next": nested}
+ response = CallToolResult(
+ content=[TextContent(type="text", text="lookup complete")],
+ structuredContent=nested,
+ isError=False,
+ )
+
+ with pytest.raises(HTTPException) as exc_info:
+ await handler.process_output_response(response=response, guardrail_to_apply=guardrail)
+
+ assert exc_info.value.status_code == 400
+
+
+def test_too_deep_json_returns_a_sentinel_rather_than_raising():
+ """The too-deep signal must be a return value, not a custom exception.
+
+ mcp_server/utils.py is reloaded by tests that override its environment-backed
+ constants, which gives any exception class defined there a fresh identity and
+ lets it escape a caller's except clause; under xdist that surfaced as a failure
+ in an unrelated shard. A sentinel has no identity to lose. Asserted directly on
+ the helper so this pins the contract without reloading the module and leaking
+ that reload into other tests.
+ """
+ from litellm.proxy._experimental.mcp_server.utils import (
+ MAX_STRUCTURED_CONTENT_SCAN_DEPTH,
+ json_string_leaves,
+ )
+
+ nested: dict = {"leaf": "jane@example.com"}
+ for _ in range(MAX_STRUCTURED_CONTENT_SCAN_DEPTH + 1):
+ nested = {"next": nested}
+
+ assert json_string_leaves(nested) is None
+ assert json_string_leaves({"a": "b"}) == ((("a",), "b"),)
+
+
+@pytest.mark.asyncio
+async def test_sensitive_structured_content_key_is_blocked():
+ """A dict key is client-visible but not rewritable, so a match must block.
+
+ Maps keyed by an identifier are a common API shape, and renaming the key would
+ change the payload contract rather than redact a value; the content filter takes
+ the same position on MCP tool call arguments.
+ """
+ from fastapi import HTTPException
+
+ handler = MCPGuardrailTranslationHandler()
+ guardrail = SubstitutingGuardrail("jane@example.com", "")
+ response = CallToolResult(
+ content=[TextContent(type="text", text="lookup complete")],
+ structuredContent={"jane@example.com": {"balance": 42.0}},
+ isError=False,
+ )
+
+ with pytest.raises(HTTPException) as exc_info:
+ await handler.process_output_response(response=response, guardrail_to_apply=guardrail)
+
+ assert exc_info.value.status_code == 400
+ assert "non-rewritable" in str(exc_info.value.detail)
+
+
+@pytest.mark.asyncio
+async def test_sensitive_structured_content_numeric_value_is_blocked():
+ """A numeric value cannot be masked in place either, so a match must block."""
+ from fastapi import HTTPException
+
+ handler = MCPGuardrailTranslationHandler()
+ guardrail = SubstitutingGuardrail("4155550199", "")
+ response = CallToolResult(
+ content=[TextContent(type="text", text="lookup complete")],
+ structuredContent={"phone": 4155550199},
+ isError=False,
+ )
+
+ with pytest.raises(HTTPException) as exc_info:
+ await handler.process_output_response(response=response, guardrail_to_apply=guardrail)
+
+ assert exc_info.value.status_code == 400
+
+
+@pytest.mark.asyncio
+async def test_clean_structured_content_keys_do_not_block():
+ """Ordinary keys and numbers must pass through untouched."""
+ handler = MCPGuardrailTranslationHandler()
+ guardrail = SubstitutingGuardrail("jane@example.com", "")
+ response = CallToolResult(
+ content=[TextContent(type="text", text="email jane@example.com")],
+ structuredContent={"record_id": "C-1001", "balance": 42.0, "count": 3},
+ isError=False,
+ )
+
+ returned = await handler.process_output_response(response=response, guardrail_to_apply=guardrail)
+
+ assert returned.content[0].text == "email "
+ assert returned.structuredContent == {"record_id": "C-1001", "balance": 42.0, "count": 3}
diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py
index 1753b0d92a8..c0affdf46b3 100644
--- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py
+++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py
@@ -7130,6 +7130,14 @@ def _mock_mcp_logging_obj() -> MagicMock:
return logging_obj
+def _mock_mcp_proxy_logging() -> MagicMock:
+ """ProxyLogging stand-in whose post_mcp_call_hook passes the result through."""
+ proxy_logging_mock = MagicMock()
+ proxy_logging_mock.post_call_failure_hook = AsyncMock()
+ proxy_logging_mock.post_mcp_call_hook = AsyncMock(side_effect=lambda response, **_: response)
+ return proxy_logging_mock
+
+
def test_extract_mcp_tool_result_error_message():
from litellm.proxy._experimental.mcp_server.utils import (
extract_mcp_tool_result_error_message,
@@ -7160,8 +7168,7 @@ async def test_fire_mcp_tool_call_logging_iserror_logs_failure():
from litellm.proxy._experimental.mcp_server.exceptions import MCPToolResultError
logging_obj = _mock_mcp_logging_obj()
- proxy_logging_mock = MagicMock()
- proxy_logging_mock.post_call_failure_hook = AsyncMock()
+ proxy_logging_mock = _mock_mcp_proxy_logging()
user_auth = UserAPIKeyAuth(api_key="test-key", user_id="test-user")
with patch("litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging_mock):
@@ -7199,8 +7206,7 @@ async def test_fire_mcp_tool_call_logging_success_path_unchanged():
)
logging_obj = _mock_mcp_logging_obj()
- proxy_logging_mock = MagicMock()
- proxy_logging_mock.post_call_failure_hook = AsyncMock()
+ proxy_logging_mock = _mock_mcp_proxy_logging()
result = _call_tool_result(False, "all good")
with patch("litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging_mock):
@@ -7229,8 +7235,7 @@ async def test_fire_mcp_tool_call_logging_iserror_without_auth_skips_failure_hoo
)
logging_obj = _mock_mcp_logging_obj()
- proxy_logging_mock = MagicMock()
- proxy_logging_mock.post_call_failure_hook = AsyncMock()
+ proxy_logging_mock = _mock_mcp_proxy_logging()
with patch("litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging_mock):
await _fire_mcp_tool_call_logging(
@@ -7256,8 +7261,7 @@ async def test_fire_mcp_tool_call_logging_strips_credentials_from_failure_hook()
)
logging_obj = _mock_mcp_logging_obj()
- proxy_logging_mock = MagicMock()
- proxy_logging_mock.post_call_failure_hook = AsyncMock()
+ proxy_logging_mock = _mock_mcp_proxy_logging()
user_auth = UserAPIKeyAuth(api_key="test-key", user_id="test-user")
request_data = {
"name": "explode",
@@ -7528,8 +7532,7 @@ async def test_call_mcp_tool_skips_failure_hook_for_upstream_auth_error():
transport=MCPTransport.http,
mcp_info={"server_name": "test_server"},
)
- proxy_logging_mock = MagicMock()
- proxy_logging_mock.post_call_failure_hook = AsyncMock()
+ proxy_logging_mock = _mock_mcp_proxy_logging()
user_auth = UserAPIKeyAuth(api_key="test-key", user_id="test-user")
with (
@@ -7841,3 +7844,83 @@ class TestPreemptive401ModeAware:
await self._run(delegate, None, has_stored_token=False)
assert exc.value.status_code == 401
await self._run(delegate, self.LITELLM_KEY_HEADERS, has_stored_token=False)
+
+
+@pytest.mark.asyncio
+async def test_post_mcp_call_guardrails_return_the_rewritten_result():
+ """The result a post_mcp_call guardrail rewrote must be what the caller sends back."""
+ from litellm.proxy._experimental.mcp_server.server import (
+ _run_post_mcp_call_guardrails,
+ )
+
+ logging_obj = _mock_mcp_logging_obj()
+ raw_result = _call_tool_result(False, "jane@example.com")
+ masked_result = _call_tool_result(False, "")
+ proxy_logging_mock = _mock_mcp_proxy_logging()
+ proxy_logging_mock.post_mcp_call_hook = AsyncMock(return_value=masked_result)
+
+ with patch("litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging_mock):
+ returned = await _run_post_mcp_call_guardrails(
+ result=raw_result,
+ litellm_logging_obj=logging_obj,
+ user_api_key_auth=UserAPIKeyAuth(api_key="test-key", user_id="test-user"),
+ request_data={},
+ )
+
+ assert returned is masked_result
+ hook_kwargs = proxy_logging_mock.post_mcp_call_hook.await_args.kwargs
+ assert hook_kwargs["response"] is raw_result
+ assert hook_kwargs["request_data"] is logging_obj.model_call_details
+
+
+@pytest.mark.asyncio
+async def test_post_mcp_call_guardrails_run_without_a_logging_object():
+ """Enforcement must not depend on logging being configured.
+
+ A tool call dispatched without a litellm_logging_obj (tool search, and any
+ caller that omits it) would otherwise skip the guardrail entirely and return
+ the unscanned tool output to the client.
+ """
+ from litellm.proxy._experimental.mcp_server.server import (
+ _run_post_mcp_call_guardrails,
+ )
+
+ raw_result = _call_tool_result(False, "jane@example.com")
+ masked_result = _call_tool_result(False, "")
+ proxy_logging_mock = _mock_mcp_proxy_logging()
+ proxy_logging_mock.post_mcp_call_hook = AsyncMock(return_value=masked_result)
+
+ with patch("litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging_mock):
+ returned = await _run_post_mcp_call_guardrails(
+ result=raw_result,
+ litellm_logging_obj=None,
+ user_api_key_auth=UserAPIKeyAuth(api_key="test-key", user_id="test-user"),
+ request_data={"name": "fetch_record"},
+ )
+
+ assert returned is masked_result
+ proxy_logging_mock.post_mcp_call_hook.assert_awaited_once()
+ assert proxy_logging_mock.post_mcp_call_hook.await_args.kwargs["request_data"] == {"name": "fetch_record"}
+
+
+@pytest.mark.asyncio
+async def test_post_mcp_call_guardrails_propagate_a_block():
+ """A post_mcp_call guardrail rejection must propagate instead of returning the result."""
+ from litellm.exceptions import BlockedPiiEntityError
+ from litellm.proxy._experimental.mcp_server.server import (
+ _run_post_mcp_call_guardrails,
+ )
+
+ proxy_logging_mock = _mock_mcp_proxy_logging()
+ proxy_logging_mock.post_mcp_call_hook = AsyncMock(
+ side_effect=BlockedPiiEntityError(entity_type="EMAIL_ADDRESS", guardrail_name="presidio-mcp")
+ )
+
+ with patch("litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging_mock):
+ with pytest.raises(BlockedPiiEntityError):
+ await _run_post_mcp_call_guardrails(
+ result=_call_tool_result(False, "jane@example.com"),
+ litellm_logging_obj=_mock_mcp_logging_obj(),
+ user_api_key_auth=None,
+ request_data={},
+ )
diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py
index 5c9612a055e..cec62f79e33 100644
--- a/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py
+++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py
@@ -1710,6 +1710,89 @@ class TestCallToolRestAPI:
assert captured["allowed_mcp_servers"] == [stub_server]
fire_logging.assert_awaited_once()
+ async def test_returns_guardrail_rewritten_tool_result(self, monkeypatch):
+ """A post_mcp_call guardrail rewrite of the tool result must reach the REST caller,
+ not the raw result the upstream server returned."""
+
+ async def fake_contexts(user_api_key_auth):
+ return [user_api_key_auth]
+
+ async def fake_get_allowed_mcp_servers(*args, **kwargs):
+ return ["server-1"]
+
+ class StubServer:
+ server_id = "server-1"
+ alias = "server-1"
+ server_name = "server-1"
+ name = "stub"
+ allowed_tools = None
+ mcp_info = {"server_name": "stub"}
+ available_on_public_internet = True
+ auth_type = None
+
+ stub_server = StubServer()
+
+ async def fake_add_litellm_data_to_request(**kwargs):
+ return kwargs.get("data", {})
+
+ async def fake_execute_mcp_tool(**kwargs):
+ return {"content": [{"type": "text", "text": "jane@example.com"}]}
+
+ monkeypatch.setattr(rest_endpoints, "build_effective_auth_contexts", fake_contexts, raising=False)
+ monkeypatch.setattr(
+ rest_endpoints.global_mcp_server_manager,
+ "get_allowed_mcp_servers",
+ fake_get_allowed_mcp_servers,
+ raising=False,
+ )
+ monkeypatch.setattr(
+ rest_endpoints.global_mcp_server_manager,
+ "get_mcp_server_by_id",
+ lambda server_id: stub_server if server_id == "server-1" else None,
+ raising=False,
+ )
+ monkeypatch.setattr(
+ "litellm.proxy.proxy_server.add_litellm_data_to_request",
+ fake_add_litellm_data_to_request,
+ raising=False,
+ )
+ monkeypatch.setattr("litellm.proxy.proxy_server.proxy_config", {}, raising=False)
+ monkeypatch.setattr(rest_endpoints, "execute_mcp_tool", fake_execute_mcp_tool, raising=False)
+ masked_result = {"content": [{"type": "text", "text": ""}]}
+ monkeypatch.setattr(
+ rest_endpoints,
+ "_fire_mcp_tool_call_logging",
+ AsyncMock(return_value=masked_result),
+ raising=False,
+ )
+
+ request = _build_request(
+ path="/mcp-rest/tools/call",
+ method="POST",
+ json_body={"server_id": "server-1", "name": "demo-tool", "arguments": {}},
+ )
+
+ result = await rest_endpoints.call_tool_rest_api(request, user_api_key_dict=UserAPIKeyAuth())
+
+ assert result == masked_result
+
+ async def test_success_logging_guardrail_rejection_propagates(self, monkeypatch):
+ """A guardrail rejecting the tool result must not be swallowed as a logging failure,
+ otherwise the unguarded result would still be returned to the caller."""
+ from litellm.exceptions import BlockedPiiEntityError
+
+ fire_logging = AsyncMock(
+ side_effect=BlockedPiiEntityError(entity_type="EMAIL_ADDRESS", guardrail_name="presidio-mcp")
+ )
+ monkeypatch.setattr(rest_endpoints, "_fire_mcp_tool_call_logging", fire_logging, raising=False)
+
+ with pytest.raises(BlockedPiiEntityError):
+ await rest_endpoints._safe_fire_mcp_tool_call_logging(
+ object(), {"result": "ok"}, datetime.now(), datetime.now()
+ )
+
+ fire_logging.assert_awaited_once()
+
@pytest.mark.parametrize("upstream_status", [401, 403])
async def test_call_tool_rest_relays_upstream_auth_failure(self, monkeypatch, upstream_status):
"""A pass-through call that hits an upstream 401/403 (surfaced by the manager as
diff --git a/tests/test_litellm/proxy/test_proxy_utils.py b/tests/test_litellm/proxy/test_proxy_utils.py
index 4673807a135..3421751d962 100644
--- a/tests/test_litellm/proxy/test_proxy_utils.py
+++ b/tests/test_litellm/proxy/test_proxy_utils.py
@@ -7,8 +7,10 @@ import pytest
from fastapi import HTTPException
from litellm.caching.caching import DualCache
+from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.proxy._types import ProxyErrorTypes
from litellm.proxy.utils import ProxyLogging
+from litellm.types.guardrails import GuardrailEventHooks
sys.path.insert(
0, os.path.abspath("../../..")
@@ -947,3 +949,139 @@ class TestSendEmailStartTls:
assert isinstance(context, ssl.SSLContext)
assert context.verify_mode == ssl.CERT_REQUIRED
assert context.check_hostname is True
+
+
+class _RecordingMCPGuardrail(CustomGuardrail):
+ """Unified guardrail that masks every text it is handed."""
+
+ def __init__(self, event_hook, masked_text="", raises=None):
+ super().__init__(guardrail_name="mcp-output-guardrail", event_hook=event_hook, default_on=True)
+ self.masked_text = masked_text
+ self.raises = raises
+ self.call_count = 0
+ self.last_input_type = None
+
+ async def apply_guardrail(self, inputs, request_data, input_type, **kwargs):
+ self.call_count += 1
+ self.last_input_type = input_type
+ if self.raises is not None:
+ raise self.raises
+ return {"texts": [self.masked_text for _ in inputs.get("texts", [])]}
+
+
+class _NativeMCPGuardrail(CustomGuardrail):
+ """Guardrail that only implements the MCP logging hook (cisco-style)."""
+
+ def __init__(self):
+ super().__init__(
+ guardrail_name="native-mcp-guardrail",
+ event_hook=GuardrailEventHooks.post_mcp_call,
+ default_on=True,
+ )
+ self.considered_count = 0
+
+ def should_run_guardrail(self, data, event_type):
+ self.considered_count += 1
+ return super().should_run_guardrail(data=data, event_type=event_type)
+
+ async def async_post_mcp_tool_call_hook(self, kwargs, response_obj, start_time, end_time):
+ return None
+
+
+@pytest.fixture
+def restore_callbacks():
+ """Restore the process-wide callback state post_mcp_call_hook reads.
+
+ ProxyLogging caches callback capabilities keyed on id()s of litellm.callbacks,
+ so a restored-but-different list can collide with a stale entry after GC and
+ leak a has_guardrail verdict into unrelated tests in the same worker.
+ """
+ original = list(litellm.callbacks)
+ yield
+ litellm.callbacks = original
+ ProxyLogging._callback_capabilities_cache.clear()
+
+
+@pytest.mark.asyncio
+async def test_post_mcp_call_hook_masks_tool_result(restore_callbacks):
+ """A post_mcp_call guardrail must see the tool result text and mask it in the returned result."""
+ from mcp.types import CallToolResult, TextContent
+
+ guardrail = _RecordingMCPGuardrail(event_hook=GuardrailEventHooks.post_mcp_call)
+ litellm.callbacks = [guardrail]
+ proxy_logging_obj = ProxyLogging(user_api_key_cache=DualCache())
+ result = CallToolResult(content=[TextContent(type="text", text="jane@example.com")], isError=False)
+
+ returned = await proxy_logging_obj.post_mcp_call_hook(
+ response=result,
+ request_data={"mcp_tool_name": "echo"},
+ user_api_key_dict=None,
+ )
+
+ assert guardrail.call_count == 1
+ assert guardrail.last_input_type == "response"
+ assert [item.text for item in returned.content] == [""]
+
+
+@pytest.mark.asyncio
+async def test_post_mcp_call_hook_skips_guardrail_configured_for_other_hooks(restore_callbacks):
+ """A guardrail not configured for post_mcp_call must not scan MCP tool results."""
+ from mcp.types import CallToolResult, TextContent
+
+ guardrail = _RecordingMCPGuardrail(event_hook=GuardrailEventHooks.post_call)
+ litellm.callbacks = [guardrail]
+ proxy_logging_obj = ProxyLogging(user_api_key_cache=DualCache())
+ result = CallToolResult(content=[TextContent(type="text", text="jane@example.com")], isError=False)
+
+ returned = await proxy_logging_obj.post_mcp_call_hook(
+ response=result,
+ request_data={"mcp_tool_name": "echo"},
+ user_api_key_dict=None,
+ )
+
+ assert guardrail.call_count == 0
+ assert [item.text for item in returned.content] == ["jane@example.com"]
+
+
+@pytest.mark.asyncio
+async def test_post_mcp_call_hook_skips_guardrail_without_apply_guardrail(restore_callbacks):
+ """Guardrails that implement async_post_mcp_tool_call_hook are dispatched by the
+ logging object, so this hook must not run them a second time."""
+ from mcp.types import CallToolResult, TextContent
+
+ guardrail = _NativeMCPGuardrail()
+ litellm.callbacks = [guardrail]
+ proxy_logging_obj = ProxyLogging(user_api_key_cache=DualCache())
+ result = CallToolResult(content=[TextContent(type="text", text="jane@example.com")], isError=False)
+
+ returned = await proxy_logging_obj.post_mcp_call_hook(
+ response=result,
+ request_data={"mcp_tool_name": "echo"},
+ user_api_key_dict=None,
+ )
+
+ assert guardrail.considered_count == 0
+ assert [item.text for item in returned.content] == ["jane@example.com"]
+
+
+@pytest.mark.asyncio
+async def test_post_mcp_call_hook_propagates_guardrail_block(restore_callbacks):
+ """A guardrail rejecting the tool result must raise out of the hook."""
+ from mcp.types import CallToolResult, TextContent
+
+ from litellm.exceptions import BlockedPiiEntityError
+
+ guardrail = _RecordingMCPGuardrail(
+ event_hook=GuardrailEventHooks.post_mcp_call,
+ raises=BlockedPiiEntityError(entity_type="EMAIL_ADDRESS", guardrail_name="mcp-output-guardrail"),
+ )
+ litellm.callbacks = [guardrail]
+ proxy_logging_obj = ProxyLogging(user_api_key_cache=DualCache())
+ result = CallToolResult(content=[TextContent(type="text", text="jane@example.com")], isError=False)
+
+ with pytest.raises(BlockedPiiEntityError):
+ await proxy_logging_obj.post_mcp_call_hook(
+ response=result,
+ request_data={"mcp_tool_name": "echo"},
+ user_api_key_dict=None,
+ )
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/add_guardrail_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/add_guardrail_form.tsx
index 17331014c57..251ce631beb 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/add_guardrail_form.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/add_guardrail_form.tsx
@@ -41,6 +41,7 @@ const modeDescriptions = {
logging_only: "Logging Only - Only runs on logging callbacks without affecting the LLM call",
pre_mcp_call: "Before MCP Tool Call - Runs before MCP tool execution and validates tool calls",
during_mcp_call: "During MCP Tool Call - Runs in parallel with MCP tool execution for monitoring",
+ post_mcp_call: "After MCP Tool Call - Runs after MCP tool execution and checks the tool result",
};
interface GuardrailPreset {
From a00757ce806e267f68bc4659afa5475c2b732c61 Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Thu, 30 Jul 2026 14:30:33 -0700
Subject: [PATCH 27/33] docs(pr-template): require e2e proof on all three LLM
endpoints when applicable
---
.github/pull_request_template.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 1301bfb0e60..85291b49880 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -40,6 +40,7 @@ If you're seeing a delay in your PR being merged, ping the LiteLLM Team on [Slac
The proof must be completely e2e with no mocks, using, for example, actual LLM calls costing real $. `pytest` commands are not enough
For bug fixes: show reproduction before the fix and passing behavior after
Include the commit hash each proof was captured at, for both the before and the after runs
+ If the change applies to all three LLM endpoints (/v1/responses, /v1/chat/completions, /v1/messages), include proof for every single one of them, not just one
For new features: show the feature working end-to-end
For UI changes: include before/after screenshots -->
From 9ec900f964a6041740e2875e3fb2d42152d6a66a Mon Sep 17 00:00:00 2001
From: Yassin Kortam
Date: Thu, 30 Jul 2026 14:36:28 -0700
Subject: [PATCH 28/33] fix(redis): stop an unreachable Redis from blocking
every request (#35273)
Two defects combined to make a Redis outage take the proxy down rather than
degrade it.
First, connection kwargs were dropped whenever Redis was configured by url.
_get_redis_url_kwargs built its allowlist from
inspect.getfullargspec(redis.Redis.from_url); from_url is declared
(cls, url, **kwargs), so the argspec carried no connection kwargs and the
function returned ['cls', 'url', 'url']. socket_timeout went with the rest,
and socket_connect_timeout falls back to it, so both ended up None and a
Redis host that drops packets rather than refusing them blocked callers
indefinitely. get_redis_connection_pool's url branch lost the same kwargs by
a different route, rebuilding its pool kwargs from scratch.
The allowlist now comes from the connection class redis-py actually forwards
those kwargs to, walking the MRO because redis-py splits them between
AbstractConnection and its subclasses. Deriving it from the client instead
would admit client-only settings such as single_connection_client and the
SSLConnection-only ssl_* family, which reach AbstractConnection and raise
TypeError on first connect.
Second, the circuit breaker could not trip even once calls failed fast.
_redis_circuit_breaker_guard inferred success from the method returning, but
async_get_cache, async_batch_get_cache, async_set_cache, async_set_cache_pipeline,
async_set_cache_sadd and async_get_ttl catch their own connection errors and
return a default so callers degrade. Each failed call therefore reset the
failure streak and the breaker never opened, so an unreachable Redis stayed in
the pool and every request kept paying a full socket timeout on it. Those
methods now mark the failure and the guard records success only when nothing
failed while the method ran. Lua script execution went through none of this,
which mattered most because the rate limiter issues all of its Redis traffic
that way, so the guard is now a small helper shared by both.
The per-call marker is a ContextVar rather than a counter on the breaker.
Breakers are shared by every concurrent caller, so a shared counter cannot
tell "my call failed" from "some other in-flight call failed", and a success
overlapping someone else's failure would be discarded until a Redis that was
still answering got evicted from the pool anyway.
Only connectivity failures feed the breaker. Command and data errors say
nothing about whether Redis is reachable, and counting them would let a caller
provoke evictions on demand (an INCR against a non-numeric value, say),
dropping rate limiting to per-process counters that spreading traffic across
replicas can outrun.
---
litellm/_redis.py | 64 ++++--
litellm/caching/redis_cache.py | 117 +++++++++--
.../test_litellm/caching/test_redis_cache.py | 186 +++++++++++++++++-
tests/test_litellm/test_redis.py | 108 ++++++++++
4 files changed, 437 insertions(+), 38 deletions(-)
diff --git a/litellm/_redis.py b/litellm/_redis.py
index fe5c5cdabe9..9e3b247f577 100644
--- a/litellm/_redis.py
+++ b/litellm/_redis.py
@@ -61,23 +61,51 @@ def _get_redis_kwargs():
return available_args
-def _get_redis_url_kwargs(client=None):
+def _init_arg_names(cls: type) -> frozenset[str]:
+ """Every ``__init__`` parameter accepted anywhere in a class's MRO.
+
+ Keyword-only parameters are included, and the MRO is walked because redis-py splits a
+ connection's parameters between ``AbstractConnection`` and its concrete subclasses.
+ """
+ return frozenset(
+ name
+ for klass in inspect.getmro(cls)
+ if klass is not object
+ for spec in (inspect.getfullargspec(klass.__init__),)
+ for name in spec.args + spec.kwonlyargs
+ )
+
+
+def _get_redis_url_kwargs(client: Optional[type] = None) -> tuple[str, ...]:
+ """Connection kwargs that redis-py forwards from ``from_url`` down to the connection.
+
+ ``from_url`` is declared as ``(cls, url, **kwargs)``, so introspecting it yields no
+ connection kwargs at all. What it really does is hand its kwargs to the connection
+ class, so that class's signature is the allowlist.
+
+ Taking the client's signature instead would be wrong in both directions: it omits
+ nothing useful, but it admits client-only parameters such as
+ ``single_connection_client`` and ``auto_close_connection_pool``, plus the ``ssl_*``
+ family that only ``SSLConnection`` accepts. Those reach ``AbstractConnection`` and
+ raise ``TypeError`` the first time a connection is created. TLS on a url config is
+ selected by the ``rediss://`` scheme, which picks ``SSLConnection`` on its own.
+ """
if client is None:
- client = redis.Redis.from_url
- arg_spec = inspect.getfullargspec(redis.Redis.from_url)
+ client = redis.Redis
+ connection_cls = async_redis.Connection if client is async_redis.Redis else redis.Connection
+
+ exclude_args = frozenset(
+ {
+ "self",
+ "connection_pool",
+ "retry",
+ }
+ )
# Only allow primitive arguments
- exclude_args = {
- "self",
- "connection_pool",
- "retry",
- }
+ include_args = ("url", "max_connections")
- include_args = ["url"]
-
- available_args = [x for x in arg_spec.args if x not in exclude_args] + include_args
-
- return available_args
+ return tuple(x for x in _init_arg_names(connection_cls) if x not in exclude_args) + include_args
def _get_redis_cluster_kwargs(client=None):
@@ -614,7 +642,7 @@ def get_redis_async_client(
if "url" in redis_kwargs and redis_kwargs["url"] is not None:
if connection_pool is not None:
return async_redis.Redis(connection_pool=connection_pool)
- args = _get_redis_url_kwargs(client=async_redis.Redis.from_url)
+ args = _get_redis_url_kwargs(client=async_redis.Redis)
url_kwargs = {}
for arg in redis_kwargs:
if arg in args:
@@ -662,10 +690,10 @@ def get_redis_connection_pool(
return None
if "url" in redis_kwargs and redis_kwargs["url"] is not None:
- pool_kwargs = {
- "timeout": REDIS_CONNECTION_POOL_TIMEOUT,
- "url": redis_kwargs["url"],
- }
+ allowed_args = _get_redis_url_kwargs(client=async_redis.Redis)
+ pool_kwargs = {k: v for k, v in redis_kwargs.items() if k in allowed_args and k != "max_connections"}
+ pool_kwargs["timeout"] = REDIS_CONNECTION_POOL_TIMEOUT
+ pool_kwargs["url"] = redis_kwargs["url"]
if "max_connections" in redis_kwargs:
try:
pool_kwargs["max_connections"] = int(redis_kwargs["max_connections"])
diff --git a/litellm/caching/redis_cache.py b/litellm/caching/redis_cache.py
index dd1c152a421..9e0f022262b 100644
--- a/litellm/caching/redis_cache.py
+++ b/litellm/caching/redis_cache.py
@@ -17,7 +17,8 @@ import json
import time
from collections.abc import Awaitable, Callable, Sequence
from datetime import timedelta
-from typing import TYPE_CHECKING, Any, List, Optional, Tuple, Union, cast
+from contextvars import ContextVar
+from typing import TYPE_CHECKING, Any, List, Optional, Tuple, TypeVar, Union, cast
import litellm
from litellm._logging import print_verbose, verbose_logger
@@ -168,24 +169,97 @@ class RedisCircuitBreaker:
self._state = self.CLOSED
+_RedisCallResult = TypeVar("_RedisCallResult")
+
+
+_swallowed_redis_failures: ContextVar[int] = ContextVar("litellm_swallowed_redis_failures", default=0)
+
+
+@functools.lru_cache(maxsize=1)
+def _redis_health_error_types() -> tuple[type, ...]:
+ """Exception types that mean the Redis backend itself is unhealthy.
+
+ Command and data errors say nothing about connectivity: an INCR against a non-numeric
+ value or an undecodable cached entry is a request problem, and counting those would let
+ a caller trip the shared breaker on demand, dropping rate limiting to per-process
+ counters that spreading traffic across replicas can outrun.
+
+ Imported lazily because this module is reachable from a base ``import litellm`` while
+ redis is not a base dependency.
+ """
+ from redis.exceptions import BusyLoadingError, ClusterDownError
+ from redis.exceptions import ConnectionError as RedisConnectionError
+ from redis.exceptions import TimeoutError as RedisTimeoutError
+
+ return (RedisConnectionError, RedisTimeoutError, BusyLoadingError, ClusterDownError, OSError, asyncio.TimeoutError)
+
+
+def _is_redis_health_failure(exc: BaseException) -> bool:
+ """True when ``exc`` indicates Redis is unreachable rather than the request being bad."""
+ try:
+ return isinstance(exc, _redis_health_error_types())
+ except ImportError:
+ return True
+
+
+def _record_swallowed_redis_failure(breaker: RedisCircuitBreaker, exc: BaseException) -> None:
+ """Record a Redis failure that the calling method is about to swallow.
+
+ The marker is a ContextVar rather than a counter on the breaker because breakers are
+ shared by every concurrent caller. A plain shared counter cannot tell "my call failed"
+ from "some other in-flight call failed", so a success overlapping someone else's
+ failure would be discarded and a Redis that is answering would still be evicted.
+ asyncio gives each task its own copy of the context, so this is per-call.
+ """
+ if not _is_redis_health_failure(exc):
+ return
+ breaker.record_failure()
+ _swallowed_redis_failures.set(_swallowed_redis_failures.get() + 1)
+
+
+async def _run_under_circuit_breaker(
+ breaker: RedisCircuitBreaker,
+ name: str,
+ call: Callable[[], Awaitable[_RedisCallResult]],
+) -> _RedisCallResult:
+ """Run one Redis coroutine under a circuit breaker.
+
+ Shared by the method decorator and the Lua script executor so both feed the same
+ health signal. Success is recorded only when nothing failed while ``call`` ran,
+ because several Redis methods catch their own connection errors and return a default.
+ """
+ if breaker.is_open():
+ raise Exception(f"Redis circuit breaker is open — skipping {name}")
+ swallowed_before = _swallowed_redis_failures.get()
+ try:
+ result = await call()
+ except Exception as e:
+ if _is_redis_health_failure(e):
+ breaker.record_failure()
+ raise
+ if _swallowed_redis_failures.get() == swallowed_before:
+ breaker.record_success()
+ return result
+
+
def _redis_circuit_breaker_guard(method): # type: ignore
"""
Decorator for RedisCache async methods.
Checks the circuit breaker before each call; records success/failure after.
Does not apply to ping/disconnect/test_connection (health/teardown must always run).
+
+ A returning method is not proof of a healthy Redis: several methods catch their own
+ connection errors and return a default so callers degrade rather than fail. Counting
+ those as successes reset the failure streak on every request, so the breaker could
+ never open and Redis was never taken out of the pool. Success is therefore recorded
+ only when no failure was registered while the method ran.
"""
@functools.wraps(method)
async def wrapper(self, *args, **kwargs): # type: ignore
- if self._circuit_breaker.is_open():
- raise Exception(f"Redis circuit breaker is open — skipping {method.__name__}")
- try:
- result = await method(self, *args, **kwargs)
- self._circuit_breaker.record_success()
- return result
- except Exception:
- self._circuit_breaker.record_failure()
- raise
+ return await _run_under_circuit_breaker(
+ self._circuit_breaker, method.__name__, lambda: method(self, *args, **kwargs)
+ )
return wrapper
@@ -551,13 +625,16 @@ class RedisCache(BaseCache):
)
async def run_script(keys: Sequence[str], args: Sequence[Any], client: Any = None) -> Any:
- executor: Optional[Callable[..., Awaitable[Any]]] = litellm.in_memory_llm_clients_cache.get_cache(
- key=script_cache_key
- )
- if executor is None:
- executor = self._register_script_for_current_loop(script)
- litellm.in_memory_llm_clients_cache.set_cache(key=script_cache_key, value=executor)
- return await executor(keys=keys, args=args, client=client)
+ async def execute() -> object:
+ executor: Optional[Callable[..., Awaitable[Any]]] = litellm.in_memory_llm_clients_cache.get_cache(
+ key=script_cache_key
+ )
+ if executor is None:
+ executor = self._register_script_for_current_loop(script)
+ litellm.in_memory_llm_clients_cache.set_cache(key=script_cache_key, value=executor)
+ return await executor(keys=keys, args=args, client=client)
+
+ return await _run_under_circuit_breaker(self._circuit_breaker, "run_script", execute)
return run_script
@@ -674,6 +751,7 @@ class RedisCache(BaseCache):
str(e),
value,
)
+ _record_swallowed_redis_failure(self._circuit_breaker, e)
async def _pipeline_helper(
self,
@@ -758,6 +836,7 @@ class RedisCache(BaseCache):
str(e),
cache_value,
)
+ _record_swallowed_redis_failure(self._circuit_breaker, e)
async def _set_cache_sadd_helper(
self,
@@ -842,6 +921,7 @@ class RedisCache(BaseCache):
str(e),
value,
)
+ _record_swallowed_redis_failure(self._circuit_breaker, e)
@_redis_circuit_breaker_guard
async def batch_cache_write(self, key, value, **kwargs):
@@ -1106,6 +1186,7 @@ class RedisCache(BaseCache):
)
)
print_verbose(f"litellm.caching.caching: async get() - Got exception from REDIS: {str(e)}")
+ _record_swallowed_redis_failure(self._circuit_breaker, e)
@_redis_circuit_breaker_guard
async def async_batch_get_cache(
@@ -1177,6 +1258,7 @@ class RedisCache(BaseCache):
)
)
verbose_logger.error(f"Error occurred in async batch get cache - {str(e)}")
+ _record_swallowed_redis_failure(self._circuit_breaker, e)
return key_value_dict
def sync_ping(self) -> bool:
@@ -1432,6 +1514,7 @@ class RedisCache(BaseCache):
return ttl
except Exception as e:
verbose_logger.debug(f"Redis TTL Error: {e}")
+ _record_swallowed_redis_failure(self._circuit_breaker, e)
return None
@_redis_circuit_breaker_guard
diff --git a/tests/test_litellm/caching/test_redis_cache.py b/tests/test_litellm/caching/test_redis_cache.py
index a2e18a62638..59200719197 100644
--- a/tests/test_litellm/caching/test_redis_cache.py
+++ b/tests/test_litellm/caching/test_redis_cache.py
@@ -59,9 +59,24 @@ def test_delete_cache_applies_namespace(namespace, monkeypatch, redis_no_ping):
@pytest.mark.asyncio
-async def test_redis_client_init_with_socket_timeout(monkeypatch, redis_no_ping):
- monkeypatch.setenv("REDIS_HOST", "my-fake-host")
- redis_cache = RedisCache(socket_timeout=1.0)
+@pytest.mark.parametrize(
+ "redis_config",
+ [
+ pytest.param({"host": "my-fake-host"}, id="host_port"),
+ pytest.param({"url": "redis://my-fake-host:6379"}, id="url"),
+ ],
+)
+async def test_redis_client_init_with_socket_timeout(monkeypatch, redis_no_ping, redis_config):
+ """socket_timeout has to reach the connection however Redis was configured.
+
+ A url config used to drop every connection kwarg, so redis-py was left with
+ socket_timeout (and socket_connect_timeout, which falls back to it) unset. A
+ Redis host that drops packets instead of refusing them then blocks each caller
+ indefinitely, and the circuit breaker never trips because no call ever returns.
+ """
+ monkeypatch.delenv("REDIS_URL", raising=False)
+ monkeypatch.delenv("REDIS_HOST", raising=False)
+ redis_cache = RedisCache(socket_timeout=1.0, **redis_config)
assert redis_cache.redis_kwargs["socket_timeout"] == 1.0
client = redis_cache.init_async_client()
assert client is not None
@@ -428,3 +443,168 @@ def test_delete_cache_namespaces_key(namespace, expected, monkeypatch, redis_no_
redis_cache.redis_client = mock_client
redis_cache.delete_cache(key="k")
mock_client.delete.assert_called_once_with(expected)
+
+
+def _closed_port() -> int:
+ """A port with nothing listening, so Redis calls fail fast and deterministically."""
+ import socket
+
+ with socket.socket() as s:
+ s.bind(("127.0.0.1", 0))
+ return s.getsockname()[1]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "call_method",
+ [
+ pytest.param(lambda c: c.async_get_cache("lit4930"), id="async_get_cache"),
+ pytest.param(lambda c: c.async_batch_get_cache(["lit4930"]), id="async_batch_get_cache"),
+ pytest.param(lambda c: c.async_set_cache("lit4930", "v"), id="async_set_cache"),
+ pytest.param(lambda c: c.async_get_ttl("lit4930"), id="async_get_ttl"),
+ ],
+)
+async def test_circuit_breaker_opens_when_method_swallows_redis_failure(redis_no_ping, call_method):
+ """A guarded method that swallows its own Redis error must still count as a failure.
+
+ These methods catch connection errors and return a default so callers degrade instead
+ of failing, which is correct. But that returns cleanly through the circuit breaker
+ guard, and counting it as a success reset the failure streak on every call, so the
+ breaker could never open. An unreachable Redis then stayed in the pool and every
+ request kept paying the full socket timeout on it.
+ """
+ from litellm.constants import REDIS_CIRCUIT_BREAKER_FAILURE_THRESHOLD
+
+ cache = RedisCache(host="127.0.0.1", port=_closed_port(), socket_timeout=0.5)
+
+ for _ in range(REDIS_CIRCUIT_BREAKER_FAILURE_THRESHOLD):
+ await call_method(cache)
+
+ with pytest.raises(Exception, match="circuit breaker is open"):
+ await call_method(cache)
+
+
+@pytest.mark.asyncio
+async def test_circuit_breaker_success_still_resets_the_failure_streak(redis_no_ping):
+ """A reachable Redis must keep the breaker closed, however many earlier calls failed.
+
+ The guard now records success only when nothing failed while the method ran, so this
+ pins the other half of that contract: a call that genuinely reaches Redis has to clear
+ the streak, or a healthy Redis would eventually be evicted from the pool.
+ """
+ from litellm.constants import REDIS_CIRCUIT_BREAKER_FAILURE_THRESHOLD
+
+ cache = RedisCache(host="127.0.0.1", port=_closed_port(), socket_timeout=0.5)
+
+ for _ in range(REDIS_CIRCUIT_BREAKER_FAILURE_THRESHOLD - 1):
+ await cache.async_get_cache("lit4930")
+ assert cache._circuit_breaker.is_open() is False
+
+ reachable_redis = AsyncMock()
+ reachable_redis.get.return_value = None
+ with patch.object(cache, "init_async_client", return_value=reachable_redis):
+ await cache.async_get_cache("lit4930")
+
+ for _ in range(REDIS_CIRCUIT_BREAKER_FAILURE_THRESHOLD - 1):
+ await cache.async_get_cache("lit4930")
+
+ assert cache._circuit_breaker.is_open() is False, "one success must clear the streak"
+
+
+@pytest.mark.asyncio
+async def test_circuit_breaker_covers_lua_script_execution(redis_no_ping):
+ """Lua script execution must feed the breaker like every other Redis call.
+
+ The v3 rate limiter issues all of its Redis traffic through async_register_script, so
+ leaving that path unguarded meant the coordination calls during an outage never
+ counted toward taking Redis out of the pool and kept paying a full socket timeout
+ each, which is the traffic the outage hurts most.
+ """
+ from litellm.constants import REDIS_CIRCUIT_BREAKER_FAILURE_THRESHOLD
+
+ cache = RedisCache(host="127.0.0.1", port=_closed_port(), socket_timeout=0.5)
+ run_script = cache.async_register_script("return 1")
+
+ for _ in range(REDIS_CIRCUIT_BREAKER_FAILURE_THRESHOLD):
+ with pytest.raises(Exception):
+ await run_script(keys=["lit4930"], args=[1])
+
+ with pytest.raises(Exception, match="circuit breaker is open"):
+ await run_script(keys=["lit4930"], args=[1])
+
+
+@pytest.mark.asyncio
+async def test_concurrent_success_is_not_cancelled_by_another_calls_failure():
+ """One caller's failure must not discard a different caller's success.
+
+ A breaker is shared by every concurrent caller, so tracking "did this call fail" on the
+ breaker itself cannot tell my failure from someone else's. A Redis that is still
+ answering would then be evicted from the pool by unrelated in-flight failures, which is
+ the opposite of the outage this guard exists to handle.
+ """
+ from redis.exceptions import ConnectionError as RedisConnectionError
+
+ from litellm.caching.redis_cache import (
+ RedisCircuitBreaker,
+ _record_swallowed_redis_failure,
+ _run_under_circuit_breaker,
+ )
+
+ breaker = RedisCircuitBreaker(failure_threshold=3, recovery_timeout=60)
+
+ # The failure has to land after both calls are already in flight, which is the only
+ # ordering where a shared counter confuses the two. Failing before the healthy call
+ # starts would leave its snapshot correct and prove nothing.
+ async def swallows_a_failure():
+ await asyncio.sleep(0.02)
+ _record_swallowed_redis_failure(breaker, RedisConnectionError("redis unreachable"))
+ return None
+
+ async def succeeds_while_the_other_fails():
+ await asyncio.sleep(0.05)
+ return "ok"
+
+ rounds = breaker.failure_threshold + 1
+ for _ in range(rounds):
+ await asyncio.gather(
+ _run_under_circuit_breaker(breaker, "failing", swallows_a_failure),
+ _run_under_circuit_breaker(breaker, "healthy", succeeds_while_the_other_fails),
+ )
+
+ assert breaker._failure_count < breaker.failure_threshold, "the healthy call must clear the streak"
+ assert breaker.is_open() is False, "a Redis answering every round must stay in the pool"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "error, opens_breaker",
+ [
+ pytest.param("ConnectionError", True, id="connection_refused_is_unhealthy"),
+ pytest.param("TimeoutError", True, id="timeout_is_unhealthy"),
+ pytest.param("BusyLoadingError", True, id="loading_is_unhealthy"),
+ pytest.param("ResponseError", False, id="wrong_type_command_is_not"),
+ pytest.param("DataError", False, id="bad_data_is_not"),
+ ],
+)
+async def test_only_connectivity_failures_open_the_breaker(error, opens_breaker):
+ """Command and data errors must not count against Redis health.
+
+ They say nothing about connectivity, and a caller able to provoke them (an INCR against
+ a non-numeric value, say) could otherwise trip the shared breaker on demand and drop
+ rate limiting to per-process counters, which spreading traffic across replicas outruns.
+ """
+ import redis.exceptions
+
+ from litellm.caching.redis_cache import RedisCircuitBreaker, _run_under_circuit_breaker
+
+ breaker = RedisCircuitBreaker(failure_threshold=3, recovery_timeout=60)
+ raised = getattr(redis.exceptions, error)("boom")
+
+ async def failing_call():
+ raise raised
+
+ for _ in range(breaker.failure_threshold + 1):
+ with pytest.raises(Exception):
+ await _run_under_circuit_breaker(breaker, "op", failing_call)
+
+ assert breaker.is_open() is opens_breaker
diff --git a/tests/test_litellm/test_redis.py b/tests/test_litellm/test_redis.py
index 0818237655d..e0fa800723d 100644
--- a/tests/test_litellm/test_redis.py
+++ b/tests/test_litellm/test_redis.py
@@ -737,3 +737,111 @@ def test_connection_pool_env_redis_ssl_false_uses_plain_connection(monkeypatch):
assert pool is not None
assert pool.connection_class is async_redis.Connection
assert "ssl" not in pool.connection_kwargs
+
+
+@pytest.mark.parametrize(
+ "redis_config",
+ [
+ pytest.param({"host": "redis-host", "port": 6379}, id="host_port"),
+ pytest.param({"url": "redis://redis-host:6379"}, id="url"),
+ ],
+)
+def test_connection_pool_keeps_socket_timeout(redis_config, monkeypatch):
+ """The async pool must carry socket_timeout however Redis was configured.
+
+ The url branch used to rebuild pool kwargs from scratch as {timeout, url,
+ max_connections}, dropping socket_timeout. redis-py then leaves both
+ socket_timeout and socket_connect_timeout (which falls back to it) unset, so a
+ Redis host that drops packets rather than refusing them blocks every caller
+ indefinitely instead of failing fast.
+ """
+ monkeypatch.delenv("REDIS_URL", raising=False)
+ monkeypatch.delenv("REDIS_HOST", raising=False)
+ monkeypatch.delenv("REDIS_CLUSTER_NODES", raising=False)
+
+ pool = get_redis_connection_pool(socket_timeout=5.0, **redis_config)
+
+ assert pool is not None
+ assert pool.connection_kwargs.get("socket_timeout") == 5.0
+
+
+@pytest.mark.parametrize(
+ "redis_config",
+ [
+ pytest.param({"host": "redis-host", "port": 6379}, id="host_port"),
+ pytest.param({"url": "redis://redis-host:6379"}, id="url"),
+ ],
+)
+def test_sync_client_keeps_socket_timeout(redis_config, monkeypatch):
+ """The sync client is built during RedisCache.__init__ and blocks the caller.
+
+ Without socket_timeout it stalls for the OS TCP timeout against an unreachable
+ host, so merely constructing the cache stops the process.
+ """
+ monkeypatch.delenv("REDIS_URL", raising=False)
+ monkeypatch.delenv("REDIS_HOST", raising=False)
+ monkeypatch.delenv("REDIS_CLUSTER_NODES", raising=False)
+
+ client = get_redis_client(socket_timeout=5.0, **redis_config)
+
+ assert client.connection_pool.connection_kwargs.get("socket_timeout") == 5.0
+
+
+@pytest.mark.parametrize(
+ "redis_config",
+ [
+ pytest.param({"host": "redis-host", "port": 6379}, id="host_port"),
+ pytest.param({"url": "redis://redis-host:6379"}, id="url"),
+ ],
+)
+def test_async_client_keeps_socket_timeout(redis_config, monkeypatch):
+ """Same invariant for the async client built without an injected pool."""
+ monkeypatch.delenv("REDIS_URL", raising=False)
+ monkeypatch.delenv("REDIS_HOST", raising=False)
+ monkeypatch.delenv("REDIS_CLUSTER_NODES", raising=False)
+
+ client = get_redis_async_client(socket_timeout=5.0, **redis_config)
+
+ assert client.connection_pool.connection_kwargs.get("socket_timeout") == 5.0
+
+
+def test_url_config_does_not_forward_ssl_kwarg(monkeypatch):
+ """ssl stays consumed rather than forwarded on the url path.
+
+ TLS is selected by the rediss:// scheme there; handing ssl=True to a redis://
+ url yields a plain Connection that rejects the kwarg when it first connects.
+ """
+ monkeypatch.delenv("REDIS_URL", raising=False)
+ monkeypatch.delenv("REDIS_HOST", raising=False)
+ monkeypatch.delenv("REDIS_CLUSTER_NODES", raising=False)
+
+ client = get_redis_client(url="redis://redis-host:6379", ssl=True)
+
+ assert "ssl" not in client.connection_pool.connection_kwargs
+
+
+@pytest.mark.parametrize(
+ "client_only_kwarg",
+ [
+ pytest.param({"single_connection_client": True}, id="single_connection_client"),
+ pytest.param({"auto_close_connection_pool": True}, id="auto_close_connection_pool"),
+ pytest.param({"ssl_ca_certs": "/tmp/ca.pem"}, id="ssl_ca_certs"),
+ pytest.param({"ssl": True}, id="ssl"),
+ ],
+)
+def test_url_config_drops_kwargs_the_connection_cannot_accept(client_only_kwarg, monkeypatch):
+ """Only kwargs the connection accepts may be forwarded on the url path.
+
+ from_url hands its kwargs down to the connection class, so client-level settings and
+ the SSLConnection-only ssl_* family raise TypeError the first time a connection is
+ created. TLS on a url config comes from the rediss:// scheme instead.
+ """
+ monkeypatch.delenv("REDIS_URL", raising=False)
+ monkeypatch.delenv("REDIS_HOST", raising=False)
+ monkeypatch.delenv("REDIS_CLUSTER_NODES", raising=False)
+
+ pool = get_redis_connection_pool(url="redis://redis-host:6379", socket_timeout=5.0, **client_only_kwarg)
+
+ assert pool is not None
+ pool.make_connection()
+ assert pool.connection_kwargs.get("socket_timeout") == 5.0
From 66ca72ce0802ba21f373297aa983d8202d733112 Mon Sep 17 00:00:00 2001
From: "devin-ai-integration[bot]"
<158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Thu, 30 Jul 2026 21:38:07 +0000
Subject: [PATCH 29/33] fix(rate-limits): keep the v3 limiter out of
provider-facing metadata on responses routes (#35207)
* fix(rate-limits): stop the v3 limiter from creating provider-facing metadata on responses routes
* Update tests/test_litellm/proxy/hooks/test_parallel_request_limiter_v3.py
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
---------
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: yucheng-berri
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
---
.../hooks/parallel_request_limiter_v3.py | 38 ++++-----
.../hooks/test_parallel_request_limiter_v3.py | 83 +++++++++++++++++++
2 files changed, 102 insertions(+), 19 deletions(-)
diff --git a/litellm/proxy/hooks/parallel_request_limiter_v3.py b/litellm/proxy/hooks/parallel_request_limiter_v3.py
index f72492881b3..719496785dd 100644
--- a/litellm/proxy/hooks/parallel_request_limiter_v3.py
+++ b/litellm/proxy/hooks/parallel_request_limiter_v3.py
@@ -28,6 +28,7 @@ from litellm import DualCache
from litellm._logging import verbose_proxy_logger
from litellm.constants import DYNAMIC_RATE_LIMIT_ERROR_THRESHOLD_PER_MINUTE
from litellm.integrations.custom_logger import CustomLogger
+from litellm.litellm_core_utils.core_helpers import get_or_create_metadata_bucket
from litellm.litellm_core_utils.prompt_templates.common_utils import (
get_str_from_messages,
)
@@ -2447,13 +2448,13 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
data["litellm_proxy_rate_limit_response"] = response
# Mirror into metadata so streaming success logging can find
# it via ``kwargs["litellm_params"]["metadata"]``.
- self._stash_value_in_metadata_channels(
+ self._stash_value_in_internal_metadata(
data=data,
key=RATE_LIMIT_RESPONSE_KEY,
value=response,
)
if parallel_slot_id is not None:
- self._stash_value_in_metadata_channels(
+ self._stash_value_in_internal_metadata(
data=data,
key=MAX_PARALLEL_SLOT_ACQUIRED_KEY,
value={
@@ -2533,7 +2534,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
requested_model=requested_model,
)
else:
- self._stash_value_in_metadata_channels(
+ self._stash_value_in_internal_metadata(
data=data,
key=RATE_LIMIT_DESCRIPTORS_KEY,
value=descriptors,
@@ -2566,7 +2567,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
data["litellm_proxy_rate_limit_response"] = tpm_response
# Keep the metadata stash in sync when this is the
# first snapshot written.
- self._stash_value_in_metadata_channels(
+ self._stash_value_in_internal_metadata(
data=data,
key=RATE_LIMIT_RESPONSE_KEY,
value=tpm_response,
@@ -2803,19 +2804,17 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
return merged
@staticmethod
- def _stash_value_in_metadata_channels(
+ def _stash_value_in_internal_metadata(
data: Dict[str, Any],
key: str,
value: Any,
) -> None:
- for channel in ("metadata", "litellm_metadata"):
- existing = data.get(channel)
- if isinstance(existing, dict):
- existing[key] = value
- elif channel == "metadata":
- # ``litellm_metadata`` is owned by the router; don't conjure
- # it here.
- data[channel] = {key: value}
+ # Writes only the proxy-internal bucket. Routes that own
+ # ``litellm_metadata`` (Responses, /v1/messages, batches, files) expose
+ # ``metadata`` as a provider request parameter, so creating or adding to
+ # it here would forward internal state upstream.
+ _, metadata_bucket = get_or_create_metadata_bucket(data)
+ metadata_bucket[key] = value
@classmethod
def _stash_reservation_in_data(
@@ -2831,11 +2830,11 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
"""
scopes_payload: Optional[List[List[str]]] = [[k, v] for k, v in reserved_scopes] if reserved_scopes else None
- cls._stash_value_in_metadata_channels(data=data, key=TPM_RESERVED_TOKENS_KEY, value=estimated_tokens)
+ cls._stash_value_in_internal_metadata(data=data, key=TPM_RESERVED_TOKENS_KEY, value=estimated_tokens)
if reserved_model:
- cls._stash_value_in_metadata_channels(data=data, key=TPM_RESERVED_MODEL_KEY, value=reserved_model)
+ cls._stash_value_in_internal_metadata(data=data, key=TPM_RESERVED_MODEL_KEY, value=reserved_model)
if scopes_payload is not None:
- cls._stash_value_in_metadata_channels(data=data, key=TPM_RESERVED_SCOPES_KEY, value=scopes_payload)
+ cls._stash_value_in_internal_metadata(data=data, key=TPM_RESERVED_SCOPES_KEY, value=scopes_payload)
@staticmethod
def _lookup_stashed_value(
@@ -2858,9 +2857,10 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
return candidate
litellm_params = kwargs.get("litellm_params")
if isinstance(litellm_params, dict):
- lp_metadata = litellm_params.get("metadata")
- if isinstance(lp_metadata, dict):
- candidate = lp_metadata.get(key)
+ for channel in ("litellm_metadata", "metadata"):
+ lp_metadata = litellm_params.get(channel)
+ if isinstance(lp_metadata, dict) and lp_metadata.get(key) is not None:
+ return lp_metadata[key]
if candidate is None and isinstance(standard_logging_metadata, dict):
candidate = standard_logging_metadata.get(key)
return candidate
diff --git a/tests/test_litellm/proxy/hooks/test_parallel_request_limiter_v3.py b/tests/test_litellm/proxy/hooks/test_parallel_request_limiter_v3.py
index 9337050b61c..a4c42ff601e 100644
--- a/tests/test_litellm/proxy/hooks/test_parallel_request_limiter_v3.py
+++ b/tests/test_litellm/proxy/hooks/test_parallel_request_limiter_v3.py
@@ -3165,6 +3165,89 @@ async def test_pre_call_hook_does_not_leak_internal_stash_to_request_body():
assert isinstance(metadata.get(RATE_LIMIT_DESCRIPTORS_KEY), list)
+@pytest.mark.asyncio
+@pytest.mark.parametrize("caller_metadata", [None, {"user_tag": "abc"}])
+async def test_pre_call_hook_does_not_touch_provider_metadata_on_litellm_metadata_routes(
+ caller_metadata,
+):
+ """Regression for #35197: routes that own ``litellm_metadata`` (Responses,
+ /v1/messages, batches, files) send ``metadata`` to the provider, so the
+ limiter must never create it or write stash keys into it."""
+ from litellm.proxy.hooks.parallel_request_limiter_v3 import (
+ _LITELLM_STASH_KEYS,
+ RATE_LIMIT_DESCRIPTORS_KEY,
+ RATE_LIMIT_RESPONSE_KEY,
+ TPM_RESERVED_TOKENS_KEY,
+ )
+
+ user_api_key_dict = UserAPIKeyAuth(
+ api_key=hash_token("sk-responses-metadata"),
+ tpm_limit=1000,
+ rpm_limit=5,
+ )
+ local_cache = DualCache()
+ handler = _PROXY_MaxParallelRequestsHandler(
+ internal_usage_cache=InternalUsageCache(local_cache),
+ )
+
+ async def mock_should_rate_limit(descriptors, **kwargs):
+ return {
+ "overall_code": "OK",
+ "statuses": [
+ {
+ "code": "OK",
+ "current_limit": 5,
+ "limit_remaining": 4,
+ "descriptor_key": d["key"],
+ "descriptor_value": d["value"],
+ "rate_limit_type": "requests",
+ }
+ for d in descriptors
+ ],
+ }
+
+ async def mock_reserve_tpm_tokens(descriptors, estimated_tokens, **kwargs):
+ return {"overall_code": "OK", "statuses": []}
+
+ handler.should_rate_limit = mock_should_rate_limit
+ handler.reserve_tpm_tokens = mock_reserve_tpm_tokens
+
+ data: Dict[str, Any] = {
+ "model": "responses-model",
+ "input": "hello",
+ "litellm_metadata": {},
+ }
+ if caller_metadata is not None:
+ data["metadata"] = dict(caller_metadata)
+
+ await handler.async_pre_call_hook(
+ user_api_key_dict=user_api_key_dict,
+ cache=local_cache,
+ data=data,
+ call_type="aresponses",
+ )
+
+ if caller_metadata is None:
+ assert "metadata" not in data, f"limiter created provider metadata: {data.get('metadata')!r}"
+ else:
+ assert data["metadata"] == caller_metadata
+
+ litellm_metadata = data["litellm_metadata"]
+ assert litellm_metadata.get(TPM_RESERVED_TOKENS_KEY)
+ assert isinstance(litellm_metadata.get(RATE_LIMIT_DESCRIPTORS_KEY), list)
+ assert litellm_metadata.get(RATE_LIMIT_RESPONSE_KEY)
+
+ leaked = [k for k in _LITELLM_STASH_KEYS if k in data]
+ assert not leaked, f"stash keys leaked to top level: {leaked}"
+
+ for key in _LITELLM_STASH_KEYS:
+ assert handler._lookup_stashed_value(
+ kwargs={"litellm_params": {"litellm_metadata": litellm_metadata}},
+ standard_logging_metadata=None,
+ key=key,
+ ) == litellm_metadata.get(key)
+
+
@pytest.mark.asyncio
async def test_pre_call_hook_rejects_caller_supplied_stash_values():
"""Caller cannot pre-populate stash keys in body metadata to drive a
From 15c7d850e5d6f46b93e36362de6f89cd4307168c Mon Sep 17 00:00:00 2001
From: milan
Date: Thu, 30 Jul 2026 22:11:01 +0000
Subject: [PATCH 30/33] fix(caching): stamp provider on embedding cache-hit
logs so spend logs record provider
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
litellm/caching/caching_handler.py | 1 +
.../caching/test_caching_handler.py | 38 +++++++++++++++++++
2 files changed, 39 insertions(+)
diff --git a/litellm/caching/caching_handler.py b/litellm/caching/caching_handler.py
index b17e055c7ea..d8a2d2d76b7 100644
--- a/litellm/caching/caching_handler.py
+++ b/litellm/caching/caching_handler.py
@@ -517,6 +517,7 @@ class LLMCachingHandler:
cached_result=final_embedding_cached_response,
is_async=True,
is_embedding=True,
+ custom_llm_provider=custom_llm_provider,
)
self._async_log_cache_hit_on_callbacks(
logging_obj=logging_obj,
diff --git a/tests/test_litellm/caching/test_caching_handler.py b/tests/test_litellm/caching/test_caching_handler.py
index 1136a0b7e7b..38019fc0fee 100644
--- a/tests/test_litellm/caching/test_caching_handler.py
+++ b/tests/test_litellm/caching/test_caching_handler.py
@@ -558,6 +558,44 @@ async def test_embedding_cache_falls_back_to_token_counter_for_legacy_entries():
assert response.usage.prompt_tokens > 0
+@pytest.mark.asyncio
+async def test_embedding_cache_hit_sets_custom_llm_provider_on_logging_obj():
+ """A full embedding cache hit must stamp the resolved provider onto the logging
+ obj so spend logs record the provider instead of None/unknown."""
+ from litellm.types.utils import CallTypes
+
+ llm_caching_handler = LLMCachingHandler(
+ original_function=MagicMock(),
+ request_kwargs={},
+ start_time=datetime.now(),
+ )
+
+ cached_result = [
+ {
+ "embedding": [-0.025, -0.019],
+ "index": 0,
+ "object": "embedding",
+ "model": "text-embedding-3-small",
+ "prompt_tokens": 5,
+ }
+ ]
+
+ logging_obj = _build_logging_obj(CallTypes.aembedding.value, stream=False)
+ logging_obj.async_success_handler = AsyncMock()
+
+ response, cache_hit = llm_caching_handler._process_async_embedding_cached_response(
+ final_embedding_cached_response=None,
+ cached_result=cached_result,
+ kwargs={"model": "text-embedding-3-small", "input": "hello world"},
+ logging_obj=logging_obj,
+ start_time=datetime.now(),
+ model="text-embedding-3-small",
+ )
+
+ assert cache_hit
+ assert logging_obj.model_call_details["custom_llm_provider"] == "openai"
+
+
def test_request_kwargs_does_not_retain_logging_obj():
"""
The caching handler lives on logging_obj._llm_caching_handler, so keeping
From 23f3e10012cf6aa975065769a797c553e92ab245 Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Thu, 30 Jul 2026 15:33:49 -0700
Subject: [PATCH 31/33] fix(proxy): recognize inherited apply_guardrail
overrides and keep masking guardrails on their own stream hook
---
litellm/proxy/utils.py | 3 +-
.../test_proxy_logging_hook_detection.py | 116 +++++++++++++++++-
2 files changed, 116 insertions(+), 3 deletions(-)
diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py
index 39045e155d6..62394e5fbcd 100644
--- a/litellm/proxy/utils.py
+++ b/litellm/proxy/utils.py
@@ -2699,7 +2699,8 @@ class ProxyLogging:
kind == "override"
and stream_needs_translation
and isinstance(resolved_callback, CustomGuardrail)
- and "apply_guardrail" in type(resolved_callback).__dict__
+ and resolved_callback.uses_apply_guardrail_interface()
+ and not resolved_callback.mask_response_content
)
else kind
)
diff --git a/tests/test_litellm/proxy/test_proxy_logging_hook_detection.py b/tests/test_litellm/proxy/test_proxy_logging_hook_detection.py
index 032dc5c4df7..015dcd9b5db 100644
--- a/tests/test_litellm/proxy/test_proxy_logging_hook_detection.py
+++ b/tests/test_litellm/proxy/test_proxy_logging_hook_detection.py
@@ -200,17 +200,19 @@ def _anthropic_stream_chunks(text_parts):
return chunks
-def _content_filter_guardrail(action: str):
+def _content_filter_guardrail(action: str, guardrail_cls=None, **guardrail_kwargs):
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import (
ContentFilterGuardrail,
)
from litellm.types.guardrails import BlockedWord, ContentFilterAction
- return ContentFilterGuardrail(
+ cls = guardrail_cls or ContentFilterGuardrail
+ return cls(
guardrail_name="output-filter",
blocked_words=[BlockedWord(keyword="zebra", action=ContentFilterAction(action))],
event_hook="post_call",
default_on=True,
+ **guardrail_kwargs,
)
@@ -247,6 +249,12 @@ def test_stream_requires_guardrail_translation_route_detection():
is False
)
assert ProxyLogging._stream_requires_guardrail_translation(UserAPIKeyAuth(api_key="sk-1234")) is False
+ assert (
+ ProxyLogging._stream_requires_guardrail_translation(
+ UserAPIKeyAuth(api_key="sk-1234", request_route="/route/without/call/types")
+ )
+ is False
+ )
@pytest.mark.asyncio
@@ -367,3 +375,107 @@ async def test_unified_guardrail_iterator_accepts_explicit_guardrail(monkeypatch
guardrail_to_apply=guardrail,
):
pass
+
+
+@pytest.mark.asyncio
+async def test_post_call_stream_guardrail_reroutes_inherited_apply_guardrail(monkeypatch):
+ """
+ The reroute predicate must recognize apply_guardrail implementations
+ inherited from a parent class, not only ones defined on the registered
+ leaf class. A vendor base class can carry apply_guardrail while the leaf
+ only overrides the streaming iterator; a leaf-class ``__dict__`` check
+ would leave that guardrail on the raw Anthropic SSE path unscanned.
+ """
+ from fastapi import HTTPException
+
+ from litellm.caching.caching import DualCache
+ from litellm.proxy._types import UserAPIKeyAuth
+ from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import (
+ ContentFilterGuardrail,
+ )
+
+ class _InheritsApplyGuardrail(ContentFilterGuardrail):
+ async def async_post_call_streaming_iterator_hook(self, user_api_key_dict, response, request_data):
+ async for item in response:
+ yield item
+
+ guardrail = _content_filter_guardrail("BLOCK", guardrail_cls=_InheritsApplyGuardrail)
+ assert "apply_guardrail" not in type(guardrail).__dict__
+ monkeypatch.setattr(litellm, "callbacks", [guardrail])
+
+ proxy_logging = ProxyLogging(user_api_key_cache=DualCache())
+ request_data = {
+ "model": "claude-sonnet-5",
+ "litellm_logging_obj": _streaming_logging_obj(),
+ "metadata": {},
+ }
+
+ async def fake_stream():
+ for chunk in _anthropic_stream_chunks(["the", " zebra runs"]):
+ yield chunk
+
+ delivered = []
+ with pytest.raises(HTTPException) as exc_info:
+ async for chunk in proxy_logging.async_post_call_streaming_iterator_hook(
+ response=fake_stream(),
+ user_api_key_dict=UserAPIKeyAuth(api_key="sk-1234", request_route="/v1/messages"),
+ request_data=request_data,
+ ):
+ delivered.append(chunk)
+
+ assert exc_info.value.detail["keyword"] == "zebra"
+ assert delivered == []
+
+
+@pytest.mark.asyncio
+async def test_post_call_stream_masking_guardrail_keeps_own_iterator_on_anthropic(monkeypatch):
+ """
+ A guardrail with mask_response_content=True must stay on its own iterator
+ hook on /v1/messages. The unified streaming path cannot re-emit rewritten
+ text on raw Anthropic SSE (block_only drops rewrites and buffered replay
+ releases the unredacted originals), so rerouting such a guardrail would
+ deliver content it decided to mask. PANW Prisma AIRS is the concrete
+ case: its own hook parses the raw bytes and blocks instead of masking.
+ """
+ from litellm.caching.caching import DualCache
+ from litellm.proxy._types import UserAPIKeyAuth
+ from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import (
+ ContentFilterGuardrail,
+ )
+
+ own_hook_streams = []
+
+ class _MasksViaOwnRawStreamHook(ContentFilterGuardrail):
+ apply_guardrail = ContentFilterGuardrail.apply_guardrail
+
+ async def async_post_call_streaming_iterator_hook(self, user_api_key_dict, response, request_data):
+ own_hook_streams.append(request_data.get("model"))
+ async for item in response:
+ yield item
+
+ guardrail = _content_filter_guardrail(
+ "BLOCK", guardrail_cls=_MasksViaOwnRawStreamHook, mask_response_content=True
+ )
+ monkeypatch.setattr(litellm, "callbacks", [guardrail])
+
+ proxy_logging = ProxyLogging(user_api_key_cache=DualCache())
+ chunks = _anthropic_stream_chunks(["the", " zebra runs"])
+
+ async def fake_stream():
+ for chunk in chunks:
+ yield chunk
+
+ delivered = []
+ async for chunk in proxy_logging.async_post_call_streaming_iterator_hook(
+ response=fake_stream(),
+ user_api_key_dict=UserAPIKeyAuth(api_key="sk-1234", request_route="/v1/messages"),
+ request_data={
+ "model": "claude-sonnet-5",
+ "litellm_logging_obj": _streaming_logging_obj(),
+ "metadata": {},
+ },
+ ):
+ delivered.append(chunk)
+
+ assert own_hook_streams == ["claude-sonnet-5"]
+ assert delivered == chunks
From 87c2e03af88ba73f480c0dc8c8c14a4515c9b37c Mon Sep 17 00:00:00 2001
From: Yassin Kortam
Date: Thu, 30 Jul 2026 15:45:40 -0700
Subject: [PATCH 32/33] feat(db): opt-in REPLICA IDENTITY FULL after prisma
migrations (#35267)
Logical replication consumers need FULL replica identity to reconstruct the
old row of an UPDATE or DELETE, and prisma leaves every table it creates at
the postgres default. Operators had to re-apply the setting by hand after
each migration run.
Setting LITELLM_SET_REPLICA_IDENTITY_FULL now re-asserts it on every LiteLLM
table at the end of a successful migration run, through the prisma CLI so the
dependency-free proxy-extras package stays that way. Tables that are already
FULL are skipped, foreign tables in the same schema are left alone, and a
database that refuses the ALTER is reported rather than failing the run.
Resolves LIT-3022
---
.../litellm_proxy_extras/replica_identity.py | 106 ++++++++++++
.../litellm_proxy_extras/utils.py | 46 +++++
litellm/proxy/db/prisma_client.py | 16 ++
.../test_replica_identity_full.py | 159 ++++++++++++++++++
.../proxy/db/test_prisma_client.py | 22 +++
.../proxy/db/test_replica_identity.py | 85 ++++++++++
6 files changed, 434 insertions(+)
create mode 100644 litellm-proxy-extras/litellm_proxy_extras/replica_identity.py
create mode 100644 tests/proxy_migration_tests/test_replica_identity_full.py
create mode 100644 tests/test_litellm/proxy/db/test_replica_identity.py
diff --git a/litellm-proxy-extras/litellm_proxy_extras/replica_identity.py b/litellm-proxy-extras/litellm_proxy_extras/replica_identity.py
new file mode 100644
index 00000000000..dc92e9dca6a
--- /dev/null
+++ b/litellm-proxy-extras/litellm_proxy_extras/replica_identity.py
@@ -0,0 +1,106 @@
+"""Optional post-migration step that raises Postgres REPLICA IDENTITY to FULL.
+
+Logical-replication consumers (Neon / lakehouse sync and similar) need FULL
+replica identity to reconstruct the old row of an UPDATE or DELETE. Prisma
+leaves every table it creates at the Postgres default, so the setting has to be
+re-applied by hand after each migration run. Setting
+``LITELLM_SET_REPLICA_IDENTITY_FULL`` makes every migration run re-assert it.
+
+The statement goes through the Prisma CLI rather than a Postgres driver because
+``litellm-proxy-extras`` has no runtime dependencies, while the CLI is already
+required for the migrations themselves.
+"""
+
+import subprocess
+import tempfile
+from pathlib import Path
+
+from litellm_proxy_extras._logging import logger
+
+REPLICA_IDENTITY_FULL_ENV_VAR = "LITELLM_SET_REPLICA_IDENTITY_FULL"
+
+REPLICA_IDENTITY_FULL_SQL = r"""
+DO $$
+DECLARE
+ target regclass;
+BEGIN
+ SET LOCAL lock_timeout = '5s';
+ FOR target IN
+ SELECT c.oid::regclass
+ FROM pg_class c
+ JOIN pg_namespace n ON n.oid = c.relnamespace
+ WHERE c.relkind = 'r'
+ AND c.relreplident <> 'f'
+ AND n.nspname = ANY (current_schemas(false))
+ AND c.relname LIKE 'LiteLLM\_%'
+ LOOP
+ BEGIN
+ EXECUTE format('ALTER TABLE %s REPLICA IDENTITY FULL', target);
+ EXCEPTION WHEN lock_not_available THEN
+ RAISE WARNING 'REPLICA IDENTITY FULL skipped for %: table busy, retrying next run', target;
+ END;
+ END LOOP;
+END
+$$;
+"""
+
+
+def apply_replica_identity_full(
+ schema_path: str,
+ prisma_command: str,
+ prisma_env: dict[str, str],
+) -> bool:
+ """Set REPLICA IDENTITY FULL on every LiteLLM table that is not already FULL.
+
+ Never raises. Replication metadata is not needed to serve requests, so
+ every failure mode is reported and stepped over rather than taking down a
+ migration run that already succeeded: a database that refuses the ALTER
+ (most often because the runtime user does not own the tables), a missing
+ or unrunnable Prisma CLI, a read-only temp directory, or a timeout.
+
+ Returns True when the statement was applied, False when it failed.
+ """
+ logger.info("Applying REPLICA IDENTITY FULL to LiteLLM tables")
+ try:
+ with tempfile.TemporaryDirectory(prefix="litellm_replica_identity_") as tmp_dir:
+ sql_path = Path(tmp_dir) / "replica_identity_full.sql"
+ sql_path.write_text(REPLICA_IDENTITY_FULL_SQL)
+ subprocess.run(
+ [
+ prisma_command,
+ "db",
+ "execute",
+ "--file",
+ str(sql_path),
+ "--schema",
+ schema_path,
+ ],
+ timeout=60,
+ check=True,
+ capture_output=True,
+ text=True,
+ env=prisma_env,
+ )
+ except subprocess.CalledProcessError as e:
+ logger.error(
+ "Failed to set REPLICA IDENTITY FULL. Logical replication "
+ "consumers may reject updates to these tables. Grant table "
+ "ownership to the migration user, or apply "
+ "`ALTER TABLE ... REPLICA IDENTITY FULL` by hand. Error: %s",
+ e.stderr,
+ )
+ return False
+ except subprocess.TimeoutExpired:
+ logger.error("Timed out setting REPLICA IDENTITY FULL on LiteLLM tables")
+ return False
+ except OSError as e:
+ logger.error(
+ "Could not run the REPLICA IDENTITY FULL statement. Logical "
+ "replication consumers may reject updates to these tables. "
+ "Error: %s",
+ e,
+ )
+ return False
+
+ logger.info("REPLICA IDENTITY FULL applied to LiteLLM tables")
+ return True
diff --git a/litellm-proxy-extras/litellm_proxy_extras/utils.py b/litellm-proxy-extras/litellm_proxy_extras/utils.py
index 369b6561931..af822573322 100644
--- a/litellm-proxy-extras/litellm_proxy_extras/utils.py
+++ b/litellm-proxy-extras/litellm_proxy_extras/utils.py
@@ -10,6 +10,10 @@ from pathlib import Path
from typing import Optional
from litellm_proxy_extras._logging import logger
+from litellm_proxy_extras.replica_identity import (
+ REPLICA_IDENTITY_FULL_ENV_VAR,
+ apply_replica_identity_full,
+)
def str_to_bool(value: Optional[str]) -> bool:
@@ -676,6 +680,39 @@ class ProxyExtrasDBManager:
finally:
os.chdir(original_dir)
+ @staticmethod
+ def apply_replica_identity_full_if_requested() -> bool:
+ """
+ Re-assert REPLICA IDENTITY FULL on LiteLLM's tables when the operator
+ opted in via LITELLM_SET_REPLICA_IDENTITY_FULL.
+
+ Prisma leaves new tables at the Postgres default, which logical
+ replication consumers reject, so the setting has to be re-applied after
+ every migration run rather than once by hand.
+
+ Returns:
+ bool: True if the setting was applied, False if it was not
+ requested or could not be applied.
+ """
+ if not str_to_bool(os.getenv(REPLICA_IDENTITY_FULL_ENV_VAR)):
+ return False
+ try:
+ schema_path = ProxyExtrasDBManager._get_prisma_dir() + "/schema.prisma"
+ prisma_command = _get_prisma_command()
+ prisma_env = _get_prisma_env()
+ except OSError as e:
+ logger.error(
+ "Could not resolve the migrations directory for the REPLICA "
+ "IDENTITY FULL step, skipping it. Error: %s",
+ e,
+ )
+ return False
+ return apply_replica_identity_full(
+ schema_path=schema_path,
+ prisma_command=prisma_command,
+ prisma_env=prisma_env,
+ )
+
@staticmethod
def setup_database(
use_migrate: bool = False, use_v2_resolver: bool = False
@@ -694,6 +731,15 @@ class ProxyExtrasDBManager:
Returns:
bool: True if setup was successful, False otherwise
"""
+ migrated = ProxyExtrasDBManager._run_migrations(
+ use_migrate=use_migrate, use_v2_resolver=use_v2_resolver
+ )
+ if migrated:
+ ProxyExtrasDBManager.apply_replica_identity_full_if_requested()
+ return migrated
+
+ @staticmethod
+ def _run_migrations(use_migrate: bool, use_v2_resolver: bool) -> bool:
if use_v2_resolver:
logger.info("Using v2 migration resolver (--use_v2_migration_resolver)")
return ProxyExtrasDBManager._setup_database_v2(use_migrate=use_migrate)
diff --git a/litellm/proxy/db/prisma_client.py b/litellm/proxy/db/prisma_client.py
index cc608d6e82c..1e0d8f5e010 100644
--- a/litellm/proxy/db/prisma_client.py
+++ b/litellm/proxy/db/prisma_client.py
@@ -834,6 +834,21 @@ class PrismaManager:
dname = os.path.dirname(os.path.dirname(abspath))
return dname
+ @staticmethod
+ def _apply_replica_identity_full_if_requested() -> None:
+ """
+ `prisma db push` bypasses litellm-proxy-extras, so the opt-in
+ REPLICA IDENTITY FULL step has to be driven from here too.
+
+ litellm-proxy-extras is an optional install, so this is a no-op when it
+ is absent.
+ """
+ try:
+ from litellm_proxy_extras.utils import ProxyExtrasDBManager
+ except ImportError:
+ return
+ ProxyExtrasDBManager.apply_replica_identity_full_if_requested()
+
@staticmethod
def setup_database(use_migrate: bool = False, use_v2_resolver: bool = False) -> bool:
"""
@@ -880,6 +895,7 @@ class PrismaManager:
timeout=60,
check=True,
)
+ PrismaManager._apply_replica_identity_full_if_requested()
return True
except subprocess.TimeoutExpired:
verbose_proxy_logger.warning(f"Attempt {attempt + 1} timed out")
diff --git a/tests/proxy_migration_tests/test_replica_identity_full.py b/tests/proxy_migration_tests/test_replica_identity_full.py
new file mode 100644
index 00000000000..6a88e6994e9
--- /dev/null
+++ b/tests/proxy_migration_tests/test_replica_identity_full.py
@@ -0,0 +1,159 @@
+"""Coverage for the opt-in REPLICA IDENTITY FULL post-migration step.
+
+The DB-backed tests run against the same Postgres the migration suite uses, in
+a throwaway schema so they cannot disturb the migrated tables.
+"""
+
+import os
+import uuid
+
+import pytest
+
+from litellm_proxy_extras.replica_identity import (
+ REPLICA_IDENTITY_FULL_ENV_VAR,
+ apply_replica_identity_full,
+)
+from litellm_proxy_extras.utils import ProxyExtrasDBManager
+
+psycopg = pytest.importorskip("psycopg")
+
+requires_db = pytest.mark.skipif(
+ "DATABASE_URL" not in os.environ,
+ reason="requires a postgres database (DATABASE_URL)",
+)
+
+
+def _base_url() -> str:
+ return os.environ["DATABASE_URL"].split("?")[0]
+
+
+def _replica_identities(schema: str) -> dict:
+ with psycopg.connect(_base_url(), autocommit=True) as conn:
+ rows = conn.execute(
+ "SELECT c.relname, c.relreplident FROM pg_class c "
+ "JOIN pg_namespace n ON n.oid = c.relnamespace "
+ "WHERE n.nspname = %s AND c.relkind = 'r'",
+ (schema,),
+ ).fetchall()
+ return dict(rows)
+
+
+@pytest.fixture
+def scratch_schema(monkeypatch):
+ """A schema holding two LiteLLM tables and one foreign table, all at the default."""
+ schema = f"replica_identity_{uuid.uuid4().hex[:8]}"
+ with psycopg.connect(_base_url(), autocommit=True) as conn:
+ conn.execute(f'CREATE SCHEMA "{schema}"')
+ conn.execute(
+ f'CREATE TABLE "{schema}"."LiteLLM_ScratchTable" (id TEXT PRIMARY KEY, note TEXT)'
+ )
+ conn.execute(f'CREATE TABLE "{schema}"."LiteLLM_ScratchSibling" (id TEXT PRIMARY KEY)')
+ conn.execute(f'CREATE TABLE "{schema}"."ScratchForeignTable" (id TEXT PRIMARY KEY)')
+
+ monkeypatch.setenv("DATABASE_URL", f"{_base_url()}?schema={schema}")
+ yield schema
+
+ with psycopg.connect(_base_url(), autocommit=True) as conn:
+ conn.execute(f'DROP SCHEMA "{schema}" CASCADE')
+
+
+@requires_db
+def test_applies_full_to_litellm_tables_only(scratch_schema, monkeypatch):
+ monkeypatch.setenv(REPLICA_IDENTITY_FULL_ENV_VAR, "true")
+
+ assert ProxyExtrasDBManager.apply_replica_identity_full_if_requested() is True
+
+ identities = _replica_identities(scratch_schema)
+ assert identities["LiteLLM_ScratchTable"] == "f"
+ assert identities["LiteLLM_ScratchSibling"] == "f"
+ assert identities["ScratchForeignTable"] == "d"
+
+
+@requires_db
+def test_a_locked_table_does_not_block_the_others(scratch_schema, monkeypatch):
+ """ALTER TABLE needs an exclusive lock, so a table busy with a long read has
+ to be skipped for the next run instead of stalling every other table behind it."""
+ monkeypatch.setenv(REPLICA_IDENTITY_FULL_ENV_VAR, "true")
+
+ with psycopg.connect(_base_url()) as holder:
+ holder.execute(f'SELECT * FROM "{scratch_schema}"."LiteLLM_ScratchTable"')
+ assert ProxyExtrasDBManager.apply_replica_identity_full_if_requested() is True
+
+ identities = _replica_identities(scratch_schema)
+ assert identities["LiteLLM_ScratchTable"] == "d"
+ assert identities["LiteLLM_ScratchSibling"] == "f"
+
+
+@requires_db
+def test_leaves_tables_alone_when_not_requested(scratch_schema, monkeypatch):
+ monkeypatch.delenv(REPLICA_IDENTITY_FULL_ENV_VAR, raising=False)
+
+ assert ProxyExtrasDBManager.apply_replica_identity_full_if_requested() is False
+ assert _replica_identities(scratch_schema)["LiteLLM_ScratchTable"] == "d"
+
+
+@requires_db
+def test_is_idempotent_across_runs(scratch_schema, monkeypatch):
+ monkeypatch.setenv(REPLICA_IDENTITY_FULL_ENV_VAR, "true")
+
+ assert ProxyExtrasDBManager.apply_replica_identity_full_if_requested() is True
+ assert ProxyExtrasDBManager.apply_replica_identity_full_if_requested() is True
+
+ assert _replica_identities(scratch_schema)["LiteLLM_ScratchTable"] == "f"
+
+
+@requires_db
+def test_reports_failure_without_raising(scratch_schema, monkeypatch):
+ """A run that cannot execute the statement must not take the migration down."""
+ monkeypatch.setenv(REPLICA_IDENTITY_FULL_ENV_VAR, "true")
+ monkeypatch.setattr(
+ ProxyExtrasDBManager,
+ "_get_prisma_dir",
+ staticmethod(lambda: "/nonexistent/prisma/dir"),
+ )
+
+ assert ProxyExtrasDBManager.apply_replica_identity_full_if_requested() is False
+ assert _replica_identities(scratch_schema)["LiteLLM_ScratchTable"] == "d"
+
+
+def test_reports_an_unrunnable_prisma_cli_without_raising(tmp_path):
+ """A deployment without the Prisma CLI on PATH must still finish its
+ migration run instead of dying on the optional replication step."""
+ assert (
+ apply_replica_identity_full(
+ schema_path=str(tmp_path / "schema.prisma"),
+ prisma_command=str(tmp_path / "no-such-prisma"),
+ prisma_env={},
+ )
+ is False
+ )
+
+
+def test_setup_database_applies_after_a_successful_migration_run(monkeypatch):
+ applied = []
+ monkeypatch.setattr(
+ ProxyExtrasDBManager, "_run_migrations", staticmethod(lambda **kwargs: True)
+ )
+ monkeypatch.setattr(
+ ProxyExtrasDBManager,
+ "apply_replica_identity_full_if_requested",
+ staticmethod(lambda: applied.append(True)),
+ )
+
+ assert ProxyExtrasDBManager.setup_database(use_migrate=True) is True
+ assert applied == [True]
+
+
+def test_setup_database_skips_replica_identity_when_migrations_fail(monkeypatch):
+ applied = []
+ monkeypatch.setattr(
+ ProxyExtrasDBManager, "_run_migrations", staticmethod(lambda **kwargs: False)
+ )
+ monkeypatch.setattr(
+ ProxyExtrasDBManager,
+ "apply_replica_identity_full_if_requested",
+ staticmethod(lambda: applied.append(True)),
+ )
+
+ assert ProxyExtrasDBManager.setup_database(use_migrate=True) is False
+ assert applied == []
diff --git a/tests/test_litellm/proxy/db/test_prisma_client.py b/tests/test_litellm/proxy/db/test_prisma_client.py
index eeaf726941f..08b873dfc44 100644
--- a/tests/test_litellm/proxy/db/test_prisma_client.py
+++ b/tests/test_litellm/proxy/db/test_prisma_client.py
@@ -193,3 +193,25 @@ async def test_recreate_prisma_client_recovers_from_disconnected_client(
mock_kill.assert_not_called()
assert wrapper._original_prisma is mock_new_prisma
mock_new_prisma.connect.assert_awaited_once()
+
+
+def test_db_push_applies_replica_identity_full_when_requested(monkeypatch):
+ """`prisma db push` bypasses litellm-proxy-extras, so it needs its own call
+ into the opt-in REPLICA IDENTITY FULL step."""
+ from litellm.proxy.db.prisma_client import PrismaManager
+ from litellm_proxy_extras.replica_identity import REPLICA_IDENTITY_FULL_ENV_VAR
+ from litellm_proxy_extras.utils import ProxyExtrasDBManager
+
+ monkeypatch.setenv(REPLICA_IDENTITY_FULL_ENV_VAR, "true")
+ applied = []
+ monkeypatch.setattr(
+ ProxyExtrasDBManager,
+ "apply_replica_identity_full_if_requested",
+ staticmethod(lambda: applied.append(True)),
+ )
+
+ with patch("litellm.proxy.db.prisma_client.subprocess.run") as mock_run:
+ assert PrismaManager.setup_database(use_migrate=False) is True
+
+ assert mock_run.call_args[0][0][:3] == ["prisma", "db", "push"]
+ assert applied == [True]
diff --git a/tests/test_litellm/proxy/db/test_replica_identity.py b/tests/test_litellm/proxy/db/test_replica_identity.py
new file mode 100644
index 00000000000..ecfc6433ab1
--- /dev/null
+++ b/tests/test_litellm/proxy/db/test_replica_identity.py
@@ -0,0 +1,85 @@
+"""The opt-in REPLICA IDENTITY FULL step, without a database.
+
+The behavior against real Postgres is covered by
+tests/proxy_migration_tests/test_replica_identity_full.py; these pin the two
+things that hold with no database at all: the statement handed to the Prisma
+CLI, and the promise that no failure of this optional step escapes into a
+migration run that already succeeded.
+"""
+
+import subprocess
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+from litellm_proxy_extras.replica_identity import (
+ REPLICA_IDENTITY_FULL_ENV_VAR,
+ apply_replica_identity_full,
+)
+from litellm_proxy_extras.utils import ProxyExtrasDBManager
+
+
+def test_hands_the_alter_statement_to_the_prisma_cli():
+ captured = {}
+
+ def capture(cmd, **kwargs):
+ captured["cmd"] = cmd
+ captured["sql"] = Path(cmd[cmd.index("--file") + 1]).read_text()
+ return subprocess.CompletedProcess(cmd, 0)
+
+ with patch(
+ "litellm_proxy_extras.replica_identity.subprocess.run", side_effect=capture
+ ):
+ applied = apply_replica_identity_full(
+ schema_path="/somewhere/schema.prisma",
+ prisma_command="prisma",
+ prisma_env={"DATABASE_URL": "postgresql://x/y"},
+ )
+
+ assert applied is True
+ assert captured["cmd"][:3] == ["prisma", "db", "execute"]
+ assert captured["cmd"][-2:] == ["--schema", "/somewhere/schema.prisma"]
+
+ sql = captured["sql"]
+ assert "ALTER TABLE %s REPLICA IDENTITY FULL" in sql
+ assert r"c.relname LIKE 'LiteLLM\_%'" in sql
+ assert "c.relreplident <> 'f'" in sql
+ assert "lock_timeout" in sql
+
+
+@pytest.mark.parametrize(
+ "failure",
+ [
+ subprocess.CalledProcessError(1, "prisma", stderr="must be owner of table"),
+ subprocess.TimeoutExpired("prisma", 60),
+ OSError(2, "No such file or directory"),
+ PermissionError(13, "Read-only file system"),
+ ],
+ ids=["rejected", "timed-out", "cli-missing", "read-only-fs"],
+)
+def test_every_failure_is_reported_instead_of_raised(failure):
+ with patch(
+ "litellm_proxy_extras.replica_identity.subprocess.run", side_effect=failure
+ ):
+ assert (
+ apply_replica_identity_full(
+ schema_path="/somewhere/schema.prisma",
+ prisma_command="prisma",
+ prisma_env={},
+ )
+ is False
+ )
+
+
+def test_an_unusable_migrations_dir_skips_the_step_instead_of_killing_the_run(
+ tmp_path, monkeypatch
+):
+ """LITELLM_MIGRATION_DIR makes the step copy the migrations tree before it
+ can run, and that copy is filesystem work that can fail on its own."""
+ blocker = tmp_path / "blocker"
+ blocker.write_text("not a directory")
+ monkeypatch.setenv(REPLICA_IDENTITY_FULL_ENV_VAR, "true")
+ monkeypatch.setenv("LITELLM_MIGRATION_DIR", str(blocker / "migrations"))
+
+ assert ProxyExtrasDBManager.apply_replica_identity_full_if_requested() is False
From 6e26087cf407995b3b54f7ca4c845f6988b83626 Mon Sep 17 00:00:00 2001
From: ryan-crabbe-berri
Date: Thu, 30 Jul 2026 16:06:45 -0700
Subject: [PATCH 33/33] fix(proxy): only enforce budgets on routes that can
spend (#35274)
* fix(proxy): only enforce budgets on routes that can spend
Budget checks ran inside common_checks with no route filter, so an
over-budget user, team, organization or tag got a 429 on every
authenticated route, including the management calls the Admin UI makes
on load. An internal user who exhausted their budget could not open the
dashboard to see why, and a max_budget of 0 locked them out from the
moment the account existed.
Gate the scope budget checks on RouteChecks.is_llm_api_route, matching
the virtual key budget check, the reservation path and the global proxy
budget check, which already scope themselves this way. /health/services
keeps enforcing because it fires Slack, email and webhook sends.
The Admin UI is affected because a UI login mints a virtual key scoped
to the litellm-dashboard pseudo-team. That token was shielded from
personal budgets by the team-key exemption until #32005 removed it.
* fix(proxy): keep budget enforcement on provider-calling health routes
/health and /health/test_connection are not LLM API routes but both run
litellm.ahealth_check against real deployments, so exempting them let an
exhausted budget keep incurring provider spend.
Add them alongside /health/services in BUDGET_ENFORCED_SIDE_EFFECT_ROUTES
and cover all three with a regression test.
* chore(ui): drop env-dependent schema.d.ts regeneration from this PR
The regenerated diff was union-member reordering only, with no change to
the represented types, and the ordering differs between a local run and
CI. Keeping the committed file as-is lets the drift check pass and keeps
this PR to the auth change.
* chore(ui): restore schema.d.ts to the branch base
The previous commit restored it from the staging tip, which pulled in
unrelated merged changes. This PR changes no backend models, so the file
should be untouched.
---
litellm/proxy/auth/auth_checks.py | 20 +++-
.../proxy/auth/test_auth_checks.py | 108 ++++++++++++++++++
2 files changed, 123 insertions(+), 5 deletions(-)
diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py
index 0472b496b78..263fec77d12 100644
--- a/litellm/proxy/auth/auth_checks.py
+++ b/litellm/proxy/auth/auth_checks.py
@@ -486,6 +486,14 @@ MODEL_DISCOVERY_ROUTES = frozenset(
}
)
+BUDGET_ENFORCED_SIDE_EFFECT_ROUTES = frozenset(
+ {
+ "/health",
+ "/health/services",
+ "/health/test_connection",
+ }
+)
+
async def common_checks(
request_body: dict,
@@ -532,8 +540,10 @@ async def common_checks(
request=request,
)
- if route in MODEL_DISCOVERY_ROUTES:
- skip_budget_checks = True
+ skip_all_budget_checks = skip_budget_checks or (
+ route not in BUDGET_ENFORCED_SIDE_EFFECT_ROUTES
+ and (route in MODEL_DISCOVERY_ROUTES or not RouteChecks.is_llm_api_route(route=route))
+ )
# 1. If team is blocked
if team_object is not None and team_object.blocked is True:
@@ -607,7 +617,7 @@ async def common_checks(
project_object=project_object,
_model=_model,
llm_router=llm_router,
- skip_budget_checks=skip_budget_checks,
+ skip_budget_checks=skip_all_budget_checks,
valid_token=valid_token,
proxy_logging_obj=proxy_logging_obj,
)
@@ -616,7 +626,7 @@ async def common_checks(
_reject_clientside_metadata_tags_check(general_settings, request_body, route)
# If this is a free model, skip all budget checks
- if not skip_budget_checks:
+ if not skip_all_budget_checks:
# Key metadata.tags are injected into request_body here so the tag budget
# check can read them; this mutation must run before the gathered checks.
if valid_token is not None:
@@ -713,7 +723,7 @@ async def common_checks(
raise budget_error
_enforce_user_param_check(general_settings, request, request_body, route)
- _global_proxy_budget_check(global_proxy_spend, skip_budget_checks, route)
+ _global_proxy_budget_check(global_proxy_spend, skip_all_budget_checks, route)
_guardrail_modification_check(request_body, team_object)
# 10 [OPTIONAL] Organization RBAC checks
diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py
index 4d0ef58b7f8..5f3b0f36b95 100644
--- a/tests/test_litellm/proxy/auth/test_auth_checks.py
+++ b/tests/test_litellm/proxy/auth/test_auth_checks.py
@@ -4876,6 +4876,114 @@ async def test_common_checks_personal_user_budget_skipped_for_team_key():
assert result is True
+@pytest.mark.parametrize(
+ "scope, route, expect_blocked",
+ [
+ ("user", "/chat/completions", True),
+ ("user", "/key/list", False),
+ ("team", "/chat/completions", True),
+ ("team", "/key/list", False),
+ ("org", "/chat/completions", True),
+ ("org", "/key/list", False),
+ ],
+)
+@pytest.mark.asyncio
+async def test_budget_checks_only_run_on_llm_api_routes(scope, route, expect_blocked):
+ """Budgets cap spend, so they must only gate routes that can spend.
+
+ Enforcing them on management routes locked an over-budget caller out of the
+ Admin UI, which authenticates with a normal virtual key, leaving no way to
+ reach the page that raises the limit.
+ """
+ from fastapi import Request
+
+ from litellm.proxy.auth.auth_checks import common_checks
+
+ over_budget_counter = {"user": "spend:user:u1", "team": "spend:team:t1", "org": "spend:org:o1"}[scope]
+
+ async def _spend_by_counter(counter_key, fallback_spend, max_budget=None, **kwargs):
+ return 999.0 if counter_key == over_budget_counter else 0.0
+
+ async def _no_membership(*a, **kw):
+ return None
+
+ org_table = MagicMock()
+ org_table.spend = 999.0
+ org_table.litellm_budget_table = MagicMock()
+ org_table.litellm_budget_table.max_budget = 10.0
+
+ async def _get_org(*a, **kw):
+ return org_table
+
+ user = LiteLLM_UserTable(user_id="u1", spend=0.0, max_budget=10.0 if scope == "user" else None)
+ team = LiteLLM_TeamTable(team_id="t1", max_budget=10.0) if scope == "team" else None
+ token = UserAPIKeyAuth(
+ token="k1",
+ user_id="u1",
+ team_id="t1" if scope == "team" else None,
+ org_id="o1" if scope == "org" else None,
+ )
+ proxy_logging_obj = MagicMock()
+ proxy_logging_obj.budget_alerts = AsyncMock()
+
+ async def _run():
+ return await common_checks(
+ request_body={"messages": [{"role": "user", "content": "hi"}]},
+ team_object=team,
+ user_object=user,
+ end_user_object=None,
+ global_proxy_spend=None,
+ general_settings={},
+ route=route,
+ llm_router=None,
+ proxy_logging_obj=proxy_logging_obj,
+ valid_token=token,
+ request=MagicMock(spec=Request),
+ )
+
+ with patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), patch(
+ "litellm.proxy.proxy_server.get_current_spend", _spend_by_counter
+ ), patch("litellm.proxy.auth.auth_checks.get_team_membership", _no_membership), patch(
+ "litellm.proxy.auth.auth_checks.get_org_object", _get_org
+ ):
+ if expect_blocked:
+ with pytest.raises(litellm.BudgetExceededError):
+ await _run()
+ else:
+ assert await _run() is True
+
+
+@pytest.mark.parametrize("route", ["/health", "/health/services", "/health/test_connection"])
+@pytest.mark.asyncio
+async def test_spend_capable_non_llm_routes_still_enforce_budget(route):
+ """These routes are not LLM API routes but still reach a provider or an
+ external service: /health and /health/test_connection run litellm.ahealth_check
+ against real deployments, and /health/services fires Slack/email/webhook sends.
+ Exempting them with the other management routes would let an exhausted budget
+ keep spending.
+ """
+ from fastapi import Request
+
+ from litellm.proxy.auth.auth_checks import common_checks
+
+ team = LiteLLM_TeamTable(team_id="t1", spend=150.0, max_budget=100.0)
+
+ with pytest.raises(litellm.BudgetExceededError):
+ await common_checks(
+ request_body={},
+ team_object=team,
+ user_object=None,
+ end_user_object=None,
+ global_proxy_spend=None,
+ general_settings={},
+ route=route,
+ llm_router=None,
+ proxy_logging_obj=AsyncMock(),
+ valid_token=UserAPIKeyAuth(token="k1", team_id="t1"),
+ request=MagicMock(spec=Request),
+ )
+
+
@pytest.mark.asyncio
async def test_get_default_end_user_budget_db_fetch_returns_validated_budget(monkeypatch):
from litellm.proxy.auth.auth_checks import get_default_end_user_budget