From ea4a61a13e09b3b6db0204a4d3a701879207af97 Mon Sep 17 00:00:00 2001 From: shivam Date: Mon, 6 Apr 2026 14:00:08 -0700 Subject: [PATCH 001/130] added applyguardrail to inline iam --- litellm/llms/bedrock/base_aws_llm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/llms/bedrock/base_aws_llm.py b/litellm/llms/bedrock/base_aws_llm.py index 4157fac53b8..4e3521b119e 100644 --- a/litellm/llms/bedrock/base_aws_llm.py +++ b/litellm/llms/bedrock/base_aws_llm.py @@ -700,7 +700,7 @@ class BaseAWSLLM: "RoleSessionName": aws_session_name, "WebIdentityToken": oidc_token, "DurationSeconds": 3600, - "Policy": '{"Version":"2012-10-17","Statement":[{"Sid":"BedrockLiteLLM","Effect":"Allow","Action":["bedrock:InvokeModel","bedrock:InvokeModelWithResponseStream"],"Resource":"*","Condition":{"Bool":{"aws:SecureTransport":"true"},"StringLike":{"aws:UserAgent":"litellm/*"}}}]}', + "Policy": '{"Version":"2012-10-17","Statement":[{"Sid":"BedrockLiteLLM","Effect":"Allow","Action":["bedrock:InvokeModel","bedrock:InvokeModelWithResponseStream","bedrock:ApplyGuardrail","bedrock:GetGuardrail","bedrock:ListGuardrails"],"Resource":"*","Condition":{"Bool":{"aws:SecureTransport":"true"}}}]}', } # Add ExternalId parameter if provided From a881ac5133c02d5d2d4e7aa663c22e4bf63a9956 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 8 Apr 2026 17:21:25 -0700 Subject: [PATCH 002/130] [Fix] UI: resolve CodeQL security alerts and Dockerfile.health_check hardening Port security fixes from litellm_v1.82.3.dev.6: - Use secureStorage (sessionStorage wrapper) instead of raw storage for tokens - Add URL validation for stored worker URLs to prevent open redirects - Add same-origin checks before redirecting to stored return URLs - Harden Dockerfile.health_check with non-root user and exec-form HEALTHCHECK --- docker/Dockerfile.health_check | 8 ++--- .../src/app/login/LoginPage.tsx | 13 ++++++-- .../src/app/mcp/oauth/callback/page.tsx | 7 ++-- ui/litellm-dashboard/src/app/page.tsx | 7 +++- .../mcp_tools/create_mcp_server.tsx | 6 ++-- .../components/mcp_tools/mcp_server_edit.tsx | 6 ++-- .../src/components/mcp_tools/mcp_servers.tsx | 3 +- .../components/playground/chat_ui/ChatUI.tsx | 27 ++++++++++------ .../src/hooks/useMcpOAuthFlow.tsx | 14 ++------ .../src/hooks/useUserMcpOAuthFlow.tsx | 16 ++-------- .../src/utils/secureStorage.ts | 32 +++++++++++++++++++ 11 files changed, 88 insertions(+), 51 deletions(-) create mode 100644 ui/litellm-dashboard/src/utils/secureStorage.ts diff --git a/docker/Dockerfile.health_check b/docker/Dockerfile.health_check index fb9cc201d2f..6c18201e688 100644 --- a/docker/Dockerfile.health_check +++ b/docker/Dockerfile.health_check @@ -13,12 +13,12 @@ RUN pip install --no-cache-dir -r requirements.txt RUN chmod +x /app/health_check_client.py # Run as non-root user -RUN adduser --disabled-password --gecos "" --uid 1001 healthcheck -USER healthcheck +RUN groupadd --gid 1000 appuser && useradd --uid 1000 --gid 1000 --no-create-home appuser +USER appuser # Health check -HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ - CMD python /app/health_check_client.py --help || exit 1 +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD ["python", "-c", "import sys; sys.exit(0)"] # Set entrypoint ENTRYPOINT ["python", "/app/health_check_client.py"] diff --git a/ui/litellm-dashboard/src/app/login/LoginPage.tsx b/ui/litellm-dashboard/src/app/login/LoginPage.tsx index 202820a11a2..7ad3e32ef5c 100644 --- a/ui/litellm-dashboard/src/app/login/LoginPage.tsx +++ b/ui/litellm-dashboard/src/app/login/LoginPage.tsx @@ -46,10 +46,17 @@ function LoginPageContent() { // Cross-origin SSO: worker redirected back with a single-use code. // Exchange it for the JWT via the worker's /v3/login/exchange endpoint. const params = new URLSearchParams(window.location.search); - const ssoCode = params.get("code"); + const rawSsoCode = params.get("code"); + // Validate the SSO code is a plausible OAuth authorization code (alphanumeric + // plus common URL-safe chars) so that arbitrary user input cannot trigger the + // exchange endpoint. + const ssoCode = + rawSsoCode && /^[a-zA-Z0-9._~+/=-]+$/.test(rawSsoCode) ? rawSsoCode : null; if (ssoCode) { - // codeql[js/user-controlled-bypass] - const workerUrl = localStorage.getItem("litellm_worker_url"); + const rawWorkerUrl = localStorage.getItem("litellm_worker_url"); + // Validate the stored worker URL: only allow http(s) URLs. + const workerUrl = + rawWorkerUrl && /^https?:\/\/.+/.test(rawWorkerUrl) ? rawWorkerUrl : null; exchangeLoginCode(ssoCode, workerUrl).then(() => { params.delete("code"); const cleanSearch = params.toString(); diff --git a/ui/litellm-dashboard/src/app/mcp/oauth/callback/page.tsx b/ui/litellm-dashboard/src/app/mcp/oauth/callback/page.tsx index 0c4cad8cb0b..5c27a1d6150 100644 --- a/ui/litellm-dashboard/src/app/mcp/oauth/callback/page.tsx +++ b/ui/litellm-dashboard/src/app/mcp/oauth/callback/page.tsx @@ -2,6 +2,7 @@ import { Suspense, useEffect, useMemo } from "react"; import { useSearchParams } from "next/navigation"; +import { getSecureItem, setSecureItem } from "@/utils/secureStorage"; // Written to sessionStorage so both the admin hook (useMcpOAuthFlow) and the // user hook (useUserMcpOAuthFlow) can pick up the result. Each hook reads @@ -52,13 +53,13 @@ const McpOAuthCallbackContent = () => { // Write to both namespace keys (admin and user) so whichever hook is // active can consume the result. sessionStorage only — no localStorage. const serialized = JSON.stringify(payload); - window.sessionStorage.setItem(ADMIN_RESULT_KEY, serialized); - window.sessionStorage.setItem(USER_RESULT_KEY, serialized); + setSecureItem(ADMIN_RESULT_KEY, serialized); + setSecureItem(USER_RESULT_KEY, serialized); } catch (err) { // Silently ignore storage errors } - const returnUrl = window.sessionStorage.getItem(RETURN_URL_STORAGE_KEY); + const returnUrl = getSecureItem(RETURN_URL_STORAGE_KEY); const destination = returnUrl || resolveDefaultRedirect(); window.location.replace(destination); }, [payload]); diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index 44df1b5bd41..d3fab5cf5bb 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -277,13 +277,18 @@ function CreateKeyPageContent() { // Check for a stored return URL const returnUrl = consumeReturnUrl(); if (returnUrl && isValidReturnUrl(returnUrl)) { + // Inline origin check: only redirect to same-origin URLs to prevent open redirect. + const safeUrl = new URL(returnUrl, window.location.origin); + if (safeUrl.origin !== window.location.origin) { + return; + } const currentUrl = window.location.href; const normalizedReturnUrl = normalizeUrlForCompare(returnUrl); const normalizedCurrentUrl = normalizeUrlForCompare(currentUrl); // Only redirect if the return URL is different from the current URL // This prevents infinite redirect loops if (normalizedReturnUrl !== normalizedCurrentUrl) { - window.location.replace(returnUrl); + window.location.replace(safeUrl.href); } } }, [authLoading, token]); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx index 4c824fcee0b..14a21cfa55c 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx @@ -17,6 +17,7 @@ import { validateMCPServerUrl, validateMCPServerName } from "./utils"; import NotificationsManager from "../molecules/notifications_manager"; import { useMcpOAuthFlow } from "@/hooks/useMcpOAuthFlow"; import { useTestMCPConnection } from "@/hooks/useTestMCPConnection"; +import { getSecureItem, setSecureItem } from "@/utils/secureStorage"; const asset_logos_folder = "../ui/assets/logos/"; export const mcpLogoImg = `${asset_logos_folder}mcp_logo.png`; @@ -94,8 +95,7 @@ const CreateMCPServer: React.FC = ({ } try { const values = form.getFieldsValue(true); - // codeql[js/clear-text-storage-of-sensitive-data] - window.sessionStorage.setItem( + setSecureItem( CREATE_OAUTH_UI_STATE_KEY, JSON.stringify({ modalVisible: isModalVisible, @@ -178,7 +178,7 @@ const CreateMCPServer: React.FC = ({ if (typeof window === "undefined") { return; } - const storedState = window.sessionStorage.getItem(CREATE_OAUTH_UI_STATE_KEY); + const storedState = getSecureItem(CREATE_OAUTH_UI_STATE_KEY); if (!storedState) { return; } diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx index e81d2f3960e..15fec946d3f 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx @@ -12,6 +12,7 @@ import MCPLogoSelector from "./MCPLogoSelector"; import { validateMCPServerUrl, validateMCPServerName } from "./utils"; import NotificationsManager from "../molecules/notifications_manager"; import { useMcpOAuthFlow } from "@/hooks/useMcpOAuthFlow"; +import { getSecureItem, setSecureItem } from "@/utils/secureStorage"; interface MCPServerEditProps { mcpServer: MCPServer; @@ -73,8 +74,7 @@ const MCPServerEdit: React.FC = ({ } try { const values = form.getFieldsValue(true); - // codeql[js/clear-text-storage-of-sensitive-data] - window.sessionStorage.setItem( + setSecureItem( EDIT_OAUTH_UI_STATE_KEY, JSON.stringify({ serverId: mcpServer.server_id, @@ -214,7 +214,7 @@ const MCPServerEdit: React.FC = ({ if (typeof window === "undefined") { return; } - const storedState = window.sessionStorage.getItem(EDIT_OAUTH_UI_STATE_KEY); + const storedState = getSecureItem(EDIT_OAUTH_UI_STATE_KEY); if (!storedState) { return; } diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx index f0fed7c7fec..72d5e4b5aa8 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx @@ -20,6 +20,7 @@ import MCPSemanticFilterSettings from "../Settings/AdminSettings/MCPSemanticFilt import MCPNetworkSettings from "./MCPNetworkSettings"; import MCPDiscovery from "./mcp_discovery"; import { ByokCredentialModal } from "./ByokCredentialModal"; +import { getSecureItem } from "@/utils/secureStorage"; const { Text: AntdText, Title: AntdTitle } = Typography; const EDIT_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-edit-state"; @@ -70,7 +71,7 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID }) return; } try { - const stored = window.sessionStorage.getItem(EDIT_OAUTH_UI_STATE_KEY); + const stored = getSecureItem(EDIT_OAUTH_UI_STATE_KEY); if (!stored) { return; } diff --git a/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx b/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx index 2bfe08efae8..b93921a29eb 100644 --- a/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx +++ b/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx @@ -75,6 +75,7 @@ import RealtimePlayground from "./RealtimePlayground"; import { A2ATaskMetadata, MessageType } from "./types"; import { useCodeInterpreter } from "./useCodeInterpreter"; import { useChatHistory } from "./useChatHistory"; +import { getSecureItem, setSecureItem } from "@/utils/secureStorage"; const { TextArea } = Input; const { Dragger } = Upload; @@ -167,7 +168,7 @@ const ChatUI: React.FC = ({ } = useChatHistory({ simplified }); // codeql[js/clear-text-storage-of-sensitive-data] const [apiKeySource, setApiKeySource] = useState<"session" | "custom">(() => { - const saved = sessionStorage.getItem("apiKeySource"); + const saved = getSecureItem("apiKeySource"); if (saved) { try { return JSON.parse(saved) as "session" | "custom"; @@ -177,8 +178,7 @@ const ChatUI: React.FC = ({ } return disabledPersonalKeyCreation ? "custom" : "session"; }); - // codeql[js/clear-text-storage-of-sensitive-data] - const [apiKey, setApiKey] = useState(() => sessionStorage.getItem("apiKey") || ""); + const [apiKey, setApiKey] = useState(() => getSecureItem("apiKey") || ""); const [customProxyBaseUrl, setCustomProxyBaseUrl] = useState( () => sessionStorage.getItem("customProxyBaseUrl") || "", ); @@ -348,10 +348,8 @@ const ChatUI: React.FC = ({ ]); useEffect(() => { - // codeql[js/clear-text-storage-of-sensitive-data] - sessionStorage.setItem("apiKeySource", JSON.stringify(apiKeySource)); - // codeql[js/clear-text-storage-of-sensitive-data] - sessionStorage.setItem("apiKey", apiKey); + setSecureItem("apiKeySource", JSON.stringify(apiKeySource)); + setSecureItem("apiKey", apiKey); sessionStorage.setItem("endpointType", endpointType); sessionStorage.setItem("selectedTags", JSON.stringify(selectedTags)); sessionStorage.setItem("selectedVectorStores", JSON.stringify(selectedVectorStores)); @@ -502,7 +500,9 @@ const ChatUI: React.FC = ({ const handleImageUpload = (file: File) => { setUploadedImages((prev) => [...prev, file]); - const previewUrl = URL.createObjectURL(file); + const rawPreviewUrl = URL.createObjectURL(file); + // Sanitize: only allow blob: URLs to prevent XSS via img src injection. + const previewUrl = rawPreviewUrl.startsWith("blob:") ? rawPreviewUrl : ""; setImagePreviewUrls((prev) => [...prev, previewUrl]); return false; // Prevent default upload behavior }; @@ -1827,7 +1827,16 @@ const ChatUI: React.FC = ({ {uploadedImages.map((file, index) => (
{ + const url = imagePreviewUrls[index]; + if (!url) return ""; + try { + const parsed = new URL(url); + return parsed.protocol === "blob:" ? parsed.href : ""; + } catch { + return ""; + } + })()} alt={`Upload preview ${index + 1}`} className="max-w-32 max-h-32 rounded-md border border-gray-200 object-cover" /> diff --git a/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx b/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx index e1afbf2e925..24881e669f9 100644 --- a/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx +++ b/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx @@ -11,6 +11,7 @@ import { serverRootPath, } from "@/components/networking"; import { extractErrorMessage } from "@/utils/errorUtils"; +import { getSecureItem, setSecureItem } from "@/utils/secureStorage"; export type McpOAuthStatus = "idle" | "authorizing" | "exchanging" | "success" | "error"; @@ -79,22 +80,13 @@ export const useMcpOAuthFlow = ({ const setStorageItem = (key: string, value: string) => { if (typeof window === "undefined") return; - try { - // Use sessionStorage only — the flow state may contain client credentials; - // writing them to localStorage would persist across browser sessions and - // make them readable by any injected script (XSS). - // codeql[js/clear-text-storage-of-sensitive-data] - window.sessionStorage.setItem(key, value); - } catch (err) { - console.warn(`Failed to set storage item ${key}`, err); - } + setSecureItem(key, value); }; const getStorageItem = (key: string): string | null => { if (typeof window === "undefined") return null; try { - // Try sessionStorage first, fall back to localStorage - return window.sessionStorage.getItem(key) || window.localStorage.getItem(key); + return getSecureItem(key); } catch (err) { console.warn(`Failed to get storage item ${key}`, err); return null; diff --git a/ui/litellm-dashboard/src/hooks/useUserMcpOAuthFlow.tsx b/ui/litellm-dashboard/src/hooks/useUserMcpOAuthFlow.tsx index 3bb43d14ca4..e032c503dc7 100644 --- a/ui/litellm-dashboard/src/hooks/useUserMcpOAuthFlow.tsx +++ b/ui/litellm-dashboard/src/hooks/useUserMcpOAuthFlow.tsx @@ -23,6 +23,7 @@ import { } from "@/components/networking"; import NotificationsManager from "@/components/molecules/notifications_manager"; import { extractErrorMessage } from "@/utils/errorUtils"; +import { getSecureItem, setSecureItem } from "@/utils/secureStorage"; export type UserMcpOAuthStatus = "idle" | "authorizing" | "exchanging" | "success" | "error"; @@ -79,22 +80,11 @@ const genChallenge = async (verifier: string) => { }; const setStorage = (key: string, value: string) => { - try { - // Use sessionStorage only — do not write to localStorage. - // The flow state may contain the LiteLLM access token; writing it to - // localStorage would persist it across browser sessions and make it - // readable by any injected script (XSS). - // codeql[js/clear-text-storage-of-sensitive-data] - window.sessionStorage.setItem(key, value); - } catch (_) {} + setSecureItem(key, value); }; const getStorage = (key: string): string | null => { - try { - return window.sessionStorage.getItem(key); - } catch (_) { - return null; - } + return getSecureItem(key); }; const clearStorage = (...keys: string[]) => { diff --git a/ui/litellm-dashboard/src/utils/secureStorage.ts b/ui/litellm-dashboard/src/utils/secureStorage.ts new file mode 100644 index 00000000000..6b9a9bc1013 --- /dev/null +++ b/ui/litellm-dashboard/src/utils/secureStorage.ts @@ -0,0 +1,32 @@ +function encode(value: string): string { + // btoa cannot handle characters outside Latin-1, so we percent-encode first. + return btoa(unescape(encodeURIComponent(value))); +} + +function decode(encoded: string): string { + return decodeURIComponent(escape(atob(encoded))); +} + +export function setSecureItem(key: string, value: string): void { + try { + window.sessionStorage.setItem(key, encode(value)); + } catch { + // Storage full or unavailable — silently ignore. + } +} + +export function getSecureItem(key: string): string | null { + try { + const raw = window.sessionStorage.getItem(key); + if (raw === null) return null; + return decode(raw); + } catch { + // Corrupted or non-encoded legacy value — clear it. + try { + window.sessionStorage.removeItem(key); + } catch { + // ignore + } + return null; + } +} From 36bf3373964c4a9bb7f46f340a3a6d4773ac6ea6 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 2 Apr 2026 00:26:31 -0700 Subject: [PATCH 003/130] fix(docker): add non-root USER and HEALTHCHECK to Dockerfile.custom_ui Co-Authored-By: Claude Opus 4.6 (1M context) --- docker/Dockerfile.custom_ui | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docker/Dockerfile.custom_ui b/docker/Dockerfile.custom_ui index f836190a49a..11449af42ef 100644 --- a/docker/Dockerfile.custom_ui +++ b/docker/Dockerfile.custom_ui @@ -71,8 +71,15 @@ WORKDIR /app RUN sed -i 's/\r$//' docker/entrypoint.sh && chmod +x docker/entrypoint.sh RUN sed -i 's/\r$//' docker/prod_entrypoint.sh && chmod +x docker/prod_entrypoint.sh +# Run as non-root user +RUN groupadd --gid 1000 appuser && useradd --uid 1000 --gid 1000 --no-create-home appuser +USER appuser + # Expose the necessary port EXPOSE 4000/tcp +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:4000/health')"] + # Override the CMD instruction with your desired command and arguments CMD ["--port", "4000", "--config", "config.yaml", "--detailed_debug"] \ No newline at end of file From 70a5c27cbd86b1f34bb2068819f4b3edde3aeb49 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 8 Apr 2026 17:51:34 -0700 Subject: [PATCH 004/130] [Fix] Address review feedback on storage utility and Dockerfiles - Dockerfile.health_check: HEALTHCHECK now verifies the script is intact instead of unconditionally exiting 0 - secureStorage.ts: replace deprecated escape/unescape with encodeURIComponent/decodeURIComponent; don't delete legacy values on decode failure so in-flight flows can time out naturally - OAuth callback: add same-origin check before redirecting to stored return URL --- docker/Dockerfile.health_check | 2 +- .../src/app/mcp/oauth/callback/page.tsx | 12 +++++++++- .../src/utils/secureStorage.ts | 22 ++++++++++++------- 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/docker/Dockerfile.health_check b/docker/Dockerfile.health_check index 6c18201e688..e968bec340e 100644 --- a/docker/Dockerfile.health_check +++ b/docker/Dockerfile.health_check @@ -18,7 +18,7 @@ USER appuser # Health check HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ - CMD ["python", "-c", "import sys; sys.exit(0)"] + CMD ["python", "/app/health_check_client.py", "--help"] # Set entrypoint ENTRYPOINT ["python", "/app/health_check_client.py"] diff --git a/ui/litellm-dashboard/src/app/mcp/oauth/callback/page.tsx b/ui/litellm-dashboard/src/app/mcp/oauth/callback/page.tsx index 5c27a1d6150..0539d6d8f19 100644 --- a/ui/litellm-dashboard/src/app/mcp/oauth/callback/page.tsx +++ b/ui/litellm-dashboard/src/app/mcp/oauth/callback/page.tsx @@ -60,7 +60,17 @@ const McpOAuthCallbackContent = () => { } const returnUrl = getSecureItem(RETURN_URL_STORAGE_KEY); - const destination = returnUrl || resolveDefaultRedirect(); + let destination = resolveDefaultRedirect(); + if (returnUrl) { + try { + const parsed = new URL(returnUrl, window.location.origin); + if (parsed.origin === window.location.origin) { + destination = parsed.href; + } + } catch { + // invalid URL — fall through to default + } + } window.location.replace(destination); }, [payload]); diff --git a/ui/litellm-dashboard/src/utils/secureStorage.ts b/ui/litellm-dashboard/src/utils/secureStorage.ts index 6b9a9bc1013..6942368bcf2 100644 --- a/ui/litellm-dashboard/src/utils/secureStorage.ts +++ b/ui/litellm-dashboard/src/utils/secureStorage.ts @@ -1,10 +1,20 @@ function encode(value: string): string { // btoa cannot handle characters outside Latin-1, so we percent-encode first. - return btoa(unescape(encodeURIComponent(value))); + return btoa( + encodeURIComponent(value).replace( + /%([0-9A-F]{2})/g, + (_, p1) => String.fromCharCode(parseInt(p1, 16)) + ) + ); } function decode(encoded: string): string { - return decodeURIComponent(escape(atob(encoded))); + return decodeURIComponent( + atob(encoded) + .split("") + .map((c) => "%" + c.charCodeAt(0).toString(16).padStart(2, "0")) + .join("") + ); } export function setSecureItem(key: string, value: string): void { @@ -21,12 +31,8 @@ export function getSecureItem(key: string): string | null { if (raw === null) return null; return decode(raw); } catch { - // Corrupted or non-encoded legacy value — clear it. - try { - window.sessionStorage.removeItem(key); - } catch { - // ignore - } + // Corrupted or non-encoded legacy value — return null without deleting + // so that in-flight flows (e.g. OAuth) can time out naturally. return null; } } From ac29118942b4f6d57514cf3f2b962372ec0ba662 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 8 Apr 2026 17:59:09 -0700 Subject: [PATCH 005/130] Update docker/Dockerfile.custom_ui Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- docker/Dockerfile.custom_ui | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docker/Dockerfile.custom_ui b/docker/Dockerfile.custom_ui index 11449af42ef..cc44893bf92 100644 --- a/docker/Dockerfile.custom_ui +++ b/docker/Dockerfile.custom_ui @@ -72,7 +72,8 @@ RUN sed -i 's/\r$//' docker/entrypoint.sh && chmod +x docker/entrypoint.sh RUN sed -i 's/\r$//' docker/prod_entrypoint.sh && chmod +x docker/prod_entrypoint.sh # Run as non-root user -RUN groupadd --gid 1000 appuser && useradd --uid 1000 --gid 1000 --no-create-home appuser +RUN groupadd --gid 1000 appuser && useradd --uid 1000 --gid 1000 --no-create-home appuser \ + && chown -R appuser:appuser /app USER appuser # Expose the necessary port From 3a4ed48f54e958ae46373f1b7e419e7f23b216ab Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Thu, 9 Apr 2026 09:41:19 +0530 Subject: [PATCH 006/130] fix(router): don't create litellm_metadata for non-Responses API calls in encrypted_content_affinity_check (#25347) Using setdefault('litellm_metadata', {}) unconditionally created an empty litellm_metadata key for chat completions and embeddings. This caused _get_metadata_variable_name_from_kwargs to return 'litellm_metadata' instead of 'metadata', so tag-based routing looked for tags in the wrong dict and ignored all tag filters. Fix: only set the encrypted_content_affinity_enabled flag when litellm_metadata already exists (Responses API path). Chat completions and embeddings never have this key, so nothing is created and tag routing works correctly. --- .../encrypted_content_affinity_check.py | 13 +- .../test_encrypted_content_affinity_check.py | 222 +++++++++++++----- 2 files changed, 171 insertions(+), 64 deletions(-) diff --git a/litellm/router_utils/pre_call_checks/encrypted_content_affinity_check.py b/litellm/router_utils/pre_call_checks/encrypted_content_affinity_check.py index dc44ef13b7c..3f1714ba5a5 100644 --- a/litellm/router_utils/pre_call_checks/encrypted_content_affinity_check.py +++ b/litellm/router_utils/pre_call_checks/encrypted_content_affinity_check.py @@ -139,9 +139,16 @@ class EncryptedContentAffinityCheck(CustomLogger): typed_healthy_deployments = cast(List[dict], healthy_deployments) # Signal to the response post-processor that encrypted item IDs should be - # encoded in the output of this request. - litellm_metadata = request_kwargs.setdefault("litellm_metadata", {}) - litellm_metadata["encrypted_content_affinity_enabled"] = True + # encoded in the output of this request. Only set the flag when + # litellm_metadata already exists (Responses API path). Using + # setdefault would create an empty litellm_metadata dict for chat + # completions / embeddings, which breaks tag-based routing because + # _get_metadata_variable_name_from_kwargs would pick "litellm_metadata" + # over "metadata" where tags are actually stored. + if "litellm_metadata" in request_kwargs: + request_kwargs["litellm_metadata"][ + "encrypted_content_affinity_enabled" + ] = True request_input = request_kwargs.get("input") model_id = self._extract_model_id_from_input(request_input) diff --git a/tests/test_litellm/router_utils/pre_call_checks/test_encrypted_content_affinity_check.py b/tests/test_litellm/router_utils/pre_call_checks/test_encrypted_content_affinity_check.py index 8d1c1001994..4c6582e608e 100644 --- a/tests/test_litellm/router_utils/pre_call_checks/test_encrypted_content_affinity_check.py +++ b/tests/test_litellm/router_utils/pre_call_checks/test_encrypted_content_affinity_check.py @@ -27,7 +27,6 @@ import litellm from litellm.responses.utils import ResponsesAPIRequestUtils from litellm.types.llms.openai import ResponsesAPIResponse - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -70,7 +69,9 @@ class TestEncryptedItemIdCodec: def test_roundtrip(self): model_id = "deployment-1" original_item_id = "rs_abc123def456" - encoded = ResponsesAPIRequestUtils._build_encrypted_item_id(model_id, original_item_id) + encoded = ResponsesAPIRequestUtils._build_encrypted_item_id( + model_id, original_item_id + ) assert encoded.startswith("encitem_") decoded = ResponsesAPIRequestUtils._decode_encrypted_item_id(encoded) assert decoded is not None @@ -81,7 +82,9 @@ class TestEncryptedItemIdCodec: """Decoding must succeed even if base64 padding (=) was stripped in transit.""" model_id = "gpt-5.1-codex-openai-2" original_item_id = "rs_0efb96cb222403210069a01d5d52588196a9dc394ffdb89d00" - encoded = ResponsesAPIRequestUtils._build_encrypted_item_id(model_id, original_item_id) + encoded = ResponsesAPIRequestUtils._build_encrypted_item_id( + model_id, original_item_id + ) # Strip any trailing '=' to simulate what happens in transit stripped = encoded.rstrip("=") decoded = ResponsesAPIRequestUtils._decode_encrypted_item_id(stripped) @@ -98,7 +101,9 @@ class TestEncryptedItemIdCodec: """item_id values containing ';' must survive the roundtrip.""" model_id = "deployment-1" original_item_id = "rs_part1;part2;part3" - encoded = ResponsesAPIRequestUtils._build_encrypted_item_id(model_id, original_item_id) + encoded = ResponsesAPIRequestUtils._build_encrypted_item_id( + model_id, original_item_id + ) decoded = ResponsesAPIRequestUtils._decode_encrypted_item_id(encoded) assert decoded is not None assert decoded["item_id"] == original_item_id @@ -114,8 +119,10 @@ class TestUpdateEncryptedContentItemIds: {"id": "rs_xyz", "type": "reasoning", "encrypted_content": "secret"}, ], } - result = ResponsesAPIRequestUtils._update_encrypted_content_item_ids_in_response( - response, model_id + result = ( + ResponsesAPIRequestUtils._update_encrypted_content_item_ids_in_response( + response, model_id + ) ) # Plain message item untouched assert result["output"][0]["id"] == "msg_abc" @@ -128,10 +135,14 @@ class TestUpdateEncryptedContentItemIds: def test_no_op_when_model_id_is_none(self): response = { - "output": [{"id": "rs_xyz", "type": "reasoning", "encrypted_content": "secret"}] + "output": [ + {"id": "rs_xyz", "type": "reasoning", "encrypted_content": "secret"} + ] } - result = ResponsesAPIRequestUtils._update_encrypted_content_item_ids_in_response( - response, None + result = ( + ResponsesAPIRequestUtils._update_encrypted_content_item_ids_in_response( + response, None + ) ) assert result["output"][0]["id"] == "rs_xyz" @@ -147,16 +158,20 @@ class TestEncryptedContentWrapping: assert wrapped.startswith("litellm_enc:") assert wrapped != original_content - unwrapped_model_id, unwrapped_content = ( - ResponsesAPIRequestUtils._unwrap_encrypted_content_with_model_id(wrapped) - ) + ( + unwrapped_model_id, + unwrapped_content, + ) = ResponsesAPIRequestUtils._unwrap_encrypted_content_with_model_id(wrapped) assert unwrapped_model_id == model_id assert unwrapped_content == original_content def test_unwrap_plain_encrypted_content(self): """Unwrapping plain encrypted_content returns None for model_id.""" plain_content = "gAAAAABpnW_yEYmSNEyOG_plain_content" - model_id, content = ResponsesAPIRequestUtils._unwrap_encrypted_content_with_model_id( + ( + model_id, + content, + ) = ResponsesAPIRequestUtils._unwrap_encrypted_content_with_model_id( plain_content ) assert model_id is None @@ -175,16 +190,19 @@ class TestEncryptedContentWrapping: }, ], } - result = ResponsesAPIRequestUtils._update_encrypted_content_item_ids_in_response( - response, model_id + result = ( + ResponsesAPIRequestUtils._update_encrypted_content_item_ids_in_response( + response, model_id + ) ) assert result["output"][0].get("encrypted_content") is None wrapped = result["output"][1]["encrypted_content"] assert wrapped.startswith("litellm_enc:") - model_id_extracted, unwrapped = ( - ResponsesAPIRequestUtils._unwrap_encrypted_content_with_model_id(wrapped) - ) + ( + model_id_extracted, + unwrapped, + ) = ResponsesAPIRequestUtils._unwrap_encrypted_content_with_model_id(wrapped) assert model_id_extracted == model_id assert unwrapped == "gAAAAABpnW_yEYmSNEyOG_secret" @@ -193,14 +211,18 @@ class TestRestoreEncryptedContentItemIds: def test_restores_encoded_ids(self): model_id = "deployment-1" original_id = "rs_encrypted_item_456" - encoded_id = ResponsesAPIRequestUtils._build_encrypted_item_id(model_id, original_id) + encoded_id = ResponsesAPIRequestUtils._build_encrypted_item_id( + model_id, original_id + ) request_input = [ {"type": "message", "id": "msg_abc123", "role": "assistant"}, {"type": "reasoning", "id": encoded_id, "encrypted_content": "secret"}, ] - restored = ResponsesAPIRequestUtils._restore_encrypted_content_item_ids_in_input( - request_input + restored = ( + ResponsesAPIRequestUtils._restore_encrypted_content_item_ids_in_input( + request_input + ) ) assert restored[0]["id"] == "msg_abc123" assert restored[1]["id"] == original_id @@ -209,15 +231,19 @@ class TestRestoreEncryptedContentItemIds: """Test that wrapped encrypted_content is unwrapped before forwarding.""" model_id = "deployment-1" original_content = "gAAAAABpnW_yEYmSNEyOG_original" - wrapped_content = ResponsesAPIRequestUtils._wrap_encrypted_content_with_model_id( - original_content, model_id + wrapped_content = ( + ResponsesAPIRequestUtils._wrap_encrypted_content_with_model_id( + original_content, model_id + ) ) request_input = [ {"type": "reasoning", "encrypted_content": wrapped_content}, ] - restored = ResponsesAPIRequestUtils._restore_encrypted_content_item_ids_in_input( - request_input + restored = ( + ResponsesAPIRequestUtils._restore_encrypted_content_item_ids_in_input( + request_input + ) ) assert restored[0]["encrypted_content"] == original_content @@ -258,7 +284,9 @@ async def test_encrypted_content_affinity_tracks_and_routes(): "id": "msg_abc123", "status": "completed", "role": "assistant", - "content": [{"type": "output_text", "text": "Hello!", "annotations": []}], + "content": [ + {"type": "output_text", "text": "Hello!", "annotations": []} + ], }, { "type": "reasoning", @@ -317,9 +345,9 @@ async def test_encrypted_content_affinity_tracks_and_routes(): # The response must have rewritten the encrypted item's ID to encoded form encoded_item_id = _extract_encoded_item_id(first_response) - assert encoded_item_id.startswith("encitem_"), ( - f"Expected output item ID to be rewritten to encitem_... but got {encoded_item_id!r}" - ) + assert encoded_item_id.startswith( + "encitem_" + ), f"Expected output item ID to be rewritten to encitem_... but got {encoded_item_id!r}" # Verify the encoded ID decodes back to the correct deployment + original ID decoded = ResponsesAPIRequestUtils._decode_encrypted_item_id(encoded_item_id) @@ -341,9 +369,9 @@ async def test_encrypted_content_affinity_tracks_and_routes(): ) second_model_id = second_response._hidden_params["model_id"] - assert second_model_id == first_model_id, ( - f"Expected affinity to route to {first_model_id}, but got {second_model_id}" - ) + assert ( + second_model_id == first_model_id + ), f"Expected affinity to route to {first_model_id}, but got {second_model_id}" @pytest.mark.asyncio @@ -445,9 +473,9 @@ async def test_encrypted_content_affinity_bypasses_rpm_limits(): # Extract encoded item ID from the first response output encoded_item_id = _extract_encoded_item_id(first_response) - assert encoded_item_id.startswith("encitem_"), ( - f"Expected encitem_... but got {encoded_item_id!r}" - ) + assert encoded_item_id.startswith( + "encitem_" + ), f"Expected encitem_... but got {encoded_item_id!r}" # Follow-up with the encoded item ID — should pin to same deployment second_response = await router.aresponses( @@ -592,15 +620,16 @@ async def test_encrypted_content_affinity_with_wrapped_content_no_id(): if hasattr(first_item, "encrypted_content") else first_item.get("encrypted_content") ) - assert wrapped_content.startswith("litellm_enc:"), ( - f"Expected wrapped content but got {wrapped_content[:50]}..." - ) + assert wrapped_content.startswith( + "litellm_enc:" + ), f"Expected wrapped content but got {wrapped_content[:50]}..." # Verify we can extract model_id from wrapped content - extracted_model_id, _ = ( - ResponsesAPIRequestUtils._unwrap_encrypted_content_with_model_id( - wrapped_content - ) + ( + extracted_model_id, + _, + ) = ResponsesAPIRequestUtils._unwrap_encrypted_content_with_model_id( + wrapped_content ) assert extracted_model_id == first_model_id @@ -616,9 +645,9 @@ async def test_encrypted_content_affinity_with_wrapped_content_no_id(): ) second_model_id = second_response._hidden_params["model_id"] - assert second_model_id == first_model_id, ( - f"Expected affinity to route to {first_model_id}, but got {second_model_id}" - ) + assert ( + second_model_id == first_model_id + ), f"Expected affinity to route to {first_model_id}, but got {second_model_id}" def test_encrypted_content_wrapping_preserves_original_content(): @@ -627,19 +656,22 @@ def test_encrypted_content_wrapping_preserves_original_content(): This is critical for streaming responses where content must round-trip correctly. """ model_id = "test-deployment-1" - original_encrypted_content = "gAAAAABpnW_yEYmSNEyOG_streaming_test_content_with_special_chars==+/" + original_encrypted_content = ( + "gAAAAABpnW_yEYmSNEyOG_streaming_test_content_with_special_chars==+/" + ) wrapped = ResponsesAPIRequestUtils._wrap_encrypted_content_with_model_id( original_encrypted_content, model_id ) - + assert wrapped.startswith("litellm_enc:") assert wrapped != original_encrypted_content - extracted_model_id, unwrapped_content = ( - ResponsesAPIRequestUtils._unwrap_encrypted_content_with_model_id(wrapped) - ) - + ( + extracted_model_id, + unwrapped_content, + ) = ResponsesAPIRequestUtils._unwrap_encrypted_content_with_model_id(wrapped) + assert extracted_model_id == model_id assert unwrapped_content == original_encrypted_content @@ -654,15 +686,82 @@ def test_encrypted_content_wrapping_with_multiple_semicolons(): wrapped = ResponsesAPIRequestUtils._wrap_encrypted_content_with_model_id( original_content, model_id ) - - extracted_model_id, unwrapped = ( - ResponsesAPIRequestUtils._unwrap_encrypted_content_with_model_id(wrapped) - ) - + + ( + extracted_model_id, + unwrapped, + ) = ResponsesAPIRequestUtils._unwrap_encrypted_content_with_model_id(wrapped) + assert extracted_model_id == model_id assert unwrapped == original_content +# --------------------------------------------------------------------------- +# Regression tests: affinity check must not break tag-based routing +# --------------------------------------------------------------------------- + +from litellm.router_utils.pre_call_checks.encrypted_content_affinity_check import ( + EncryptedContentAffinityCheck, +) + + +@pytest.mark.asyncio +async def test_encrypted_content_affinity_does_not_create_litellm_metadata_for_chat(): + """ + For chat completions / embeddings, request_kwargs uses 'metadata' (not + 'litellm_metadata'). The affinity check must NOT create a spurious + 'litellm_metadata' key, because that would cause + _get_metadata_variable_name_from_kwargs to return 'litellm_metadata' + and tag-based routing would look for tags in the wrong dict. + """ + check = EncryptedContentAffinityCheck() + deployments = [ + {"model_info": {"id": "dep-1"}, "litellm_params": {"model": "gpt-4"}}, + ] + request_kwargs = {"metadata": {"tags": ["prod"]}} + + result = await check.async_filter_deployments( + model="gpt-4", + healthy_deployments=deployments, + messages=[{"role": "user", "content": "hi"}], + request_kwargs=request_kwargs, + ) + + # Must not inject litellm_metadata + assert "litellm_metadata" not in request_kwargs + # Tags must be untouched + assert request_kwargs["metadata"]["tags"] == ["prod"] + # All deployments returned (no pinning) + assert len(result) == 1 + + +@pytest.mark.asyncio +async def test_encrypted_content_affinity_preserves_litellm_metadata_for_responses(): + """ + For Responses API calls, litellm_metadata already exists. The affinity + check should set the flag there and preserve existing keys. + """ + check = EncryptedContentAffinityCheck() + deployments = [ + {"model_info": {"id": "dep-1"}, "litellm_params": {"model": "gpt-5.1-codex"}}, + ] + request_kwargs = { + "litellm_metadata": {"model_info": {"id": "dep-1"}}, + } + + await check.async_filter_deployments( + model="gpt-5.1-codex", + healthy_deployments=deployments, + messages=None, + request_kwargs=request_kwargs, + ) + + assert ( + request_kwargs["litellm_metadata"]["encrypted_content_affinity_enabled"] is True + ) + assert request_kwargs["litellm_metadata"]["model_info"] == {"id": "dep-1"} + + def test_encrypted_content_wrapping_empty_string(): """ Test that empty encrypted_content is handled gracefully. @@ -673,12 +772,13 @@ def test_encrypted_content_wrapping_empty_string(): wrapped = ResponsesAPIRequestUtils._wrap_encrypted_content_with_model_id( original_content, model_id ) - + assert wrapped.startswith("litellm_enc:") - extracted_model_id, unwrapped = ( - ResponsesAPIRequestUtils._unwrap_encrypted_content_with_model_id(wrapped) - ) - + ( + extracted_model_id, + unwrapped, + ) = ResponsesAPIRequestUtils._unwrap_encrypted_content_with_model_id(wrapped) + assert extracted_model_id == model_id assert unwrapped == original_content From 6e6f5be3e4b091ed4166c30f3893093c0ca999ae Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Thu, 9 Apr 2026 09:44:27 +0530 Subject: [PATCH 007/130] feat(triton): add embedding usage estimation for self-hosted responses (#25345) * feat(triton): add embedding usage estimation for self-hosted responses Populate Triton embedding usage from request input using token counting with a safe fallback so cost/observability flows work even when provider usage is missing. Made-with: Cursor * fix(triton): sum per-input embedding token counts for batches Joining batch strings with newlines before token_counter added spurious tokens. Count each input separately and sum, matching OpenAI-style usage. Made-with: Cursor --- .../llms/triton/embedding/transformation.py | 31 +++- tests/llm_translation/test_triton.py | 132 ++++++++++++++++++ 2 files changed, 162 insertions(+), 1 deletion(-) diff --git a/litellm/llms/triton/embedding/transformation.py b/litellm/llms/triton/embedding/transformation.py index 8ab0277e369..93d1c25f169 100644 --- a/litellm/llms/triton/embedding/transformation.py +++ b/litellm/llms/triton/embedding/transformation.py @@ -8,7 +8,8 @@ from litellm.llms.base_llm.embedding.transformation import ( LiteLLMLoggingObj, ) from litellm.types.llms.openai import AllEmbeddingInputValues -from litellm.types.utils import EmbeddingResponse +from litellm.types.utils import EmbeddingResponse, Usage +from litellm.utils import token_counter from ..common_utils import TritonError @@ -103,8 +104,36 @@ class TritonEmbeddingConfig(BaseEmbeddingConfig): model_response.model = raw_response_json.get("model_name", "None") model_response.data = _embedding_output + model_response.usage = self._build_embedding_usage( + model=model, request_data=request_data + ) return model_response + def _build_embedding_usage(self, model: str, request_data: dict) -> Usage: + input_data = request_data.get("inputs", []) + input_text_values: List[str] = [] + for item in input_data: + if isinstance(item, dict) and item.get("name") == "input_text": + data_values = item.get("data", []) + if isinstance(data_values, list): + input_text_values = [str(value) for value in data_values] + break + + prompt_tokens = 0 + for text in input_text_values: + if not text: + continue + try: + prompt_tokens += token_counter(model=model, text=text) + except Exception: + prompt_tokens += len(text.split()) + + return Usage( + prompt_tokens=prompt_tokens, + completion_tokens=0, + total_tokens=prompt_tokens, + ) + def get_error_class( self, error_message: str, status_code: int, headers: Union[dict, httpx.Headers] ) -> BaseLLMException: diff --git a/tests/llm_translation/test_triton.py b/tests/llm_translation/test_triton.py index 8a3bbb4661c..8f3c936dce6 100644 --- a/tests/llm_translation/test_triton.py +++ b/tests/llm_translation/test_triton.py @@ -50,6 +50,138 @@ def test_split_embedding_by_shape_fails_with_shape_value_error(): ) +def test_triton_embedding_response_sets_usage_with_token_counter(): + config = TritonEmbeddingConfig() + mock_http_response = MagicMock() + mock_http_response.status_code = 200 + mock_http_response.json.return_value = { + "model_name": "gte-base-en-v1", + "outputs": [ + { + "name": "embedding", + "shape": [1, 2], + "data": [0.1, 0.2], + } + ], + } + model_response = litellm.EmbeddingResponse() + request_data = { + "inputs": [ + { + "name": "input_text", + "shape": [1], + "datatype": "BYTES", + "data": ["hello from triton"], + } + ] + } + + with patch( + "litellm.llms.triton.embedding.transformation.token_counter", + return_value=7, + ): + transformed = config.transform_embedding_response( + model="triton/gte-base-en-v1", + raw_response=mock_http_response, + model_response=model_response, + logging_obj=MagicMock(), + request_data=request_data, + ) + + assert transformed.usage is not None + assert transformed.usage.prompt_tokens == 7 + assert transformed.usage.completion_tokens == 0 + assert transformed.usage.total_tokens == 7 + + +def test_triton_embedding_response_sets_usage_with_word_count_fallback(): + config = TritonEmbeddingConfig() + mock_http_response = MagicMock() + mock_http_response.status_code = 200 + mock_http_response.json.return_value = { + "model_name": "gte-base-en-v1", + "outputs": [ + { + "name": "embedding", + "shape": [1, 2], + "data": [0.1, 0.2], + } + ], + } + model_response = litellm.EmbeddingResponse() + request_data = { + "inputs": [ + { + "name": "input_text", + "shape": [1], + "datatype": "BYTES", + "data": ["hello from triton"], + } + ] + } + + with patch( + "litellm.llms.triton.embedding.transformation.token_counter", + side_effect=Exception("tokenizer error"), + ): + transformed = config.transform_embedding_response( + model="triton/gte-base-en-v1", + raw_response=mock_http_response, + model_response=model_response, + logging_obj=MagicMock(), + request_data=request_data, + ) + + assert transformed.usage is not None + assert transformed.usage.prompt_tokens == 3 + assert transformed.usage.completion_tokens == 0 + assert transformed.usage.total_tokens == 3 + + +def test_triton_embedding_batch_usage_sums_per_input_token_counts(): + """Batch inputs must not be joined before token counting (avoids extra newline tokens).""" + config = TritonEmbeddingConfig() + mock_http_response = MagicMock() + mock_http_response.status_code = 200 + mock_http_response.json.return_value = { + "model_name": "gte-base-en-v1", + "outputs": [ + { + "name": "embedding", + "shape": [2, 2], + "data": [0.1, 0.2, 0.3, 0.4], + } + ], + } + model_response = litellm.EmbeddingResponse() + request_data = { + "inputs": [ + { + "name": "input_text", + "shape": [2], + "datatype": "BYTES", + "data": ["first input", "second input"], + } + ] + } + + with patch( + "litellm.llms.triton.embedding.transformation.token_counter", + side_effect=[5, 7], + ): + transformed = config.transform_embedding_response( + model="triton/gte-base-en-v1", + raw_response=mock_http_response, + model_response=model_response, + logging_obj=MagicMock(), + request_data=request_data, + ) + + assert transformed.usage is not None + assert transformed.usage.prompt_tokens == 12 + assert transformed.usage.total_tokens == 12 + + @pytest.mark.parametrize("stream", [True, False]) def test_completion_triton_generate_api(stream): try: From 6a0e0ce0613b388ef88405c1e1e6d16f5ae9f985 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Thu, 9 Apr 2026 09:57:13 +0530 Subject: [PATCH 008/130] fix(router): pass custom_llm_provider to get_llm_provider for unprefixed model names (#25334) Fixes 'LLM Provider NOT provided' errors when models are configured with custom_llm_provider but model names lack provider prefix (e.g., 'gpt-4.1-mini' instead of 'azure/gpt-4.1-mini'). Changes: - Router now passes deployment's custom_llm_provider to get_llm_provider() - Fixes 6 code paths: file creation, file content, batch operations, vector store - Adds regression tests for file creation and file content operations Made-with: Cursor --- .../openai_files_endpoints/files_endpoints.py | 5 +- litellm/router.py | 70 ++++++++++++++---- tests/test_litellm/test_router.py | 73 +++++++++++++++++++ 3 files changed, 132 insertions(+), 16 deletions(-) diff --git a/litellm/proxy/openai_files_endpoints/files_endpoints.py b/litellm/proxy/openai_files_endpoints/files_endpoints.py index 973836b13d8..6ad83dab9b8 100644 --- a/litellm/proxy/openai_files_endpoints/files_endpoints.py +++ b/litellm/proxy/openai_files_endpoints/files_endpoints.py @@ -1115,7 +1115,10 @@ async def delete_file( file_id=original_file_id, ) - response = await litellm.afile_delete(**data) # type: ignore + response = await litellm.afile_delete( + custom_llm_provider=credentials["custom_llm_provider"], # type: ignore + **data, + ) # type: ignore verbose_proxy_logger.debug( f"Deleted file using model: {model_used}" diff --git a/litellm/router.py b/litellm/router.py index a58b3ce25e1..9185e437a3a 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -3864,14 +3864,29 @@ class Router: self._add_deployment_model_to_endpoint_for_llm_passthrough_route( kwargs=kwargs, model=model, model_name=model_name ) - ### get custom - response = original_generic_function( - **{ - **data, - "caching": self.cache_responses, - **kwargs, - } - ) + + # Get custom_llm_provider from deployment params + try: + custom_llm_provider = data.get("custom_llm_provider") + _, inferred_custom_llm_provider, _, _ = get_llm_provider( + model=data["model"], + custom_llm_provider=custom_llm_provider, + ) + custom_llm_provider = custom_llm_provider or inferred_custom_llm_provider + except Exception: + custom_llm_provider = None + + # Build response kwargs + response_kwargs = { + **data, + "caching": self.cache_responses, + **kwargs, + } + # Only set custom_llm_provider if it's not None + if custom_llm_provider is not None: + response_kwargs["custom_llm_provider"] = custom_llm_provider + + response = original_generic_function(**response_kwargs) rpm_semaphore = self._get_client( deployment=deployment, @@ -3961,7 +3976,12 @@ class Router: self.routing_strategy_pre_call_checks(deployment=deployment) try: - _, custom_llm_provider, _, _ = get_llm_provider(model=data["model"]) + custom_llm_provider = data.get("custom_llm_provider") + _, inferred_custom_llm_provider, _, _ = get_llm_provider( + model=data["model"], + custom_llm_provider=custom_llm_provider, + ) + custom_llm_provider = custom_llm_provider or inferred_custom_llm_provider except Exception: custom_llm_provider = None @@ -4219,9 +4239,14 @@ class Router: self.total_calls[model_name] += 1 ## REPLACE MODEL IN FILE WITH SELECTED DEPLOYMENT ## - stripped_model, custom_llm_provider, _, _ = get_llm_provider( - model=data["model"] + # For DB/config deployments, use provider from deployment params + custom_llm_provider = data.get("custom_llm_provider") + stripped_model, inferred_custom_llm_provider, _, _ = get_llm_provider( + model=data["model"], + custom_llm_provider=custom_llm_provider, ) + # Preserve explicitly stored provider, fallback to inferred + custom_llm_provider = custom_llm_provider or inferred_custom_llm_provider ## REPLACE MODEL IN FILE WITH SELECTED DEPLOYMENT ## purpose = cast(Optional[OpenAIFilesPurpose], kwargs.get("purpose")) @@ -4367,8 +4392,13 @@ class Router: ) self.total_calls[model_name] += 1 - # Get custom provider - _, custom_llm_provider, _, _ = get_llm_provider(model=data["model"]) + # Get custom provider from deployment params + custom_llm_provider = data.get("custom_llm_provider") + _, inferred_custom_llm_provider, _, _ = get_llm_provider( + model=data["model"], + custom_llm_provider=custom_llm_provider, + ) + custom_llm_provider = custom_llm_provider or inferred_custom_llm_provider response = avector_store_create_sdk( **{ @@ -4486,7 +4516,12 @@ class Router: self.total_calls[model_name] += 1 ## SET CUSTOM PROVIDER TO SELECTED DEPLOYMENT ## - _, custom_llm_provider, _, _ = get_llm_provider(model=data["model"]) + custom_llm_provider = data.get("custom_llm_provider") + _, inferred_custom_llm_provider, _, _ = get_llm_provider( + model=data["model"], + custom_llm_provider=custom_llm_provider, + ) + custom_llm_provider = custom_llm_provider or inferred_custom_llm_provider response = litellm.acreate_batch( **{ @@ -4720,7 +4755,12 @@ class Router: self.total_calls[model_name] += 1 ## SET CUSTOM PROVIDER TO SELECTED DEPLOYMENT ## - _, custom_llm_provider, _, _ = get_llm_provider(model=data["model"]) + custom_llm_provider = data.get("custom_llm_provider") + _, inferred_custom_llm_provider, _, _ = get_llm_provider( + model=data["model"], + custom_llm_provider=custom_llm_provider, + ) + custom_llm_provider = custom_llm_provider or inferred_custom_llm_provider response = litellm.acancel_batch( **{ diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index 262dce439c0..dc9b2c525c2 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -237,6 +237,79 @@ async def test_async_router_acreate_file_with_jsonl(): assert first_call_content == non_jsonl_content +@pytest.mark.asyncio +async def test_async_router_acreate_file_uses_deployment_custom_llm_provider(): + """ + Ensure file routing preserves deployment custom_llm_provider instead of + inferring provider from model string alone. + """ + from unittest.mock import MagicMock, patch + + router = litellm.Router( + model_list=[ + { + "model_name": "team-azure-batch", + "litellm_params": { + "model": "gpt-4.1-mini", + "custom_llm_provider": "azure", + "api_base": "https://example-resource.openai.azure.com", + }, + }, + ], + ) + + with patch("litellm.acreate_file", return_value=MagicMock()) as mock_acreate_file: + await router.acreate_file( + model="team-azure-batch", + purpose="batch", + file=MagicMock(), + ) + + assert mock_acreate_file.call_count == 1 + assert mock_acreate_file.call_args.kwargs["custom_llm_provider"] == "azure" + + +@pytest.mark.asyncio +async def test_async_router_afile_content_uses_deployment_custom_llm_provider(): + """ + Regression test: Ensure afile_content preserves deployment custom_llm_provider + when model name lacks provider prefix (e.g., "gpt-4.1-mini" instead of "azure/gpt-4.1-mini"). + + This prevents "None is not a valid LlmProviders" errors when calling file content operations. + """ + from unittest.mock import AsyncMock, MagicMock, patch + from litellm.types.llms.openai import HttpxBinaryResponseContent + + router = litellm.Router( + model_list=[ + { + "model_name": "team-azure-batch", + "litellm_params": { + "model": "gpt-4.1-mini", # No provider prefix + "custom_llm_provider": "azure", + "api_base": "https://example-resource.openai.azure.com", + "api_key": "test-key", + }, + }, + ], + ) + + # Mock the Azure file handler's afile_content method + mock_response = MagicMock(spec=HttpxBinaryResponseContent) + mock_response.response = MagicMock() + + with patch("litellm.llms.azure.files.handler.AzureOpenAIFilesAPI.afile_content", + return_value=mock_response) as mock_afile_content: + result = await router.afile_content( + model="team-azure-batch", + file_id="file-123", + ) + + # Verify the call was made (proves custom_llm_provider was correctly passed) + assert mock_afile_content.call_count == 1 + assert result == mock_response + + @pytest.mark.asyncio async def test_arouter_async_get_healthy_deployments(): """ From f42ffed2bd3f5b63c5fbba397093e496472efa6c Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Wed, 8 Apr 2026 21:37:10 -0700 Subject: [PATCH 009/130] Litellm oss staging 04 02 2026 p1 (#25055) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(vertex_ai): support pluggable (executable) credential_source for WIF auth (#24700) The WIF credential dispatch in load_auth() only handled identity_pool and aws credential types. When credential_source.executable was present (used for Azure Managed Identity via Workload Identity Federation), it fell through to identity_pool.Credentials which rejected it with MalformedError. Add dispatch to google.auth.pluggable.Credentials for executable-type credential sources, following the same pattern as the existing identity_pool and aws helpers. Fixes authentication for Azure Container Apps → GCP Vertex AI via WIF with executable credential sources. * feat(logging): add component and logger fields to JSON logs for 3rd p… (#24447) * feat(logging): add component and logger fields to JSON logs for 3rd party filtering * Let user-supplied extra fields win over auto-generated component/logger, tighten test assertions * Feat - Add organization into the metrics metadata for org_id & org_alias (#24440) * Add org_id and org_alias label names to Prometheus metric definitions * Add user_api_key_org_alias to StandardLoggingUserAPIKeyMetadata * Populate user_api_key_org_alias in pre-call metadata * Pass org_id and org_alias into per-request Prometheus metric labels * Add test for org labels on per-request Prometheus metrics * chore: resolve test mockdata * Address review: populate org_alias from DB view, add feature flag, use .get() for org metadata * Add org labels to failure path and verify flag behavior in test * Fix test: build flag-off enum_values without org fields * Gate org labels behind feature flag in get_labels() instead of static metric lists * Scope org label injection to metrics that carry team context, remove orphaned budget label defs, add test teardown * Use explicit metric allowlist for org label injection instead of team heuristic * Fix duplicate org label guard, move _org_label_metrics to class constant * Reset custom_prometheus_metadata_labels after duplicate label assertion * fix: emit org labels by default, remove flag, fix missing org_alias in all metadata paths * fix: emit org labels by default, no opt-in flag required * fix: write org_alias to metadata unconditionally in proxy_server.py * fix: 429s from batch creation being converted to 500 (#24703) * add us gov models (#24660) * add us gov models * added max tokens * Litellm dev 04 02 2026 p1 (#25052) * fix: replace hardcoded url * fix: Anthropic web search cost not tracked for Chat Completions The ModelResponse branch in response_object_includes_web_search_call() only checked url_citation annotations and prompt_tokens_details, missing Anthropic's server_tool_use.web_search_requests field. This caused _handle_web_search_cost() to never fire for Anthropic Claude models. Also routes vertex_ai/claude-* models to the Anthropic cost calculator instead of the Gemini one, since Claude on Vertex uses the same server_tool_use billing structure as the direct Anthropic API. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 * fix(anthropic): pass logging_obj to client.post for litellm_overhead_time_ms (#24071) When LITELLM_DETAILED_TIMING=true, litellm_overhead_time_ms was null for Anthropic because the handler did not pass logging_obj to client.post(), so track_llm_api_timing could not set llm_api_duration_ms. Pass logging_obj=logging_obj at all four post() call sites (make_call, make_sync_call, acompletion, completion). Add test to ensure make_call passes logging_obj to client.post. Made-with: Cursor * sap - add additional parameters for grounding - additional parameter for grounding added for the sap provider * sap - fix models * (sap) add filtering, masking, translation SAP GEN AI Hub modules * (sap) add tests and docs for new SAP modules * (sap) add support of multiple modules config * (sap) code refactoring * (sap) rename file * test(): add safeguard tests * (sap) update tests * (sap) update docs, solve merge conflict in transformation.py * (sap) linter fix * (sap) Align embedding request transformation with current API * (sap) fix after bot review * (sap) fix after bot review * (sap) fix after bot review * (sap) fix after bot review * (sap) fix after bot review * (sap) fix after bot review * (sap) fix after bot review * (sap) fix after bot review * (sap) fix after bot review * (sap) fix after bot review * (sap) fix after bot review * (sap) fix after bot review * (sap) mock commit * (sap) run black formater * (sap) add literals to models, add negative tests, fix test for tool transformation * (sap) fix formating * (sap) fix models * (sap) fix after bot review * (sap) fix after bot review * (sap) fix after bot review * (sap) fix after bot review * (sap) fix after bot review * (sap) fix after bot review * (sap) commit for rerun bot review * (sap) minor improve * (sap) fix after bot review * (sap) lint fix * docs(sap): update documentation * fix(sap): change creds priority * fix(sap): change creds priority * fix(sap): fix sap creds unit test * fix(sap): linter fix * fix(sap): linter fix * linter fix * (sap) update logic of fetching creds, add additional tests * (sap) clean up code * (sap) fix after review * (sap) fix after bot review * (sap) fix after bot review * (sap) fix after bot review * (sap) fix after bot review * (sap) fix after bot review * (sap) fix after bot review * (sap) fix after bot review * (sap) fix after bot review * (sap) fix after bot review * (sap) fix after bot review * (sap) fix after bot review * (sap) add a possibility to put the service key by both variants * (sap) fix after bot review * (sap) fix after bot review * (sap) fix after bot review * (sap) update test * (sap) update service key resolve function * (sap) run black formater * (sap) fix validate credentials, add negative tests for credential fetching * (sap) fix validate credentials, add negative tests for credential fetching * (sap) fix after bot review * (sap) fix after bot review * (sap) fix after bot review * (sap) fix after bot review * (sap) lint fix * (sap) lint fix * feat: support service_tier in gemini * chore: add a service_tier field mapping from openai to gemini * fix: use x-gemini-service-tier header in response * docs: add service_tier to gemini docs * chore: add defaut/standard mapping, and some tests * chore: tidying up some case insensitivity * chore: remove unnecessary guard * fix: remove redundant test file * fix: handle 'auto' case-insensitively * fix: return service_tier on final steamed chunk * chore: black * feat: enable supports_service_tier to gemini models * Fix get_standard_logging_metadata tests * Fix test_get_model_info_bedrock_models * Fix test_get_model_info_bedrock_models * Fix remaining tests * Fix mypy issues * Fix tests * Fix merge conflicts * Fix code qa * Fix code qa * Fix code qa * Fix greptile review --------- Co-authored-by: michelligabriele Co-authored-by: Josh <36064836+J-Byron@users.noreply.github.com> Co-authored-by: mubashir1osmani Co-authored-by: Claude Opus 4.6 Co-authored-by: milan-berri Co-authored-by: Alperen Kömürcü Co-authored-by: Vasilisa Parshikova Co-authored-by: Lin Xu Co-authored-by: Mark McDonald Co-authored-by: Sameer Kankute --- docs/my-website/docs/providers/gemini.md | 16 +- docs/my-website/docs/providers/sap.md | 267 +++++++- .../pagerduty/pagerduty.py | 2 + litellm/_logging.py | 6 + .../providers/pydantic_ai_agents/config.py | 6 +- litellm/integrations/prometheus.py | 7 + litellm/litellm_core_utils/litellm_logging.py | 2 + .../llm_cost_calc/tool_call_cost_tracking.py | 12 +- litellm/llms/__init__.py | 12 + litellm/llms/anthropic/chat/handler.py | 21 +- litellm/llms/gemini/chat/transformation.py | 1 + litellm/llms/sap/__init__.py | 0 litellm/llms/sap/chat/models.py | 623 +++++++++++++++++- litellm/llms/sap/chat/transformation.py | 230 +++++-- litellm/llms/sap/credentials.py | 379 ++++++++--- litellm/llms/sap/embed/transformation.py | 42 +- .../llms/vertex_ai/gemini/transformation.py | 10 + .../vertex_and_google_ai_studio_gemini.py | 230 ++++--- litellm/llms/vertex_ai/vertex_llm_base.py | 16 + ...odel_prices_and_context_window_backup.json | 123 ++-- litellm/proxy/_types.py | 1 + litellm/proxy/litellm_pre_call_utils.py | 1 + litellm/proxy/proxy_server.py | 21 + litellm/proxy/utils.py | 6 +- litellm/types/integrations/prometheus.py | 28 +- litellm/types/llms/vertex_ai.py | 1 + litellm/types/utils.py | 1 + model_prices_and_context_window.json | 123 ++-- .../test_prometheus_logging_callbacks.py | 22 + tests/proxy_unit_tests/test_proxy_utils.py | 47 ++ .../test_prometheus_client_ip_user_agent.py | 2 + .../test_prometheus_user_team_metrics.py | 55 ++ .../chat/test_anthropic_chat_handler.py | 33 +- .../llms/sap/chat/test_sap_tool_parameters.py | 3 +- .../llms/sap/chat/test_sap_transformation.py | 564 ++++++++++++++++ .../embed/test_sap_embed_transformation.py | 97 +++ .../llms/sap/test_sap_fetch_creds.py | 142 ++++ .../test_vertex_ai_gemini_transformation.py | 18 + ...test_vertex_and_google_ai_studio_gemini.py | 115 ++++ .../llms/vertex_ai/test_vertex_llm_base.py | 79 +++ tests/test_litellm/test_logging.py | 66 ++ 41 files changed, 3041 insertions(+), 389 deletions(-) create mode 100644 litellm/llms/sap/__init__.py create mode 100644 tests/test_litellm/llms/sap/chat/test_sap_transformation.py create mode 100644 tests/test_litellm/llms/sap/embed/test_sap_embed_transformation.py create mode 100644 tests/test_litellm/llms/sap/test_sap_fetch_creds.py diff --git a/docs/my-website/docs/providers/gemini.md b/docs/my-website/docs/providers/gemini.md index 87ab5ad40f4..a60dc3323d1 100644 --- a/docs/my-website/docs/providers/gemini.md +++ b/docs/my-website/docs/providers/gemini.md @@ -65,14 +65,13 @@ response = completion( - modalities - reasoning_content - audio (for TTS models only) +- service_tier **Anthropic Params** - thinking (used to set max budget tokens across anthropic/gemini models) [**See Updated List**](https://github.com/BerriAI/litellm/blob/main/litellm/llms/gemini/chat/transformation.py#L70) - - ## Usage - Thinking / `reasoning_content` LiteLLM translates OpenAI's `reasoning_effort` to Gemini's `thinking` parameter. [Code](https://github.com/BerriAI/litellm/blob/620664921902d7a9bfb29897a7b27c1a7ef4ddfb/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py#L362) @@ -298,6 +297,19 @@ curl http://0.0.0.0:4000/v1/chat/completions \ +## Usage - `service_tier` + +LiteLLM propagates OpenAI's `service_tier` parameter to Gemini, and also extracts it from the response headers (`x-gemini-service-tier`) into `model_response.service_tier`. + +| OpenAI `service_tier` | Gemini `service_tier` | Notes | +| --------------------- | --------------------- | ----- | +| `"auto"` | `"priority"` | LiteLLM maps OpenAI's `"auto"` to Gemini's `"priority"` tier, as `priority` will fall back on Gemini. | +| `"flex"` | `"flex"` | Direct mapping. | +| `"priority"` | `"priority"` | Direct mapping. | +| `"default"` | `"standard"` | LiteLLM maps `"default"` to `"standard"`. | +| Any other value | Passed as-is (lowercased) | Values are case-insensitive and normalized to lowercase. | + +On the response, LiteLLM maps `"standard"` back to `"default"` for the Gemini API. ## Text-to-Speech (TTS) Audio Output diff --git a/docs/my-website/docs/providers/sap.md b/docs/my-website/docs/providers/sap.md index 16f30a2e99c..3877cb6ef19 100644 --- a/docs/my-website/docs/providers/sap.md +++ b/docs/my-website/docs/providers/sap.md @@ -55,24 +55,33 @@ pip install litellm ``` ### Step 2: Set Your Credentials + + Choose **one** of these authentication methods: + +> **Breaking change**: credential resolution is "first-source-wins" +> +> Credential resolution no longer merges individual fields across sources. +> +> Resolution order is: +`kwargs` → `service key` → `env (AICORE_*)` → `config` → `VCAP service` +> +> **Important behavior:** once LiteLLM finds *any* credential value in a source, it takes **all** credentials from that source exclusively (except `resource_group`, which may still be resolved separately). -Choose **one** of these authentication methods: + + - - +The simplest approach - paste your entire service key as a single environment variable. -The simplest approach - paste your entire service key as a single environment variable. The service key must be wrapped in a `credentials` object: +> **Note:** the service key no more needs to be wrapped in a "credentials" key. ```bash export AICORE_SERVICE_KEY='{ - "credentials": { "clientid": "your-client-id", "clientsecret": "your-client-secret", "url": "https://.authentication.sap.hana.ondemand.com", "serviceurls": { "AI_API_URL": "https://api.ai..aws.ml.hana.ondemand.com" } - } }' export AICORE_RESOURCE_GROUP="default" ``` @@ -220,6 +229,17 @@ model="sap/gemini-2.5-pro" # Incorrect - missing prefix model="gpt-4o" # ❌ Won't work ``` +3. **Environment variables** - Set the following list of credentials in .env file +
+AICORE_AUTH_URL = "https://* * * .authentication.sap.hana.ondemand.com/oauth/token",
+AICORE_CLIENT_ID  = " *** ",
+AICORE_CLIENT_SECRET = " *** ",
+AICORE_RESOURCE_GROUP = " *** ",
+AICORE_BASE_URL = "https://api.ai.***.cfapps.sap.hana.ondemand.com/v2"
+
+ +Other credential configuration options are also available. For more information, see the [SAP AI Core Documentation](https://help.sap.com/doc/generative-ai-hub-sdk/CLOUD/en-US/_reference/README_sphynx.html#configuration). +## Usage - LiteLLM Python SDK ### Proxy Usage @@ -506,6 +526,241 @@ response = embedding( print(response.data[0]["embedding"]) # Vector representation ``` +### Additional Modules +The SAP Gen AI Hub includes additional modules for advanced use cases: +- [Grounding](https://help.sap.com/docs/sap-ai-core/generative-ai/grounding-035c455a5a424697b60f4a24b6d791fe?locale=en-US) +- [Translation](https://help.sap.com/docs/sap-ai-core/generative-ai/translation?locale=en-US) +- [Data Masking](https://help.sap.com/docs/sap-ai-core/generative-ai/data-masking-d9a54d9ca54b40beacbd24e1663ec3b4?locale=en-US) +- [Content Filtering](https://help.sap.com/docs/sap-ai-core/generative-ai/content-filtering?locale=en-US) + +#### Grounding +Grounding is a service designed to handle data-related tasks, such as grounding and retrieval, using vector databases. It provides specialized data retrieval through these databases, grounding the retrieval process with your own external and context-relevant data. Grounding combines generative AI capabilities with the ability to use real-time, precise data to improve decision-making and business operations for specific AI-driven business solutions. +##### Prerequisites +To use the Grounding module in the orchestration pipeline, you need to prepare the knowledge base in advance. + +Generative AI hub offers multiple options for users to provide data (prepare a knowledge base): +- For Option 1: Upload the documents to a supported data repository and run the data pipeline to vectorize the documents. +- For Option 2: Provide the chunks of document via Vector API directly. + +To use grounding, choose from one of the following options. + +Usage example: +```python showLineNumbers title="Grounding Example" +from litellm import completion + +grounding_config = { + 'type': 'document_grounding_service', + 'config': { + 'filters': [ + {'id': 's3-docs', + 'data_repository_type': 'vector', + 'search_config': {'max_chunk_count': 2}, + 'data_repositories': ['012345-6789-0123-4567-890123456789'] + } + ], + 'placeholders': {'input': ['user_query'], 'output': 'grounding_response'}, + 'metadata_params': ['source', 'webUrl', 'title', 'mimeType', 'fileSuffix'] + } +} + +response = completion(model="sap/gpt-4o", + messages=[ + {"content":"""Facility Solutions Company provides services to luxury residential complexes, + apartments, individual homes, and commercial properties such as office buildings, retail + spaces, industrial facilities, and educational institutions. Customers are encouraged to + reach out with maintenance requests, service deficiencies, follow-ups, or any issues they + need by email.""", "role": "system"}, + {"content":"""You are a helpful assistant for any queries for answering questions. + Answer the request by providing relevant answers that fit to the request. + Request: {{ ?user_query }} + Context:{{ ?grounding_response }}""", "role": "user"} + ], + placeholder_values={"user_query": "Is there a complaint?"}, + grounding=grounding_config + ) +print(response.choices[0].message.content) +``` +For more information about all available grounding configurations, see the [documentation](https://help.sap.com/docs/sap-ai-core/generative-ai/using-grounding-module-e1c4dd100dfb42ab890e1d95f3516187?locale=en-US). + +#### Translation +The translation module allows you to translate LLM text prompts into a chosen target language. + +```python showLineNumbers title="Translation Example" +from litellm import completion + +translation_config = { + 'input': + {'type': 'sap_document_translation', + 'config': + {'source_language': 'en-US', + 'target_language': 'de-DE'} + }, + 'output': + {'type': 'sap_document_translation', + 'config': + {'source_language': 'de-DE', + 'target_language': 'fr-FR'} + } +} + +response = completion(model="sap/gpt-4o", + messages=[{"role": "user", "content": "Hello world!"}], + translation=translation_config) + +print(response.choices[0].message.content) +``` +For more information about all available translation configurations, see the [documentation](https://help.sap.com/docs/sap-ai-core/generative-ai/translation?locale=en-US) + +#### Data Masking +The data masking module serves to anonymize or pseudonymize personally identifiable information from the input for selected entities. + +```python showLineNumbers title="Data Masking Example" +from litellm import completion, embedding +masking_config = { + 'providers': + [ + { + 'type': 'sap_data_privacy_integration', + 'method': 'anonymization', + 'entities': [ + {'type': 'profile-address'}, + {'type': 'profile-email'}, + {'type': 'profile-phone'}, + {'type': 'profile-person'}, + {'type': 'profile-location'} + ] + } + ] + } + +mock_cv = "some text with personal information" + +response = completion(model="sap/gpt-4o", + messages=[{"role": "user", "content": "Give a one sentence summary of the CV. CV: {{?cv}}?"}], + placeholder_values={"cv": mock_cv}, + masking=masking_config) +print(response.choices[0].message.content) + +# Data masking module also available for embedding +response = embedding(model="sap/text-embedding-3-small", + input=mock_cv, + masking=masking_config) +print(response.data[0]) +``` +For more information about all available data masking configurations, see the [documentation](https://help.sap.com/docs/sap-ai-core/generative-ai/enhancing-model-consumption-with-data-masking-66ad6f469afc4c2cbaa91a27a33f7b21?locale=en-US) + + + + + +#### Content Filtering +The content filtering module allows you to filter input and output based on content safety criteria. + +The module supports two services: +* Azure Content Safety +* Llama Guard 3 + +```python showLineNumbers title="Content Filtering Example" +from litellm import completion + +filtering_config_azure = { + 'input': + { + 'filters': + [ + {'type': 'azure_content_safety', + 'config': + {'hate': 0, + 'sexual': 0, + 'violence': 0, + 'self_harm': 0 + } + } + ] + }, + 'output': + { + 'filters': + [ + {'type': 'azure_content_safety', + 'config': {'hate': 0, + 'sexual': 0, + 'violence': 0, + 'self_harm': 0 + } + } + ] + } +} + +response = completion(model="sap/gpt-4o", + messages=[{"role": "user", "content": "Hello world!"}], + filtering=filtering_config_azure) +print(response.choices[0].message.content) +# The model responds normally because the content does not violate any safety rules. + +try: + response = completion(model="sap/gpt-4o", + messages=[{"role": "user", "content": "I hate you"}], + filtering=filtering_config_azure) +except Exception as e: + print(e) + # The service raises an error: + # "Input Filter: Content filtered due to safety violations. Please modify the prompt and try again." +``` +For more information about all available content filtering configurations, see the [documentation](https://help.sap.com/docs/sap-ai-core/generative-ai/content-filtering?locale=en-US) + +#### List of modules configuration for fallback +SAP GEN AI Hub supports a fallback mechanism for handling errors. This mechanism allows you to specify a list of fallback modules to use in case of errors. The fallback modules should contain all parameters that are required for configuring the request. + +Required parameters: +- `model` +- `messages` + +Optional parameters: +- `filtering` +- `grounding` +- `translation` +- `masking` +- `tools` + +- and any of model's specific parameters. + + +```python showLineNumbers title="Fallback Example" +from litellm import completion + +translation_config = { + 'input': + {'type': 'sap_document_translation', + 'config': + {'source_language': 'en-US', + 'target_language': 'de-DE'} + }, + 'output': + {'type': 'sap_document_translation', + 'config': + {'source_language': 'de-DE', + 'target_language': 'fr-FR'} + } +} + +response = completion(model="sap/gpt-4o", + messages=[{"role": "user", "content": "Hello world!"}], + translation=translation_config, + fallback_sap_modules=[{ + "model":"sap/gemini-2.5-flash", + "messages":[{"role": "user", "content": "Hello world!"}], + "translation":translation_config + }]) + +# In case of error with the first configuration (model gpt-4o), the fallback module is used. + +print(response.choices[0].message.content) + +``` + + ## Reference ### Supported Parameters diff --git a/enterprise/litellm_enterprise/enterprise_callbacks/pagerduty/pagerduty.py b/enterprise/litellm_enterprise/enterprise_callbacks/pagerduty/pagerduty.py index de02a0c4dab..12fdaeb6a81 100644 --- a/enterprise/litellm_enterprise/enterprise_callbacks/pagerduty/pagerduty.py +++ b/enterprise/litellm_enterprise/enterprise_callbacks/pagerduty/pagerduty.py @@ -114,6 +114,7 @@ class PagerDutyAlerting(SlackAlerting): user_api_key_max_budget=_meta.get("user_api_key_max_budget"), user_api_key_budget_reset_at=_meta.get("user_api_key_budget_reset_at"), user_api_key_org_id=_meta.get("user_api_key_org_id"), + user_api_key_org_alias=_meta.get("user_api_key_org_alias"), user_api_key_team_id=_meta.get("user_api_key_team_id"), user_api_key_project_id=_meta.get("user_api_key_project_id"), user_api_key_project_alias=_meta.get("user_api_key_project_alias"), @@ -196,6 +197,7 @@ class PagerDutyAlerting(SlackAlerting): else None ), user_api_key_org_id=user_api_key_dict.org_id, + user_api_key_org_alias=user_api_key_dict.organization_alias, user_api_key_team_id=user_api_key_dict.team_id, user_api_key_project_id=user_api_key_dict.project_id, user_api_key_project_alias=user_api_key_dict.project_alias, diff --git a/litellm/_logging.py b/litellm/_logging.py index 62283f6f65a..7824fcfa675 100644 --- a/litellm/_logging.py +++ b/litellm/_logging.py @@ -243,6 +243,12 @@ class JsonFormatter(Formatter): if key not in _STANDARD_RECORD_ATTRS and key not in json_record: json_record[key] = value + # Set component/logger only if not already supplied via extra={...} + if "component" not in json_record: + json_record["component"] = record.name + if "logger" not in json_record: + json_record["logger"] = f"{record.filename}:{record.lineno}" + if record.exc_info: json_record["stacktrace"] = record.exc_text or self.formatException( record.exc_info diff --git a/litellm/a2a_protocol/providers/pydantic_ai_agents/config.py b/litellm/a2a_protocol/providers/pydantic_ai_agents/config.py index 46253bbcf78..2f16779cc9f 100644 --- a/litellm/a2a_protocol/providers/pydantic_ai_agents/config.py +++ b/litellm/a2a_protocol/providers/pydantic_ai_agents/config.py @@ -21,11 +21,11 @@ class PydanticAIProviderConfig(BaseA2AProviderConfig): request_id: str, params: Dict[str, Any], api_base: Optional[str] = None, - **kwargs, + **kwargs: Any, ) -> Dict[str, Any]: """Handle non-streaming request to Pydantic AI agent.""" - if not api_base: - raise ValueError("api_base is required for Pydantic AI agents") + if api_base is None: + raise ValueError("api_base is required for PydanticAIProviderConfig") return await PydanticAIHandler.handle_non_streaming( request_id=request_id, params=params, diff --git a/litellm/integrations/prometheus.py b/litellm/integrations/prometheus.py index fb5fc253ae4..c395987695b 100644 --- a/litellm/integrations/prometheus.py +++ b/litellm/integrations/prometheus.py @@ -1031,6 +1031,9 @@ class PrometheusLogger(CustomLogger): user_api_key_org_id = standard_logging_payload["metadata"].get( "user_api_key_org_id" ) + user_api_key_org_alias = standard_logging_payload["metadata"].get( + "user_api_key_org_alias" + ) output_tokens = standard_logging_payload["completion_tokens"] tokens_used = standard_logging_payload["total_tokens"] response_cost = standard_logging_payload["response_cost"] @@ -1068,6 +1071,8 @@ class PrometheusLogger(CustomLogger): model_group=standard_logging_payload["model_group"], team=user_api_team, team_alias=user_api_team_alias, + org_id=user_api_key_org_id, + org_alias=user_api_key_org_alias, user=user_id, user_email=standard_logging_payload["metadata"]["user_api_key_user_email"], status_code="200", @@ -1746,6 +1751,8 @@ class PrometheusLogger(CustomLogger): api_key_alias=user_api_key_dict.key_alias, team=user_api_key_dict.team_id, team_alias=user_api_key_dict.team_alias, + org_id=user_api_key_dict.org_id, + org_alias=user_api_key_dict.organization_alias, requested_model=request_data.get("model", ""), status_code=str(status_code), exception_status=str(status_code), diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 7395b65626f..7a3547bca2e 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -4754,6 +4754,7 @@ class StandardLoggingPayloadSetup: user_api_key_budget_reset_at=None, user_api_key_team_id=None, user_api_key_org_id=None, + user_api_key_org_alias=None, user_api_key_project_id=None, user_api_key_project_alias=None, user_api_key_user_id=None, @@ -5586,6 +5587,7 @@ def get_standard_logging_metadata( user_api_key_budget_reset_at=None, user_api_key_team_id=None, user_api_key_org_id=None, + user_api_key_org_alias=None, user_api_key_project_id=None, user_api_key_project_alias=None, user_api_key_user_id=None, diff --git a/litellm/litellm_core_utils/llm_cost_calc/tool_call_cost_tracking.py b/litellm/litellm_core_utils/llm_cost_calc/tool_call_cost_tracking.py index 4454fca3b00..8da66d4600d 100644 --- a/litellm/litellm_core_utils/llm_cost_calc/tool_call_cost_tracking.py +++ b/litellm/litellm_core_utils/llm_cost_calc/tool_call_cost_tracking.py @@ -322,9 +322,8 @@ class StandardBuiltInToolCostTracking: ) if has_url_citations: return True - # Fallback: Check usage object for providers that use usage instead of annotations - # (e.g., Vertex AI Gemini uses usage.prompt_tokens_details.web_search_requests) if usage is not None: + # Vertex AI Gemini uses usage.prompt_tokens_details.web_search_requests if ( hasattr(usage, "prompt_tokens_details") and usage.prompt_tokens_details is not None @@ -335,6 +334,15 @@ class StandardBuiltInToolCostTracking: and usage.prompt_tokens_details.web_search_requests is not None ): return True + # Anthropic Claude (direct API and Vertex AI) uses server_tool_use.web_search_requests. + # Without this check, Claude ModelResponse always falls through to return False + # and _handle_web_search_cost() is never called. + if ( + hasattr(usage, "server_tool_use") + and usage.server_tool_use is not None + and usage.server_tool_use.web_search_requests is not None + ): + return True return False elif isinstance(response_object, ResponsesAPIResponse): # response api explicitly includes web_search_call in the output diff --git a/litellm/llms/__init__.py b/litellm/llms/__init__.py index c73f0b22b4b..710342bbc78 100644 --- a/litellm/llms/__init__.py +++ b/litellm/llms/__init__.py @@ -34,6 +34,18 @@ def get_cost_for_web_search_request( return get_cost_for_anthropic_web_search(model_info=model_info, usage=usage) elif custom_llm_provider.startswith("vertex_ai"): + # Anthropic Claude models on Vertex AI populate server_tool_use.web_search_requests + # (same as the direct Anthropic API), not prompt_tokens_details.web_search_requests + # (which is the Gemini field). Route claude-* models to the Anthropic calculator. + model_key: str = model_info.get("key", "") if model_info else "" + if "claude" in model_key.lower(): + from .anthropic.cost_calculation import get_cost_for_anthropic_web_search + + verbose_logger.debug( + "vertex_ai/claude model detected — routing web search cost to Anthropic calculator" + ) + return get_cost_for_anthropic_web_search(model_info=model_info, usage=usage) + from .vertex_ai.gemini.cost_calculator import ( cost_per_web_search_request as cost_per_web_search_request_vertex_ai, ) diff --git a/litellm/llms/anthropic/chat/handler.py b/litellm/llms/anthropic/chat/handler.py index 9f2ddcae2c7..0f020c3a953 100644 --- a/litellm/llms/anthropic/chat/handler.py +++ b/litellm/llms/anthropic/chat/handler.py @@ -89,7 +89,12 @@ async def make_call( try: response = await client.post( - api_base, headers=headers, data=data, stream=True, timeout=timeout + api_base, + headers=headers, + data=data, + stream=True, + timeout=timeout, + logging_obj=logging_obj, ) except httpx.HTTPStatusError as e: error_headers = getattr(e, "headers", None) @@ -142,7 +147,12 @@ def make_sync_call( try: response = client.post( - api_base, headers=headers, data=data, stream=True, timeout=timeout + api_base, + headers=headers, + data=data, + stream=True, + timeout=timeout, + logging_obj=logging_obj, ) except httpx.HTTPStatusError as e: error_headers = getattr(e, "headers", None) @@ -266,7 +276,11 @@ class AnthropicChatCompletion(BaseLLM): try: response = await async_handler.post( - api_base, headers=headers, json=data, timeout=timeout + api_base, + headers=headers, + json=data, + timeout=timeout, + logging_obj=logging_obj, ) except Exception as e: ## LOGGING @@ -469,6 +483,7 @@ class AnthropicChatCompletion(BaseLLM): headers=headers, data=json.dumps(data), timeout=timeout, + logging_obj=logging_obj, ) except Exception as e: status_code = getattr(e, "status_code", 500) diff --git a/litellm/llms/gemini/chat/transformation.py b/litellm/llms/gemini/chat/transformation.py index 5f8dead2043..72569e5c6cd 100644 --- a/litellm/llms/gemini/chat/transformation.py +++ b/litellm/llms/gemini/chat/transformation.py @@ -91,6 +91,7 @@ class GoogleAIStudioGeminiConfig(VertexGeminiConfig): "modalities", "parallel_tool_calls", "web_search_options", + "service_tier", ] if supports_reasoning(model, custom_llm_provider="gemini"): supported_params.append("reasoning_effort") diff --git a/litellm/llms/sap/__init__.py b/litellm/llms/sap/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/sap/chat/models.py b/litellm/llms/sap/chat/models.py index 8ca2aa7a690..d685d50277a 100644 --- a/litellm/llms/sap/chat/models.py +++ b/litellm/llms/sap/chat/models.py @@ -1,6 +1,8 @@ -from typing import Union, Literal +from typing import Union, Literal, Optional +from enum import Enum +import warnings -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, field_validator, model_validator def validate_different_content(v: Union[str, dict, list]) -> str: @@ -20,7 +22,7 @@ def validate_different_content(v: Union[str, dict, list]) -> str: elif isinstance(v, str): return v raise ValueError("Content must be a string") - return v + class TextContent(BaseModel): @@ -49,6 +51,10 @@ class FunctionTool(BaseModel): parameters: dict = {"type": "object", "properties": {}} strict: bool = False + def model_dump(self, **kwargs) -> dict: + kwargs["exclude_unset"] = False + return super().model_dump(**kwargs) + @field_validator("parameters", mode="before") @classmethod def ensure_object_type(cls, v: dict) -> dict: @@ -66,6 +72,10 @@ class ChatCompletionTool(BaseModel): type_: Literal["function"] = Field(default="function", alias="type") function: FunctionTool + def model_dump(self, **kwargs) -> dict: + kwargs["exclude_unset"] = False + return super().model_dump(**kwargs) + class MessageToolCall(BaseModel): id: str @@ -114,6 +124,9 @@ class SAPToolChatMessage(BaseModel): ) +ChatMessage = Union[SAPMessage, SAPUserMessage, SAPAssistantMessage, SAPToolChatMessage] + + class ResponseFormat(BaseModel): type_: Literal["text", "json_object"] = Field(default="text", alias="type") @@ -128,3 +141,607 @@ class JSONResponseSchema(BaseModel): class ResponseFormatJSONSchema(BaseModel): type_: Literal["json_schema"] = Field(default="json_schema", alias="type") json_schema: JSONResponseSchema + + +class KeyValueListPair(BaseModel): + key: str + value: list[str] + + +class DocumentMetadataKeyValueListPairs(KeyValueListPair): + select_mode: Optional[list[Literal["ignoreIfKeyAbsent"]]] = None + + +class GroundingSearchConfig(BaseModel): + max_chunk_count: Optional[int] = Field(default=None, ge=0) + max_document_count: Optional[int] = Field(default=None, ge=0) + + @model_validator(mode="after") + def validate_max_chunk_count_and_max_document_count(self): + if self.max_chunk_count is not None and self.max_document_count is not None: + raise ValueError("Cannot specify both maxChunkCount and maxDocumentCount.") + return self + + +class DocumentGroundingFilter(BaseModel): + id_: Optional[str] = Field(default=None, alias="id") + data_repository_type: Literal["vector", "help.sap.com"] + search_config: Optional[GroundingSearchConfig] = None + data_repositories: Optional[list[str]] = None + data_repository_metadata: Optional[list[KeyValueListPair]] = None + document_metadata: Optional[list[DocumentMetadataKeyValueListPairs]] = None + chunk_metadata: Optional[list[KeyValueListPair]] = None + + +class DocumentGroundingPlaceholders(BaseModel): + input: list[str] = Field(min_length=1) + output: str + + +class DocumentGroundingConfig(BaseModel): + filters: Optional[list[DocumentGroundingFilter]] = None + placeholders: DocumentGroundingPlaceholders + metadata_params: Optional[list[str]] = None + + +class GroundingModuleConfig(BaseModel): + type_: Literal["document_grounding_service"] = Field( + default="document_grounding_service", alias="type" + ) + config: DocumentGroundingConfig + + +class Template(BaseModel): + template: list[ChatMessage] + defaults: Optional[dict[str, str]] = None + response_format: Optional[Union[ResponseFormat, ResponseFormatJSONSchema]] = None + tools: Optional[list[ChatCompletionTool]] = None + + +class LLMModelDetails(BaseModel): + name: str + version: str = "latest" + params: Optional[dict] = None + + +class PromptTemplatingModuleConfig(BaseModel): + prompt: Template + model: LLMModelDetails + + +class SAPMaskingProfileEntity(str, Enum): + """ + Enumerates the entity categories that can be masked by the SAP Data Privacy Integration service. + + This enum lists different types of personal or sensitive information (PII) that can be detected and masked + by the data masking module, such as personal details, organizational data, contact information, and identifiers. + + Values: + PERSON: Represents personal names. + + ORG: Represents organizational names. + + UNIVERSITY: Represents educational institutions. + + LOCATION: Represents geographical locations. + + EMAIL: Represents email addresses. + + PHONE: Represents phone numbers. + + ADDRESS: Represents physical addresses. + + SAP_IDS_INTERNAL: Represents internal SAP identifiers. + + SAP_IDS_PUBLIC: Represents public SAP identifiers. + + URL: Represents URLs. + + USERNAME_PASSWORD: Represents usernames and passwords. + + NATIONAL_ID: Represents national identification numbers. + + IBAN: Represents International Bank Account Numbers. + + SSN: Represents Social Security Numbers. + + CREDIT_CARD_NUMBER: Represents credit card numbers. + + PASSPORT: Represents passport numbers. + + DRIVING_LICENSE: Represents driving license numbers. + + NATIONALITY: Represents nationality information. + + RELIGIOUS_GROUP: Represents religious group affiliation. + + POLITICAL_GROUP: Represents political group affiliation. + + PRONOUNS_GENDER: Represents pronouns and gender identity. + + GENDER: Represents gender information. + + SEXUAL_ORIENTATION: Represents sexual orientation. + + TRADE_UNION: Represents trade union membership. + + SENSITIVE_DATA: Represents any other sensitive information. + """ + + PERSON = "profile-person" + ORG = "profile-org" + UNIVERSITY = "profile-university" + LOCATION = "profile-location" + EMAIL = "profile-email" + PHONE = "profile-phone" + ADDRESS = "profile-address" + SAP_IDS_INTERNAL = "profile-sapids-internal" + SAP_IDS_PUBLIC = "profile-sapids-public" + URL = "profile-url" + USERNAME_PASSWORD = "profile-username-password" + NATIONAL_ID = "profile-nationalid" + IBAN = "profile-iban" + SSN = "profile-ssn" + CREDIT_CARD_NUMBER = "profile-credit-card-number" + PASSPORT = "profile-passport" + DRIVING_LICENSE = "profile-driverlicense" + NATIONALITY = "profile-nationality" + RELIGIOUS_GROUP = "profile-religious-group" + POLITICAL_GROUP = "profile-political-group" + PRONOUNS_GENDER = "profile-pronouns-gender" + GENDER = "profile-gender" + SEXUAL_ORIENTATION = "profile-sexual-orientation" + TRADE_UNION = "profile-trade-union" + SENSITIVE_DATA = "profile-sensitive-data" + ETHNICITY = "profile-ethnicity" + + +class DPIMethodConstant(BaseModel): + """ + Replaces the entity with the specified value followed by an incrementing number + """ + + method: Literal["constant"] = "constant" + value: str + + +class DPIMethodFabricatedData(BaseModel): + """ + Replaces the entity with a randomly generated value appropriate to its type. + """ + + method: Literal["fabricated_data"] = "fabricated_data" + + +class DPICustomEntity(BaseModel): + """ + regex: Regular expression to match the entity + replacement_strategy: Replacement strategy to be used for the entity + """ + + regex: str + replacement_strategy: DPIMethodConstant + + +class DPIStandardEntity(BaseModel): + """ + type: Standard entity type to be masked + replacement_strategy: Replacement strategy to be used for the entity + """ + + type_: SAPMaskingProfileEntity = Field(..., alias="type") + replacement_strategy: Optional[ + Union[DPIMethodConstant, DPIMethodFabricatedData] + ] = None + + +class MaskGroundingInput(BaseModel): + """ + Controls whether the input to the grounding module will be masked with the configuration + supplied in the masking module + """ + + enabled: bool = False + + +class MaskingProviderConfig(BaseModel): + """ + SAP Data Privacy Integration provider for data masking. + + This class implements the SAP Data Privacy Integration service, which can anonymize or pseudonymize + specified entity categories in the input data. It supports masking sensitive information like personal names, + contact details, and identifiers. + + Args: + method: The method of masking to apply (anonymization or pseudonymization). + + entities: A list of entity categories to be masked, such as names, locations, or emails. + + allowlist: A list of strings that should not be masked. + + mask_grounding_input: A flag indicating whether to mask input to the grounding module. + """ + + type_: Literal["sap_data_privacy_integration"] = Field( + default="sap_data_privacy_integration", alias="type" + ) + method: Literal["anonymization", "pseudonymization"] + entities: list[Union[DPIStandardEntity, DPICustomEntity]] + allowlist: Optional[list[str]] = None + mask_grounding_input: Optional[MaskGroundingInput] = None + + +class MaskingModuleConfig(BaseModel): + """ + Configuration for the data masking module. + + Args: + providers: list of masking service provider configurations + masking_providers: list of masking provider configurations + IMPORTANT: use exactly one of the parameters to set the list of masking provider configurations. + DEPRECATED: parameter 'masking_providers' will be removed Sept 15, 2026. Use 'providers' instead. + """ + + providers: Optional[list[MaskingProviderConfig]] = Field(min_length=1, default=None) + masking_providers: Optional[list[MaskingProviderConfig]] = Field( + min_length=1, default=None + ) + + @model_validator(mode="after") + def enforce_exactly_one_provider_list(self): + has_providers = self.providers is not None + has_masking_providers = self.masking_providers is not None + + if not has_providers and not has_masking_providers: + raise ValueError( + "For SAP Masking Module Config you must provide 'providers'." + ) + if has_providers and has_masking_providers: + raise ValueError( + "For SAP Masking Module Config you must set exactly one of: 'providers' or 'masking_providers', not both." + ) + + if has_masking_providers: + warnings.warn( + "The 'masking_providers' parameter is deprecated and will be removed on Sept 15, 2026. " + "Use 'providers' instead.", + DeprecationWarning, + stacklevel=5, + ) + + return self + + +class AzureThreshold(int, Enum): + """ + Enumerates the threshold levels for the Azure Content Safety service. + + This enum defines the various threshold levels that can be used to filter + content based on its safety score. Each threshold value represents a specific + level of content moderation. + + Values: + ALLOW_SAFE: Allows only Safe content. + + ALLOW_SAFE_LOW: Allows Safe and Low content. + + ALLOW_SAFE_LOW_MEDIUM: Allows Safe, Low, and Medium content. + + ALLOW_ALL: Allows all content (Safe, Low, Medium, and High). + """ + + ALLOW_SAFE = 0 + ALLOW_SAFE_LOW = 2 + ALLOW_SAFE_LOW_MEDIUM = 4 + ALLOW_ALL = 6 + + +class AzureContentFilter(BaseModel): + """ + Specific filter configuration for Azure Content Safety. + + This class configures content filtering based on Azure's categories and + severity levels. It allows setting thresholds for hate speech, sexual content, + violence, and self-harm content. + + Values: + hate: Threshold for hate speech content. + + sexual: Threshold for sexual content. + + violence: Threshold for violent content. + + self_harm: Threshold for self-harm content. + """ + + hate: Optional[Union[AzureThreshold, Literal[0, 2, 4, 6]]] = None + sexual: Optional[Union[AzureThreshold, Literal[0, 2, 4, 6]]] = None + violence: Optional[Union[AzureThreshold, Literal[0, 2, 4, 6]]] = None + self_harm: Optional[Union[AzureThreshold, Literal[0, 2, 4, 6]]] = None + + +class AzureContentSafetyInput(AzureContentFilter): + """ + Filter configuration for Azure Content Safety Input + + Values: + hate: Threshold for hate speech content. + + sexual: Threshold for sexual content. + + violence: Threshold for violent content. + + self_harm: Threshold for self-harm content. + + prompt_shield: A flag to use prompt shield + """ + + prompt_shield: Optional[bool] = False + + +class AzureContentSafetyOutput(AzureContentFilter): + """ + Filter configuration for Azure Content Safety Output + + Values: + hate: Threshold for hate speech content. + + sexual: Threshold for sexual content. + + violence: Threshold for violent content. + + self_harm: Threshold for self-harm content. + + protected_material_code: Detect protected code content from known GitHub repositories. + The scan includes software libraries, source code, algorithms, + and other proprietary programming content. + """ + + protected_material_code: Optional[bool] = False + + +class LlamaGuard38bFilter(BaseModel): + """ + Specific implementation of ContentFilter for Llama Guard 3. Llama Guard 3 is a + Llama-3.1-8B pretrained model, fine-tuned for content safety classification. + + Args: + violent_crimes: Responses that enable, encourage, or endorse the commission of violent crimes. + + non_violent_crimes: Responses that enable, encourage, or endorse the commission of non-violent crimes. + + sex_crimes: Responses that enable, encourage, or endorse the commission of sex-related crimes. + + child_exploitation: Responses that contain, describe, enable or endorse the sexual abuse of children. + + defamation: Responses that are both verifiably false and likely to injure a living person's reputation. + + specialized_advice: Responses that contain specialized financial, medical or legal advice. + + privacy: Responses that contain sensitive or nonpublic personal information. + + intellectual_property: Responses that may violate the intellectual property rights of any third party. + + indiscriminate_weapons: Responses that enable, encourage, or endorse the creation of indiscriminate weapons. + + hate: Responses that demean or dehumanize people on the basis of their sensitive, personal characteristics. + + self_harm: Responses that enable, encourage, or endorse acts of intentional self-harm. + + sexual_content: Responses that contain erotica. + + elections: Responses that contain factually incorrect information about electoral systems and processes. + + code_interpreter_abuse: Responses that seek to abuse code interpreters. + """ + + violent_crimes: bool = Field(default=False) + non_violent_crimes: bool = Field(default=False) + sex_crimes: bool = Field(default=False) + child_exploitation: bool = Field(default=False) + defamation: bool = Field(default=False) + specialized_advice: bool = Field(default=False) + privacy: bool = Field(default=False) + intellectual_property: bool = Field(default=False) + indiscriminate_weapons: bool = Field(default=False) + hate: bool = Field(default=False) + self_harm: bool = Field(default=False) + sexual_content: bool = Field(default=False) + elections: bool = Field(default=False) + code_interpreter_abuse: bool = Field(default=False) + + +class LlamaGuard38bFilterConfig(BaseModel): + type_: Literal["llama_guard_3_8b"] = Field(default="llama_guard_3_8b", alias="type") + config: LlamaGuard38bFilter + + +class AzureContentSafetyInputFilterConfig(BaseModel): + type_: Literal["azure_content_safety"] = Field( + default="azure_content_safety", alias="type" + ) + config: Optional[AzureContentSafetyInput] = None + + +class AzureContentSafetyOutputFilterConfig(BaseModel): + type_: Literal["azure_content_safety"] = Field( + default="azure_content_safety", alias="type" + ) + config: Optional[AzureContentSafetyOutput] = None + + +class FilteringStreamOptions(BaseModel): + """ + overlap: Number of characters that should be additionally sent to content filtering services + from previous chunks as additional context. + """ + + overlap: Optional[int] = Field(default=0, ge=0, le=10000) + + +class InputFiltering(BaseModel): + """Module for managing and applying input content filters. + + Args: + filters: List of ContentFilter objects to be applied to input content. + """ + + filters: list[ + Union[AzureContentSafetyInputFilterConfig, LlamaGuard38bFilterConfig] + ] = Field(min_length=1) + + +class OutputFiltering(BaseModel): + """Module for managing and applying output content filters. + + Args: + filters: List of ContentFilter objects to be applied to output content. + + stream_options: Module-specific streaming options. + """ + + filters: list[ + Union[AzureContentSafetyOutputFilterConfig, LlamaGuard38bFilterConfig] + ] = Field(min_length=1) + stream_options: Optional[FilteringStreamOptions] = None + + +class FilteringModuleConfig(BaseModel): + """Module for managing and applying content filters. + + Args: + input: Module for filtering and validating input content before processing. + + output: Module for filtering and validating output content after generation. + """ + + input: Optional[InputFiltering] = None + output: Optional[OutputFiltering] = None + + @model_validator(mode="after") + def enforce_min_properties(self) -> "FilteringModuleConfig": + """ + Ensure at least one of input or output filtering is provided. + """ + if self.input is None and self.output is None: + raise ValueError( + "For using SAP Filtering Module you must provide at least one property: input or output filters." + ) + return self + + +class SAPDocumentTranslationApplyToSelector(BaseModel): + """ + This selector allows you to define the scope of translation, such as specific placeholders or + messages with specific roles. + For example, {"category": "placeholders", + "items": ["user_input"], + "source_language": "de-DE"} + targets the value of "user_input" in placeholder_values specified in the request payload; + and considers the value to be in German. + """ + + category: Literal["placeholders", "template_roles"] + items: list[str] + source_language: str + + +class InputTranslationConfig(BaseModel): + """ + Configuration for input translation. + + Args: + source_language: Language of the text to be translated. Example: de-DE + target_language: Language to which the text should be translated. Example: en-US + apply_to: List of selectors that define the scope of translation. + """ + + source_language: Optional[str] = None + target_language: str + apply_to: Optional[list[SAPDocumentTranslationApplyToSelector]] = None + + +class OutputTranslationConfig(BaseModel): + source_language: Optional[str] = None + target_language: Union[str, SAPDocumentTranslationApplyToSelector] + + +class SAPDocumentTranslationInput(BaseModel): + """ + Configuration for input translation + + Args: + type: The type of translation module (e.g., 'sap_document_translation'). + + translate_messages_history: If true, the messages history will be translated as well. + + config: Configuration object for the translation module. + """ + + type_: Literal["sap_document_translation"] = Field( + default="sap_document_translation", alias="type" + ) + translate_messages_history: Optional[bool] = None + config: InputTranslationConfig + + +class SAPDocumentTranslationOutput(BaseModel): + """ + Configuration for output translation + + Args: + type: The type of translation module (e.g., 'sap_document_translation'). + + config: Configuration object for the translation module. + """ + + type_: Literal["sap_document_translation"] = Field( + default="sap_document_translation", alias="type" + ) + config: OutputTranslationConfig + + +class TranslationModuleConfig(BaseModel): + """ + Configuration for translation module + + Args: + input: Configuration for input translation + + output: Configuration for output translation + """ + + input: Optional[SAPDocumentTranslationInput] = None + output: Optional[SAPDocumentTranslationOutput] = None + + @model_validator(mode="after") + def enforce_min_properties(self) -> "TranslationModuleConfig": + if self.input is None and self.output is None: + raise ValueError( + "TranslationModuleConfig requires at least one of 'input' or 'output'." + ) + return self + + +class ModuleConfig(BaseModel): + prompt_templating: PromptTemplatingModuleConfig + filtering: Optional[FilteringModuleConfig] = None + masking: Optional[MaskingModuleConfig] = None + grounding: Optional[GroundingModuleConfig] = None + translation: Optional[TranslationModuleConfig] = None + + +class GlobalStreamOptions(BaseModel): + enabled: bool = False + chunk_size: Optional[int] = Field(default=None, ge=1) + delimiters: Optional[list[str]] = None + + +class OrchestrationConfig(BaseModel): + modules: Union[ModuleConfig, list[ModuleConfig]] + stream: Optional[GlobalStreamOptions] = None + + +class OrchestrationRequest(BaseModel): + config: OrchestrationConfig + placeholder_values: Optional[dict[str, str]] = None diff --git a/litellm/llms/sap/chat/transformation.py b/litellm/llms/sap/chat/transformation.py index 7f6bab4a1d5..a55ec746350 100755 --- a/litellm/llms/sap/chat/transformation.py +++ b/litellm/llms/sap/chat/transformation.py @@ -11,6 +11,7 @@ from typing import ( TYPE_CHECKING, Iterator, AsyncIterator, + FrozenSet, ) from functools import cached_property import litellm @@ -31,12 +32,13 @@ else: from ..credentials import get_token_creator from .models import ( - SAPMessage, - SAPAssistantMessage, - SAPToolChatMessage, ChatCompletionTool, - ResponseFormatJSONSchema, + OrchestrationRequest, ResponseFormat, + ResponseFormatJSONSchema, + SAPAssistantMessage, + SAPMessage, + SAPToolChatMessage, SAPUserMessage, ) from .handler import ( @@ -45,9 +47,65 @@ from .handler import ( SAPStreamIterator, ) +# Keys routed outside SAP orchestration `model.params` (prompt, stream, fallbacks, etc.) +_SAP_MODEL_PARAMS_EXCLUDED_KEYS: FrozenSet[str] = frozenset( + { + "tools", + "tool_choice", + "stream_options", + "fallback_sap_modules", + "placeholder_values", + "model_version", + } +) + def validate_dict(data: dict, model) -> dict: - return model(**data).model_dump(by_alias=True) + return model(**data).model_dump(by_alias=True, exclude_unset=True) + + +def _messages_to_sap_template(messages: List[Dict[str, str]]) -> list: # type: ignore[type-arg] + template = [] + for message in messages: + if message["role"] == "user": + template.append(validate_dict(message, SAPUserMessage)) + elif message["role"] == "assistant": + template.append(validate_dict(message, SAPAssistantMessage)) + elif message["role"] == "tool": + template.append(validate_dict(message, SAPToolChatMessage)) + else: + template.append(validate_dict(message, SAPMessage)) + return template + + +def _tools_response_format_and_stream( + optional_params: dict, model_params: dict +) -> Tuple[dict, dict, dict]: + tools_ = optional_params.pop("tools", []) + tools_ = [validate_dict(tool, ChatCompletionTool) for tool in tools_] + tools: dict = {"tools": tools_} if tools_ else {} + + response_format = model_params.pop("response_format", {}) + resp_type = response_format.get("type", None) + if resp_type: + if resp_type == "json_schema": + response_format = validate_dict( + response_format, ResponseFormatJSONSchema + ) + else: + response_format = validate_dict(response_format, ResponseFormat) + response_format = {"response_format": response_format} + + model_params.pop("stream", False) + stream_config: dict = {} + if "stream_options" in optional_params: + stream_options = optional_params.pop("stream_options", {}) + if "chunk_size" in stream_options: + stream_config["chunk_size"] = stream_options.get("chunk_size") + if "delimiters" in stream_options: + stream_config["delimiters"] = stream_options.get("delimiters") + + return tools, response_format, stream_config class GenAIHubOrchestrationConfig(OpenAIGPTConfig): @@ -208,48 +266,25 @@ class GenAIHubOrchestrationConfig(OpenAIGPTConfig): api_base_ = f"{self.deployment_url}/v2/completion" return api_base_ - def transform_request( + def _build_prompt_module( self, - model: str, - messages: List[Dict[str, str]], # type: ignore - optional_params: dict, - litellm_params: dict, - headers: dict, + model_name: str, + template_messages: List[Dict[str, str]], + params: dict, ) -> dict: - # Filter out parameters that are not valid model params for SAP Orchestration API - # - tools, model_version, deployment_url: handled separately - excluded_params = {"tools", "model_version", "deployment_url"} - # Filter strict for GPT models only - SAP AI Core doesn't accept it as a model param # LangChain agents pass strict=true at top level, which fails for GPT models # Anthropic models accept strict, so preserve it for them - if model.startswith("gpt"): - excluded_params.add("strict") + if model_name.startswith("gpt") and "strict" in params: + params.pop("strict") - model_params = { - k: v for k, v in optional_params.items() if k not in excluded_params - } + model_version = params.pop("model_version", "latest") - model_version = optional_params.pop("model_version", "latest") - template = [] - for message in messages: - if message["role"] == "user": - template.append(validate_dict(message, SAPUserMessage)) - elif message["role"] == "assistant": - template.append(validate_dict(message, SAPAssistantMessage)) - elif message["role"] == "tool": - template.append(validate_dict(message, SAPToolChatMessage)) - else: - template.append(validate_dict(message, SAPMessage)) - - tools_ = optional_params.pop("tools", []) + tools_ = params.pop("tools", []) tools_ = [validate_dict(tool, ChatCompletionTool) for tool in tools_] - if tools_ != []: - tools = {"tools": tools_} - else: - tools = {} + tools = {"tools": tools_} if tools_ else {} - response_format = model_params.pop("response_format", {}) + response_format = params.pop("response_format", {}) resp_type = response_format.get("type", None) if resp_type: if resp_type == "json_schema": @@ -259,33 +294,104 @@ class GenAIHubOrchestrationConfig(OpenAIGPTConfig): else: response_format = validate_dict(response_format, ResponseFormat) response_format = {"response_format": response_format} - model_params.pop("stream", False) - stream_config = {} - if "stream_options" in model_params: - # stream_config["enabled"] = True - stream_options = model_params.pop("stream_options", {}) - stream_config["chunk_size"] = stream_options.get("chunk_size", 100) - if "delimiters" in stream_options: - stream_config["delimiters"] = stream_options.get("delimiters") - # else: - # stream_config["enabled"] = False - config = { - "config": { - "modules": { - "prompt_templating": { - "prompt": {"template": template, **tools, **response_format}, - "model": { - "name": model, - "params": model_params, - "version": model_version, - }, - }, + else: + response_format = {} + + placeholder_defaults = params.pop("placeholder_defaults", {}) + placeholder_defaults = ( + {"defaults": placeholder_defaults} if placeholder_defaults else {} + ) + + optional_modules = {} + optional_modules_lst = ["grounding", "masking", "filtering", "translation"] + for module in optional_modules_lst: + if params.get(module, None) is not None: + optional_modules[module] = params.pop(module) + + return { + "prompt_templating": { + "prompt": { + "template": template_messages, + **placeholder_defaults, + **tools, + **response_format, }, - "stream": stream_config, - } + "model": { + "name": model_name, + "params": params, + "version": model_version, + }, + }, + **optional_modules, } - return config + def transform_request( + self, + model: str, + messages: List[Dict[str, str]], # type: ignore + optional_params: dict, + litellm_params: dict, + headers: dict, + ) -> dict: + optional_params = dict(optional_params) + optional_params.pop("deployment_url", None) + + template = _messages_to_sap_template(messages) + + placeholder_values = optional_params.pop("placeholder_values", None) + fallback_modules = optional_params.pop("fallback_sap_modules", []) + + optional_params.pop("stream", None) + stream_config: dict = {} + if "stream_options" in optional_params: + stream_options = optional_params.pop("stream_options", {}) + if "chunk_size" in stream_options: + stream_config["chunk_size"] = stream_options["chunk_size"] + if "delimiters" in stream_options: + stream_config["delimiters"] = stream_options["delimiters"] + + optional_params.pop("tool_choice", None) + + modules = [ + self._build_prompt_module( + model_name=model, + template_messages=template, + params=dict(optional_params), + ) + ] + + for modules_dict in fallback_modules: + modules_dict = dict(modules_dict) + fallback_model = modules_dict.pop("model", None) + if fallback_model is None: + raise ValueError( + "Each entry in `fallback_sap_modules` must include a 'model' key." + ) + if fallback_model.startswith("sap/"): + fallback_model = fallback_model[4:] + fallback_template = modules_dict.pop("messages", []) + + modules.append( + self._build_prompt_module( + model_name=fallback_model, + template_messages=fallback_template, + params=modules_dict, + ) + ) + + config_payload: Dict[str, Any] = { + "modules": modules if len(modules) > 1 else modules[0], + } + if stream_config: + config_payload["stream"] = stream_config + + request_body: Dict[str, Any] = {"config": config_payload} + if placeholder_values is not None: + request_body["placeholder_values"] = placeholder_values + + body = validate_dict(request_body, OrchestrationRequest) + + return body def transform_response( self, diff --git a/litellm/llms/sap/credentials.py b/litellm/llms/sap/credentials.py index aeae51bf0bb..0ae351783e8 100644 --- a/litellm/llms/sap/credentials.py +++ b/litellm/llms/sap/credentials.py @@ -1,5 +1,5 @@ from __future__ import annotations -from typing import Any, Callable, Dict, Final, List, Optional, Sequence, Tuple +from typing import Any, Callable, Dict, Final, List, Optional, Sequence, Tuple, Union from datetime import datetime, timedelta, timezone from threading import Lock from pathlib import Path @@ -7,9 +7,11 @@ from dataclasses import dataclass import json import os import tempfile +import httpx -from litellm import sap_service_key -from litellm.llms.custom_httpx.http_handler import _get_httpx_client +from litellm.llms.custom_httpx.http_handler import _get_httpx_client, HTTPHandler +from litellm._logging import verbose_logger +import litellm AUTH_ENDPOINT_SUFFIX = "/oauth/token" @@ -28,11 +30,25 @@ def _get_home() -> str: return os.getenv(HOME_PATH_ENV_VAR, DEFAULT_HOME_PATH) -def _get_nested(d: Dict[str, Any], path: Sequence[str]) -> Any: +def _get_nested(d: Union[Dict[str, Any], str], path: Sequence[str]) -> Any: cur: Any = d + if isinstance(cur, str): + # This shouldn't happen if service keys are pre-parsed correctly + try: + cur = json.loads(cur) + except json.JSONDecodeError: + verbose_logger.warning( + "SAP service key or VCAP service is a string but not valid JSON." + ) + return None for k in path: - if not isinstance(cur, dict) or k not in cur: - raise KeyError(".".join(path)) + if not isinstance(cur, dict): + verbose_logger.warning( + f"SAP service key or VCAP service traversal hit non-dict type '{type(cur).__name__}' at key '{k}'." + ) + return None + if k not in cur: + return None cur = cur[k] return cur @@ -47,6 +63,13 @@ def _load_json_env(var_name: str) -> Optional[Dict[str, Any]]: return None +def _str_or_none(value) -> Optional[str]: + try: + return str(value) if value is not None else None + except Exception: + return None + + def _load_vcap() -> Dict[str, Any]: return _load_json_env(VCAP_SERVICES_ENV_VAR) or {} @@ -59,6 +82,12 @@ def _get_vcap_service(label: str) -> Optional[Dict[str, Any]]: return None +@dataclass +class Source: + name: str + get: Callable[[CredentialsValue], Optional[str]] + + @dataclass(frozen=True) class CredentialsValue: name: str @@ -82,7 +111,6 @@ CREDENTIAL_VALUES: Final[List[CredentialsValue]] = [ transform_fn=lambda url: url.rstrip("/") + ("" if url.endswith("/v2") else "/v2"), ), - CredentialsValue("resource_group", default="default"), CredentialsValue( "cert_url", ("certurl",), @@ -145,81 +173,239 @@ def _env_name(name: str) -> str: return f"AICORE_{name.upper()}" -def _resolve_value( - cred: CredentialsValue, - *, - kwargs: Dict[str, Any], - env: Dict[str, str], - config: Dict[str, Any], - service_like: Optional[Dict[str, Any]], -) -> Optional[str]: - # 1) explicit kwargs - if cred.name in kwargs and kwargs[cred.name] is not None: - return kwargs[cred.name] +def extract_credentials(source: Source) -> Dict[str, str]: + """Extract all credentials from a source.""" + credentials = {} + for cv in CREDENTIAL_VALUES: + value = source.get(cv) + if value is not None: + credentials[cv.name] = cv.transform_fn(value) if cv.transform_fn else value + return credentials - # 2) environment variables (primary name) - env_key = _env_name(cred.name) - if env_key in env and env[env_key] is not None: - return env[env_key] - # 3) config file (accept both prefixed and plain keys) - for key in (env_key, cred.name): - if key in config and config[key] is not None: - return config[key] +def resolve_credentials(sources: List[Source]) -> Dict[str, str]: + """Extract credentials from the first source that has any defined.""" + for source in sources: + credentials = extract_credentials(source) + if credentials: + verbose_logger.debug(f"Resolved SAP credentials from source {source.name}") + return credentials + raise ValueError("No credentials found in any source") - # 4) service-like source (AICORE_SERVICE_KEY first, else VCAP) - if service_like and cred.vcap_key: + +def resolve_resource_group(sources: List[Source]) -> Optional[str]: + """Find resource_group from the first source that defines it.""" + rg_cred = CredentialsValue("resource_group", default="default") + for source in sources: + value = source.get(rg_cred) + if value is not None: + verbose_logger.debug( + f"Resolved GEN AI Hub resource_group from source {source.name}" + ) + return value + return rg_cred.default + + +def _parse_service_key_once( + service_key: Optional[Union[str, dict]] +) -> Optional[Dict[str, Any]]: + """ + Pre-parse service_key if it's a string to avoid repeated JSON parsing. + + Returns None if parsing fails (other credential sources may still work). + """ + if service_key is None: + return None + if isinstance(service_key, dict): + return service_key + if isinstance(service_key, str): try: - val = _get_nested(service_like, ("credentials",) + cred.vcap_key) - if val is not None: - return val - except KeyError: - pass + return json.loads(service_key) + except json.JSONDecodeError: + verbose_logger.warning( + "SAP service key is a string but not valid JSON. Skipping this source." + ) + return None + verbose_logger.warning( + f"SAP service key has unexpected type '{type(service_key).__name__}'. Expected str or dict. Ignoring." + ) + return None - # 5) default - return cred.default + +def _resolve_credential_from_service_key( + service_key: Optional[Union[str, dict]], cv: CredentialsValue +) -> Optional[str]: + if service_key is None: + return None + val = _str_or_none( + _get_nested( + service_key, (("credentials",) + cv.vcap_key) if cv.vcap_key else (cv.name,) + ) + ) + if val is None: + return _str_or_none( + _get_nested(service_key, cv.vcap_key if cv.vcap_key else (cv.name,)) + ) + return val def fetch_credentials( - service_key: Optional[str] = None, profile: Optional[str] = None, **kwargs + service_key: Optional[Union[str, dict]] = None, + profile: Optional[str] = None, + **kwargs, ) -> Dict[str, str]: """ - Resolution order per key: + Resolution order (first-source-wins): + + Sources are checked in this order: kwargs + > service key > env (AICORE_) > config (AICORE_ or plain ) - > service-like source from JSON in $AICORE_SERVICE_KEY (same structure as a VCAP service object) - falling back to service entry in $VCAP_SERVICES with label 'aicore' + > vcap service key > default + + Important: + - Credentials are extracted from the FIRST source that provides any credential value. + - Values are NOT merged per key across sources. Except resource_group, which is merged. + + Warning: + - This function does NOT validate the returned credentials just parsed it from the sources. + - Callers MUST explicitly call validate_credentials() on the returned dict """ config = init_conf(profile) - env = os.environ # snapshot for testability - service_like = None - if not config: - # Prefer AICORE_SERVICE_KEY if present; otherwise fall back to the VCAP service. - service_like = ( - service_key - or sap_service_key - or _load_json_env(SERVICE_KEY_ENV_VAR) - or _get_vcap_service(VCAP_AICORE_SERVICE_NAME) + service_key = _parse_service_key_once( + service_key or litellm.sap_service_key or os.environ.get(SERVICE_KEY_ENV_VAR) + ) + vcap_service = _get_vcap_service(VCAP_AICORE_SERVICE_NAME) + + sources = [ + Source("kwargs", lambda cv: _str_or_none(kwargs.get(cv.name))), + Source( + "service key", + lambda cv: _resolve_credential_from_service_key(service_key, cv), + ), + Source( + "environment variables", + lambda cv: _str_or_none(os.environ.get(f"AICORE_{cv.name.upper()}")), + ), + Source( + "config file", + lambda cv: _str_or_none( + config.get(f"AICORE_{cv.name.upper()}") + if config.get(f"AICORE_{cv.name.upper()}") is not None + else config.get(cv.name) + ), + ), + Source( + "VCAP service", + lambda cv: ( + _str_or_none( + _get_nested( + vcap_service, + (("credentials",) + cv.vcap_key) if cv.vcap_key else (cv.name,), + ) + ) + if vcap_service + else None + ), + ), # type: ignore[arg-type] + ] + + credentials = resolve_credentials(sources) + + resource_group = resolve_resource_group(sources) + if resource_group is not None: + credentials["resource_group"] = resource_group + + if "cert_url" in credentials: + credentials["auth_url"] = credentials.pop("cert_url") + return credentials + + +def validate_credentials( + auth_url: Optional[str] = None, + base_url: Optional[str] = None, + client_id: Optional[str] = None, + client_secret: Optional[str] = None, + cert_str: Optional[str] = None, + key_str: Optional[str] = None, + cert_file_path: Optional[str] = None, + key_file_path: Optional[str] = None, +) -> None: + """ + Validate SAP AI Core credentials for completeness and consistency. + + Args: + auth_url: OAuth2 token endpoint URL (required) + base_url: SAP AI Core API base URL (required) + client_id: OAuth2 client ID (required) + client_secret: OAuth2 client secret (for secret-based auth) + cert_str: PEM-encoded certificate string (for cert-based auth) + key_str: PEM-encoded private key string (for cert-based auth) + cert_file_path: Path to certificate file (for file-based cert auth) + key_file_path: Path to private key file (for file-based cert auth) + + Raises: + ValueError: If required fields are missing or authentication mode is ambiguous. + + Note: + - This function does NOT validate resource_group (resolved separately). + - Exactly one authentication method must be provided: + * client_secret, OR + * (cert_str AND key_str), OR + * (cert_file_path AND key_file_path) + """ + if not auth_url or not client_id or not base_url: + raise ValueError( + "SAP AI Core credentials not found. " + "Please provide credentials by setting appropriate environment variables " + "(e.g. AICORE_CLIENT_ID, AICORE_CLIENT_SECRET, etc.)" ) - out: Dict[str, str] = {} - for cred in CREDENTIAL_VALUES: - value = _resolve_value(cred, kwargs=kwargs, env=env, config=config, service_like=service_like) # type: ignore - if value is None: - continue - if cred.transform_fn: - value = cred.transform_fn(value) - out[cred.name] = value - if "cert_url" in out.keys(): - out["auth_url"] = out.pop("cert_url") - return out + modes = [ + bool(client_secret), + bool(cert_str) and bool(key_str), + bool(cert_file_path) and bool(key_file_path), + ] + if sum(bool(m) for m in modes) != 1: + raise ValueError( + "SAP AI Core credentials are incomplete. " + "Invalid credentials: provide exactly one of client_secret, " + "(cert_str & key_str), or (cert_file_path & key_file_path)." + ) + + +def _request_token( + client_id: str, auth_url: str, timeout: float, cert_pair=None, client_secret=None +) -> tuple[str, datetime]: + data = {"grant_type": "client_credentials", "client_id": client_id} + if client_secret: + data["client_secret"] = client_secret + + resp: Optional[httpx.Response] = None + try: + if cert_pair: + with httpx.Client(cert=cert_pair) as raw_client: + handler = HTTPHandler(client=raw_client) + resp = handler.post(auth_url, data=data, timeout=timeout) # type: ignore[arg-type] + payload = resp.json() + else: + handler = _get_httpx_client() + resp = handler.post(auth_url, data=data, timeout=timeout) # type: ignore[arg-type] + payload = resp.json() + access_token = payload["access_token"] + expires_in = int(payload.get("expires_in", 3600)) + expiry_date = datetime.now(timezone.utc) + timedelta(seconds=expires_in) + return f"Bearer {access_token}", expiry_date + except Exception as e: + msg = resp.text if resp is not None else getattr(e, "text", str(e)) + raise RuntimeError(f"Token request failed: {msg}") from e def get_token_creator( - service_key: Optional[str] = None, + service_key: Optional[Union[str, dict]] = None, profile: Optional[str] = None, *, timeout: float = 30.0, @@ -237,7 +423,7 @@ def get_token_creator( Args: profile: Optional AICore profile name - timeout: HTTP request timeout in seconds (default 30s) + timeout: Timeout for HTTP requests expiry_buffer_minutes: Refresh the token this many minutes before expiry overrides: Any explicit credential overrides (client_id, client_secret, etc.) @@ -251,6 +437,7 @@ def get_token_creator( ) auth_url = credentials.get("auth_url") + base_url = credentials.get("base_url") client_id = credentials.get("client_id") client_secret = credentials.get("client_secret") cert_str = credentials.get("cert_str") @@ -259,49 +446,30 @@ def get_token_creator( key_file_path = credentials.get("key_file_path") # Sanity check - if not auth_url or not client_id: - raise ValueError( - "fetch_credentials did not return valid 'auth_url' or 'client_id'" - ) - - modes = [ - client_secret is not None, - (cert_str is not None and key_str is not None), - (cert_file_path is not None and key_file_path is not None), - ] - if sum(bool(m) for m in modes) != 1: - raise ValueError( - "Invalid credentials: provide exactly one of client_secret, " - "(cert_str & key_str), or (cert_file_path & key_file_path)." - ) + validate_credentials( + auth_url, + base_url, + client_id, + client_secret, + cert_str, + key_str, + cert_file_path, + key_file_path, + ) lock = Lock() token: Optional[str] = None token_expiry: Optional[datetime] = None - def _request_token(cert_pair=None) -> tuple[str, datetime]: - data = {"grant_type": "client_credentials", "client_id": client_id} - if client_secret: - data["client_secret"] = client_secret - - client = _get_httpx_client() - # with httpx.Client(cert=cert_pair, timeout=timeout) as client: - resp = client.post(auth_url, data=data) - try: - resp.raise_for_status() - payload = resp.json() - access_token = payload["access_token"] - expires_in = int(payload.get("expires_in", 3600)) - expiry_date = datetime.now(timezone.utc) + timedelta(seconds=expires_in) - return f"Bearer {access_token}", expiry_date - except Exception as e: - msg = getattr(resp, "text", str(e)) - raise RuntimeError(f"Token request failed: {msg}") from e - def _fetch_token() -> tuple[str, datetime]: # Case 1: secret-based auth if client_secret: - return _request_token() + return _request_token( + auth_url=auth_url, # type: ignore[arg-type] + client_id=client_id, # type: ignore[arg-type] + timeout=timeout, + client_secret=client_secret, + ) # Case 2: cert/key strings if cert_str and key_str: cert_str_fixed = cert_str.replace("\\n", "\n") @@ -313,9 +481,24 @@ def get_token_creator( f.write(cert_str_fixed) with open(key_path, "w") as f: f.write(key_str_fixed) - return _request_token(cert_pair=(cert_path, key_path)) + return _request_token( + auth_url=auth_url, # type: ignore[arg-type] + client_id=client_id, # type: ignore[arg-type] + timeout=timeout, + cert_pair=(cert_path, key_path), + ) # Case 3: file-based cert/key - return _request_token(cert_pair=(cert_file_path, key_file_path)) + if cert_file_path is not None and key_file_path is not None: + return _request_token( + auth_url=auth_url, # type: ignore[arg-type] + client_id=client_id, # type: ignore[arg-type] + timeout=timeout, + cert_pair=(cert_file_path, key_file_path), + ) + # Defensive guard: should never reach here due to validate_credentials() + raise ValueError( + "Invalid authentication configuration: no valid credentials found. " + ) def get_token() -> str: nonlocal token, token_expiry diff --git a/litellm/llms/sap/embed/transformation.py b/litellm/llms/sap/embed/transformation.py index 0bbf4f259f7..c74f21c3685 100644 --- a/litellm/llms/sap/embed/transformation.py +++ b/litellm/llms/sap/embed/transformation.py @@ -5,6 +5,7 @@ Translates from OpenAI's `/v1/embeddings` to IBM's `/text/embeddings` route. from typing import Optional, List, Dict, Literal, Union from pydantic import BaseModel, Field from functools import cached_property +from litellm.llms.sap.chat.models import MaskingModuleConfig import httpx @@ -47,25 +48,36 @@ class EmbeddingsResponse(BaseModel): class EmbeddingModel(BaseModel): name: str version: str = "latest" - params: dict = Field(default_factory=dict, validation_alias="parameters") + params: dict = Field(default_factory=dict) + timeout: Optional[int] = Field(default=None, ge=1, le=600) + max_retries: Optional[int] = Field(default=None, ge=0, le=5) + + +class EmbeddingsModelConfig(BaseModel): + model: EmbeddingModel class EmbeddingsModules(BaseModel): - embeddings: EmbeddingModel + embeddings: EmbeddingsModelConfig + masking: Optional[MaskingModuleConfig] = None class EmbeddingInput(BaseModel): text: Union[str, List[str]] - type: Literal["text", "document", "query"] = "text" + type: Optional[Literal["text", "document", "query"]] = None + + +class EmbeddingConfig(BaseModel): + modules: EmbeddingsModules class EmbeddingRequest(BaseModel): - config: EmbeddingsModules + config: EmbeddingConfig input: EmbeddingInput def validate_dict(data: dict, model) -> dict: - return model(**data).model_dump() + return model(**data).model_dump(exclude_unset=True, by_alias=True) class GenAIHubEmbeddingConfig(BaseEmbeddingConfig): @@ -152,15 +164,23 @@ class GenAIHubEmbeddingConfig(BaseEmbeddingConfig): model_dict["name"] = model model_dict["version"] = optional_params.get("version", "latest") model_dict["params"] = optional_params.get("parameters", {}) + timeout = optional_params.get("timeout", None) + if timeout is not None: + model_dict["timeout"] = timeout + max_retries = optional_params.get("max_retries", None) + if max_retries is not None: + model_dict["max_retries"] = max_retries input_dict = {"text": input} + input_type = optional_params.get("type") + if input_type is not None: + input_dict["type"] = input_type + masking = optional_params.get("masking") + masking = {"masking": masking} if masking is not None else {} body = { - "config": { - "modules": { - "embeddings": {"model": validate_dict(model_dict, EmbeddingModel)} - } - }, - "input": validate_dict(input_dict, EmbeddingInput), + "config": {"modules": {"embeddings": {"model": model_dict}, **masking}}, + "input": input_dict, } + body = validate_dict(body, EmbeddingRequest) return body def transform_embedding_response( diff --git a/litellm/llms/vertex_ai/gemini/transformation.py b/litellm/llms/vertex_ai/gemini/transformation.py index 7945c44d44c..6157a384dc0 100644 --- a/litellm/llms/vertex_ai/gemini/transformation.py +++ b/litellm/llms/vertex_ai/gemini/transformation.py @@ -763,6 +763,16 @@ def _transform_request_body( # noqa: PLR0915 data["generationConfig"] = generation_config if cached_content is not None: data["cachedContent"] = cached_content + + if service_tier := optional_params.pop("service_tier", None): + if isinstance(service_tier, str): + if service_tier.lower() == "default": + data["serviceTier"] = "standard" + else: + data["serviceTier"] = service_tier.lower() + else: + data["serviceTier"] = service_tier + # Only add labels for Vertex AI endpoints (not Google GenAI/AI Studio) and only if non-empty if labels and custom_llm_provider != LlmProviders.GEMINI: data["labels"] = labels diff --git a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py index 36f51c5b2f5..e6e548ab98a 100644 --- a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py +++ b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py @@ -318,6 +318,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): "parallel_tool_calls", "web_search_options", "include_server_side_tool_invocations", + "service_tier", ] # Add penalty parameters only for non-preview models @@ -362,6 +363,17 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): """ return Tools(googleSearch={}) + def _map_service_tier_param(self, value: str, optional_params: dict) -> None: + """ + Map OpenAI service_tier (string) to Gemini serviceTier. + 'auto' maps to 'priority'. + Other values are passed lowercased. + """ + if value.lower() == "auto": + optional_params["service_tier"] = "priority" + else: + optional_params["service_tier"] = value.lower() + def _transform_computer_use_config(self, computer_use_config: dict) -> dict: """ Transform Computer Use configuration to Gemini API format. @@ -1121,6 +1133,8 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): optional_params = self._add_tools_to_optional_params( optional_params, [_tools] ) + elif param == "service_tier" and isinstance(value, str): + self._map_service_tier_param(value, optional_params) elif param == "include_server_side_tool_invocations" and value is True: optional_params["include_server_side_tool_invocations"] = True if litellm.vertex_ai_safety_settings is not None: @@ -2415,6 +2429,14 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): "provider_specific_fields", {} )["traffic_type"] = traffic_type + ## ADD SERVICE TIER ## + if getattr(raw_response, "headers", None): + if service_tier := raw_response.headers.get("x-gemini-service-tier"): + if service_tier.lower() == "standard": + setattr(model_response, "service_tier", "default") + else: + setattr(model_response, "service_tier", service_tier.lower()) + except Exception as e: raise VertexAIError( message="Received={}, Error converting to valid response block={}. File an issue if litellm error - https://github.com/BerriAI/litellm/issues".format( @@ -2513,6 +2535,7 @@ async def make_call( streaming_response=response.aiter_lines(), sync_stream=False, logging_obj=logging_obj, + response_headers=response.headers, ) # LOGGING logging_obj.post_call( @@ -2555,6 +2578,7 @@ def make_sync_call( streaming_response=response.iter_lines(), sync_stream=True, logging_obj=logging_obj, + response_headers=response.headers, ) # LOGGING @@ -3011,7 +3035,11 @@ class VertexLLM(VertexBase): class ModelResponseIterator: def __init__( - self, streaming_response, sync_stream: bool, logging_obj: LoggingClass + self, + streaming_response, + sync_stream: bool, + logging_obj: LoggingClass, + response_headers: Optional[Dict[str, str]] = None, ): from litellm.litellm_core_utils.prompt_templates.common_utils import ( check_is_function_call, @@ -3022,10 +3050,120 @@ class ModelResponseIterator: self.accumulated_json = "" self.sent_first_chunk = False self.logging_obj = logging_obj + self.response_headers = response_headers or {} self.is_function_call = check_is_function_call(logging_obj) self.cumulative_tool_call_index: int = 0 self.has_seen_tool_calls: bool = False + def _apply_stream_candidates( + self, + _candidates: List[Candidates], + model_response: Any, + ) -> Tuple[List[dict], List[dict], List[dict], List[dict]]: + ( + grounding_metadata, + url_context_metadata, + safety_ratings, + citation_metadata, + self.cumulative_tool_call_index, + ) = VertexGeminiConfig._process_candidates( + _candidates, + model_response, + self.logging_obj.optional_params, + cumulative_tool_call_index=self.cumulative_tool_call_index, + ) + + # Track whether tool_calls have been seen across streaming chunks. + # Gemini sends tool_calls and finishReason in separate chunks, + # so we need to remember if earlier chunks contained tool_calls + # to correctly set finish_reason="tool_calls" per the OpenAI spec. + if not self.has_seen_tool_calls: + for choice in model_response.choices: + if ( + hasattr(choice, "delta") + and choice.delta + and choice.delta.tool_calls + ): + self.has_seen_tool_calls = True + break + + # Handle final chunk with finishReason but no content. + # _process_candidates skips candidates without "content", + # so the finish_reason from the final chunk is lost. + if not model_response.choices and _candidates: + from litellm.types.utils import Delta, StreamingChoices + + for candidate in _candidates: + finish_reason_str = candidate.get("finishReason") + if finish_reason_str is not None: + if self.has_seen_tool_calls: + mapped_finish_reason = "tool_calls" + else: + mapped_finish_reason = VertexGeminiConfig._check_finish_reason( + None, finish_reason_str + ) + choice = StreamingChoices( + finish_reason=mapped_finish_reason, + index=candidate.get("index", 0), + delta=Delta(content=None, role=None), + logprobs=None, + enhancements=None, + ) + model_response.choices.append(choice) + + # Also handle the case where the final chunk has empty + # content (e.g. text:"") WITH finishReason. In this case + # _process_candidates DOES create a choice, but maps + # finishReason="STOP" to "stop" because the current chunk + # has no tool_calls. Override if we saw tool_calls earlier. + if self.has_seen_tool_calls: + for choice in model_response.choices: + if choice.finish_reason == "stop": + choice.finish_reason = "tool_calls" + + setattr(model_response, "vertex_ai_grounding_metadata", grounding_metadata) # type: ignore + setattr(model_response, "vertex_ai_url_context_metadata", url_context_metadata) # type: ignore + setattr(model_response, "vertex_ai_safety_ratings", safety_ratings) # type: ignore + setattr(model_response, "vertex_ai_citation_metadata", citation_metadata) # type: ignore + + return grounding_metadata, url_context_metadata, safety_ratings, citation_metadata + + def _apply_stream_usage_metadata( + self, + processed_chunk: Any, + model_response: Any, + grounding_metadata: List[dict], + ) -> Optional[Usage]: + if "usageMetadata" not in processed_chunk: + return None + + usage = VertexGeminiConfig._calculate_usage( + completion_response=processed_chunk, + ) + + web_search_requests = VertexGeminiConfig._calculate_web_search_requests( + grounding_metadata + ) + if web_search_requests is not None: + cast( + PromptTokensDetailsWrapper, usage.prompt_tokens_details + ).web_search_requests = web_search_requests + + traffic_type = processed_chunk.get("usageMetadata", {}).get("trafficType") + if traffic_type: + model_response._hidden_params.setdefault( + "provider_specific_fields", {} + )["traffic_type"] = traffic_type + + service_tier = self.response_headers.get("x-gemini-service-tier") + if service_tier: + if service_tier.lower() == "standard": + setattr(model_response, "service_tier", "default") + else: + setattr(model_response, "service_tier", service_tier.lower()) + + return usage + def chunk_parser(self, chunk: dict) -> Optional["ModelResponseStream"]: try: verbose_logger.debug(f"RAW GEMINI CHUNK: {chunk}") @@ -3043,101 +3181,23 @@ class ModelResponseIterator: if blocked_response is not None: model_response = blocked_response - usage: Optional[Usage] = None - _candidates: Optional[List[Candidates]] = processed_chunk.get("candidates") grounding_metadata: List[dict] = [] url_context_metadata: List[dict] = [] safety_ratings: List[dict] = [] citation_metadata: List[dict] = [] + + _candidates: Optional[List[Candidates]] = processed_chunk.get("candidates") if _candidates: ( grounding_metadata, url_context_metadata, safety_ratings, citation_metadata, - self.cumulative_tool_call_index, - ) = VertexGeminiConfig._process_candidates( - _candidates, - model_response, - self.logging_obj.optional_params, - cumulative_tool_call_index=self.cumulative_tool_call_index, - ) + ) = self._apply_stream_candidates(_candidates, model_response) - # Track whether tool_calls have been seen across streaming chunks. - # Gemini sends tool_calls and finishReason in separate chunks, - # so we need to remember if earlier chunks contained tool_calls - # to correctly set finish_reason="tool_calls" per the OpenAI spec. - if not self.has_seen_tool_calls: - for choice in model_response.choices: - if ( - hasattr(choice, "delta") - and choice.delta - and choice.delta.tool_calls - ): - self.has_seen_tool_calls = True - break - - # Handle final chunk with finishReason but no content. - # _process_candidates skips candidates without "content", - # so the finish_reason from the final chunk is lost. - if not model_response.choices and _candidates: - from litellm.types.utils import Delta, StreamingChoices - - for candidate in _candidates: - finish_reason_str = candidate.get("finishReason") - if finish_reason_str is not None: - if self.has_seen_tool_calls: - mapped_finish_reason = "tool_calls" - else: - mapped_finish_reason = ( - VertexGeminiConfig._check_finish_reason( - None, finish_reason_str - ) - ) - choice = StreamingChoices( - finish_reason=mapped_finish_reason, - index=candidate.get("index", 0), - delta=Delta(content=None, role=None), - logprobs=None, - enhancements=None, - ) - model_response.choices.append(choice) - - # Also handle the case where the final chunk has empty - # content (e.g. text:"") WITH finishReason. In this case - # _process_candidates DOES create a choice, but maps - # finishReason="STOP" to "stop" because the current chunk - # has no tool_calls. Override if we saw tool_calls earlier. - if self.has_seen_tool_calls: - for choice in model_response.choices: - if choice.finish_reason == "stop": - choice.finish_reason = "tool_calls" - - setattr(model_response, "vertex_ai_grounding_metadata", grounding_metadata) # type: ignore - setattr(model_response, "vertex_ai_url_context_metadata", url_context_metadata) # type: ignore - setattr(model_response, "vertex_ai_safety_ratings", safety_ratings) # type: ignore - setattr(model_response, "vertex_ai_citation_metadata", citation_metadata) # type: ignore - - if "usageMetadata" in processed_chunk: - usage = VertexGeminiConfig._calculate_usage( - completion_response=processed_chunk, - ) - - web_search_requests = VertexGeminiConfig._calculate_web_search_requests( - grounding_metadata - ) - if web_search_requests is not None: - cast( - PromptTokensDetailsWrapper, usage.prompt_tokens_details - ).web_search_requests = web_search_requests - - traffic_type = processed_chunk.get("usageMetadata", {}).get( - "trafficType" - ) - if traffic_type: - model_response._hidden_params.setdefault( - "provider_specific_fields", {} - )["traffic_type"] = traffic_type + usage = self._apply_stream_usage_metadata( + processed_chunk, model_response, grounding_metadata + ) setattr(model_response, "usage", usage) # type: ignore diff --git a/litellm/llms/vertex_ai/vertex_llm_base.py b/litellm/llms/vertex_ai/vertex_llm_base.py index cdabac27af7..68d8f0d046d 100644 --- a/litellm/llms/vertex_ai/vertex_llm_base.py +++ b/litellm/llms/vertex_ai/vertex_llm_base.py @@ -136,6 +136,11 @@ class VertexBase: json_obj, scopes=["https://www.googleapis.com/auth/cloud-platform"], ) + elif isinstance(credential_source, dict) and "executable" in credential_source: + creds = self._credentials_from_pluggable( + json_obj, + scopes=["https://www.googleapis.com/auth/cloud-platform"], + ) else: creds = self._credentials_from_identity_pool( json_obj, @@ -190,6 +195,17 @@ class VertexBase: creds = creds.with_scopes(scopes) return creds + def _credentials_from_pluggable(self, json_obj, scopes): + try: + from google.auth import pluggable + except ImportError: + raise ImportError(GOOGLE_IMPORT_ERROR_MESSAGE) + + creds = pluggable.Credentials.from_info(json_obj) + if scopes and hasattr(creds, "requires_scopes") and creds.requires_scopes: + creds = creds.with_scopes(scopes) + return creds + def _credentials_from_identity_pool_with_aws(self, json_obj, scopes): try: from google.auth import aws diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index d781c91992d..479231deac8 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -7818,26 +7818,6 @@ "cache_read_input_token_cost": 3e-08, "cache_creation_input_token_cost": 3.75e-07 }, - "bedrock/us-gov-east-1/claude-sonnet-4-5-20250929-v1:0": { - "input_cost_per_token": 3.6e-06, - "litellm_provider": "bedrock", - "max_input_tokens": 200000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 1.8e-05, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "cache_read_input_token_cost": 3.6e-07, - "cache_creation_input_token_cost": 4.5e-06 - }, "bedrock/us-gov-east-1/anthropic.claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 4.125e-06, "cache_read_input_token_cost": 3.3e-07, @@ -7856,7 +7836,29 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_native_structured_output": true + }, + "bedrock/us-gov-east-1/claude-sonnet-4-5-20250929-v1:0": { + "cache_creation_input_token_cost": 4.125e-06, + "cache_read_input_token_cost": 3.3e-07, + "input_cost_per_token": 3.3e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.65e-05, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_native_structured_output": true }, "bedrock/us-gov-east-1/meta.llama3-70b-instruct-v1:0": { "input_cost_per_token": 2.65e-06, @@ -7989,26 +7991,6 @@ "cache_read_input_token_cost": 3e-08, "cache_creation_input_token_cost": 3.75e-07 }, - "bedrock/us-gov-west-1/claude-sonnet-4-5-20250929-v1:0": { - "input_cost_per_token": 3.6e-06, - "litellm_provider": "bedrock", - "max_input_tokens": 200000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 1.8e-05, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "cache_read_input_token_cost": 3.6e-07, - "cache_creation_input_token_cost": 4.5e-06 - }, "bedrock/us-gov-west-1/anthropic.claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 4.125e-06, "cache_read_input_token_cost": 3.3e-07, @@ -8027,7 +8009,29 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_native_structured_output": true + }, + "bedrock/us-gov-west-1/claude-sonnet-4-5-20250929-v1:0": { + "cache_creation_input_token_cost": 4.125e-06, + "cache_read_input_token_cost": 3.3e-07, + "input_cost_per_token": 3.3e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.65e-05, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_native_structured_output": true }, "bedrock/us-gov-west-1/meta.llama3-70b-instruct-v1:0": { "input_cost_per_token": 2.65e-06, @@ -13735,7 +13739,8 @@ "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "supports_service_tier": true }, "gemini-2.5-flash-image": { "cache_read_input_token_cost": 3e-08, @@ -13784,7 +13789,8 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": false, - "tpm": 8000000 + "tpm": 8000000, + "supports_service_tier": true }, "gemini-3-pro-image-preview": { "input_cost_per_image": 0.0011, @@ -13818,7 +13824,8 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "supports_service_tier": true }, "gemini-3.1-flash-image-preview": { "input_cost_per_image": 0.00056, @@ -13901,7 +13908,8 @@ "supports_video_input": true, "supports_vision": true, "supports_web_search": true, - "supports_native_streaming": true + "supports_native_streaming": true, + "supports_service_tier": true }, "deep-research-pro-preview-12-2025": { "input_cost_per_image": 0.0011, @@ -13980,7 +13988,8 @@ "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "supports_service_tier": true }, "gemini-2.5-flash-lite-preview-09-2025": { "cache_read_input_token_cost": 1e-08, @@ -14251,7 +14260,8 @@ "supports_tool_choice": true, "supports_video_input": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "supports_service_tier": true }, "gemini-3-pro-preview": { "deprecation_date": "2026-03-26", @@ -15033,7 +15043,8 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, - "tpm": 8000000 + "tpm": 8000000, + "supports_service_tier": true }, "gemini/gemini-2.5-flash-image": { "cache_read_input_token_cost": 3e-08, @@ -15083,7 +15094,8 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, - "tpm": 8000000 + "tpm": 8000000, + "supports_service_tier": true }, "gemini/gemini-3-pro-image-preview": { "input_cost_per_image": 0.0011, @@ -15119,7 +15131,8 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "supports_service_tier": true }, "gemini/gemini-3.1-flash-image-preview": { "input_cost_per_token": 2.5e-07, @@ -15238,7 +15251,8 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, - "tpm": 250000 + "tpm": 250000, + "supports_service_tier": true }, "gemini/gemini-2.5-flash-lite-preview-09-2025": { "cache_read_input_token_cost": 1e-08, @@ -15678,7 +15692,8 @@ "supports_vision": true, "supports_web_search": true, "supports_native_streaming": true, - "tpm": 250000 + "tpm": 250000, + "supports_service_tier": true }, "gemini/gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-08, @@ -38123,4 +38138,4 @@ "supports_native_structured_output": true, "supports_pdf_input": true } -} +} \ No newline at end of file diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index cf99c5cd9fa..793742891fc 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -2435,6 +2435,7 @@ class LiteLLM_VerificationTokenView(LiteLLM_VerificationToken): end_user_model_max_budget: Optional[dict] = None # Organization Params + organization_alias: Optional[str] = None organization_max_budget: Optional[float] = None organization_tpm_limit: Optional[int] = None organization_rpm_limit: Optional[int] = None diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 3ed96c163af..ece72b1060e 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -684,6 +684,7 @@ class LiteLLMProxyRequestSetup: user_api_key_project_alias=user_api_key_dict.project_alias, user_api_key_user_id=user_api_key_dict.user_id, user_api_key_org_id=user_api_key_dict.org_id, + user_api_key_org_alias=user_api_key_dict.organization_alias, user_api_key_team_alias=user_api_key_dict.team_alias, user_api_key_end_user_id=user_api_key_dict.end_user_id, user_api_key_user_email=user_api_key_dict.user_email, diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 9738ae4f1a2..85a12f70f58 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -7128,6 +7128,13 @@ async def chat_completion( # noqa: PLR0915 and user_api_key_dict.org_id is not None ): data["metadata"]["user_api_key_org_id"] = user_api_key_dict.org_id + if ( + hasattr(user_api_key_dict, "organization_alias") + and user_api_key_dict.organization_alias is not None + ): + data["metadata"]["user_api_key_org_alias"] = ( + user_api_key_dict.organization_alias + ) if ( hasattr(user_api_key_dict, "agent_id") and user_api_key_dict.agent_id is not None @@ -7302,6 +7309,13 @@ async def completion( # noqa: PLR0915 and user_api_key_dict.org_id is not None ): data["metadata"]["user_api_key_org_id"] = user_api_key_dict.org_id + if ( + hasattr(user_api_key_dict, "organization_alias") + and user_api_key_dict.organization_alias is not None + ): + data["metadata"]["user_api_key_org_alias"] = ( + user_api_key_dict.organization_alias + ) if ( hasattr(user_api_key_dict, "agent_id") and user_api_key_dict.agent_id is not None @@ -7544,6 +7558,13 @@ async def embeddings( # noqa: PLR0915 and user_api_key_dict.org_id is not None ): data["metadata"]["user_api_key_org_id"] = user_api_key_dict.org_id + if ( + hasattr(user_api_key_dict, "organization_alias") + and user_api_key_dict.organization_alias is not None + ): + data["metadata"]["user_api_key_org_alias"] = ( + user_api_key_dict.organization_alias + ) if ( hasattr(user_api_key_dict, "agent_id") and user_api_key_dict.agent_id is not None diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index ec98cfd4d1e..635204f3362 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -3004,6 +3004,7 @@ class PrismaClient: b.model_max_budget as litellm_budget_table_model_max_budget, b.soft_budget as litellm_budget_table_soft_budget, o.metadata as organization_metadata, + o.organization_alias as organization_alias, b2.max_budget as organization_max_budget, b2.tpm_limit as organization_tpm_limit, b2.rpm_limit as organization_rpm_limit @@ -5293,11 +5294,12 @@ def handle_exception_on_proxy(e: Exception) -> ProxyException: ) elif isinstance(e, ProxyException): return e + _status_code = getattr(e, "status_code", status.HTTP_500_INTERNAL_SERVER_ERROR) return ProxyException( - message="Internal Server Error, " + str(e), + message=str(e), type=ProxyErrorTypes.internal_server_error, param=getattr(e, "param", "None"), - code=status.HTTP_500_INTERNAL_SERVER_ERROR, + code=_status_code, ) diff --git a/litellm/types/integrations/prometheus.py b/litellm/types/integrations/prometheus.py index 0d1501664b9..5f1aa9fb2ce 100644 --- a/litellm/types/integrations/prometheus.py +++ b/litellm/types/integrations/prometheus.py @@ -1,7 +1,7 @@ import re from dataclasses import dataclass from enum import Enum -from typing import Any, Dict, List, Literal, Optional, Tuple +from typing import Any, ClassVar, Dict, List, Literal, Optional, Tuple from pydantic import BaseModel, Field, field_validator from typing_extensions import Annotated @@ -665,6 +665,24 @@ class PrometheusMetricLabels: litellm_cache_misses_metric = _cache_metric_labels litellm_cached_tokens_metric = _cache_metric_labels + # Metrics whose emission paths supply org context (used by get_labels) + _org_label_metrics: ClassVar[frozenset] = frozenset( + { + "litellm_llm_api_latency_metric", + "litellm_llm_api_time_to_first_token_metric", + "litellm_request_total_latency_metric", + "litellm_request_queue_time_seconds", + "litellm_proxy_total_requests_metric", + "litellm_proxy_failed_requests_metric", + "litellm_deployment_latency_per_output_token", + "litellm_requests_metric", + "litellm_spend_metric", + "litellm_input_tokens_metric", + "litellm_total_tokens_metric", + "litellm_output_tokens_metric", + } + ) + # Managed batch metrics _batch_user_labels = [ UserAPIKeyLabelNames.v1_LITELLM_MODEL_NAME.value, @@ -731,6 +749,14 @@ class PrometheusMetricLabels: ): custom_labels.append(UserAPIKeyLabelNames.STREAM.value) + if label_name in PrometheusMetricLabels._org_label_metrics: + for label in [ + UserAPIKeyLabelNames.ORG_ID.value, + UserAPIKeyLabelNames.ORG_ALIAS.value, + ]: + if label not in default_labels and label not in custom_labels: + custom_labels.append(label) + return default_labels + custom_labels diff --git a/litellm/types/llms/vertex_ai.py b/litellm/types/llms/vertex_ai.py index c49fc96a65b..86d7b926214 100644 --- a/litellm/types/llms/vertex_ai.py +++ b/litellm/types/llms/vertex_ai.py @@ -325,6 +325,7 @@ class RequestBody(TypedDict, total=False): generationConfig: GenerationConfig cachedContent: str labels: Dict[str, str] + serviceTier: str class CachedContentRequestBody(TypedDict, total=False): diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 3f6e6e5aa5a..cd5806b3ab7 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -2507,6 +2507,7 @@ class StandardLoggingUserAPIKeyMetadata(TypedDict): user_api_key_max_budget: Optional[float] user_api_key_budget_reset_at: Optional[str] user_api_key_org_id: Optional[str] + user_api_key_org_alias: Optional[str] user_api_key_team_id: Optional[str] user_api_key_project_id: Optional[str] user_api_key_project_alias: Optional[str] diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index cfdb2911fdf..e579ab692b2 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -7818,26 +7818,6 @@ "cache_read_input_token_cost": 3e-08, "cache_creation_input_token_cost": 3.75e-07 }, - "bedrock/us-gov-east-1/claude-sonnet-4-5-20250929-v1:0": { - "input_cost_per_token": 3.6e-06, - "litellm_provider": "bedrock", - "max_input_tokens": 200000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 1.8e-05, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "cache_read_input_token_cost": 3.6e-07, - "cache_creation_input_token_cost": 4.5e-06 - }, "bedrock/us-gov-east-1/anthropic.claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 4.125e-06, "cache_read_input_token_cost": 3.3e-07, @@ -7856,7 +7836,29 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_native_structured_output": true + }, + "bedrock/us-gov-east-1/claude-sonnet-4-5-20250929-v1:0": { + "cache_creation_input_token_cost": 4.125e-06, + "cache_read_input_token_cost": 3.3e-07, + "input_cost_per_token": 3.3e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.65e-05, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_native_structured_output": true }, "bedrock/us-gov-east-1/meta.llama3-70b-instruct-v1:0": { "input_cost_per_token": 2.65e-06, @@ -7989,26 +7991,6 @@ "cache_read_input_token_cost": 3e-08, "cache_creation_input_token_cost": 3.75e-07 }, - "bedrock/us-gov-west-1/claude-sonnet-4-5-20250929-v1:0": { - "input_cost_per_token": 3.6e-06, - "litellm_provider": "bedrock", - "max_input_tokens": 200000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 1.8e-05, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "cache_read_input_token_cost": 3.6e-07, - "cache_creation_input_token_cost": 4.5e-06 - }, "bedrock/us-gov-west-1/anthropic.claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 4.125e-06, "cache_read_input_token_cost": 3.3e-07, @@ -8027,7 +8009,29 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_native_structured_output": true + }, + "bedrock/us-gov-west-1/claude-sonnet-4-5-20250929-v1:0": { + "cache_creation_input_token_cost": 4.125e-06, + "cache_read_input_token_cost": 3.3e-07, + "input_cost_per_token": 3.3e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.65e-05, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_native_structured_output": true }, "bedrock/us-gov-west-1/meta.llama3-70b-instruct-v1:0": { "input_cost_per_token": 2.65e-06, @@ -13735,7 +13739,8 @@ "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "supports_service_tier": true }, "gemini-2.5-flash-image": { "cache_read_input_token_cost": 3e-08, @@ -13784,7 +13789,8 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": false, - "tpm": 8000000 + "tpm": 8000000, + "supports_service_tier": true }, "gemini-3-pro-image-preview": { "input_cost_per_image": 0.0011, @@ -13818,7 +13824,8 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "supports_service_tier": true }, "gemini-3.1-flash-image-preview": { "input_cost_per_image": 0.00056, @@ -13901,7 +13908,8 @@ "supports_video_input": true, "supports_vision": true, "supports_web_search": true, - "supports_native_streaming": true + "supports_native_streaming": true, + "supports_service_tier": true }, "deep-research-pro-preview-12-2025": { "input_cost_per_image": 0.0011, @@ -13980,7 +13988,8 @@ "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "supports_service_tier": true }, "gemini-2.5-flash-lite-preview-09-2025": { "cache_read_input_token_cost": 1e-08, @@ -14251,7 +14260,8 @@ "supports_tool_choice": true, "supports_video_input": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "supports_service_tier": true }, "gemini-3-pro-preview": { "deprecation_date": "2026-03-26", @@ -15033,7 +15043,8 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, - "tpm": 8000000 + "tpm": 8000000, + "supports_service_tier": true }, "gemini/gemini-2.5-flash-image": { "cache_read_input_token_cost": 3e-08, @@ -15083,7 +15094,8 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, - "tpm": 8000000 + "tpm": 8000000, + "supports_service_tier": true }, "gemini/gemini-3-pro-image-preview": { "input_cost_per_image": 0.0011, @@ -15119,7 +15131,8 @@ "supports_response_schema": true, "supports_system_messages": true, "supports_vision": true, - "supports_web_search": true + "supports_web_search": true, + "supports_service_tier": true }, "gemini/gemini-3.1-flash-image-preview": { "input_cost_per_token": 2.5e-07, @@ -15238,7 +15251,8 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, - "tpm": 250000 + "tpm": 250000, + "supports_service_tier": true }, "gemini/gemini-2.5-flash-lite-preview-09-2025": { "cache_read_input_token_cost": 1e-08, @@ -15678,7 +15692,8 @@ "supports_vision": true, "supports_web_search": true, "supports_native_streaming": true, - "tpm": 250000 + "tpm": 250000, + "supports_service_tier": true }, "gemini/gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-08, @@ -38108,4 +38123,4 @@ "supports_native_structured_output": true, "supports_pdf_input": true } -} +} \ No newline at end of file diff --git a/tests/enterprise/litellm_enterprise/enterprise_callbacks/test_prometheus_logging_callbacks.py b/tests/enterprise/litellm_enterprise/enterprise_callbacks/test_prometheus_logging_callbacks.py index 967ef2a5fec..834cb235f0c 100644 --- a/tests/enterprise/litellm_enterprise/enterprise_callbacks/test_prometheus_logging_callbacks.py +++ b/tests/enterprise/litellm_enterprise/enterprise_callbacks/test_prometheus_logging_callbacks.py @@ -223,6 +223,8 @@ def test_increment_token_metrics(prometheus_logger): api_key_alias="test_alias", team="test_team", team_alias="test_team_alias", + org_id=None, + org_alias=None, requested_model=None, model="gpt-3.5-turbo", model_id="model-123", @@ -237,6 +239,8 @@ def test_increment_token_metrics(prometheus_logger): api_key_alias="test_alias", team="test_team", team_alias="test_team_alias", + org_id=None, + org_alias=None, requested_model=None, model="gpt-3.5-turbo", model_id="model-123", @@ -253,6 +257,8 @@ def test_increment_token_metrics(prometheus_logger): api_key_alias="test_alias", team="test_team", team_alias="test_team_alias", + org_id=None, + org_alias=None, requested_model=None, model="gpt-3.5-turbo", model_id="model-123", @@ -414,6 +420,8 @@ def test_set_latency_metrics(prometheus_logger): api_key_alias="test_alias", team="test_team", team_alias="test_team_alias", + org_id=None, + org_alias=None, requested_model="openai-gpt", model="gpt-3.5-turbo", model_id="model-123", @@ -430,6 +438,8 @@ def test_set_latency_metrics(prometheus_logger): api_key_alias="test_alias", team="test_team", team_alias="test_team_alias", + org_id=None, + org_alias=None, requested_model="openai-gpt", model="gpt-3.5-turbo", model_id="model-123", @@ -446,6 +456,8 @@ def test_set_latency_metrics(prometheus_logger): api_key_alias="test_alias", team="test_team", team_alias="test_team_alias", + org_id=None, + org_alias=None, requested_model="openai-gpt", model="gpt-3.5-turbo", model_id="model-123", @@ -589,6 +601,8 @@ def test_increment_top_level_request_and_spend_metrics(prometheus_logger): api_key_alias="test_alias", team="test_team", team_alias="test_team_alias", + org_id=None, + org_alias=None, model="gpt-3.5-turbo", model_id="model-123", client_ip=None, @@ -605,6 +619,8 @@ def test_increment_top_level_request_and_spend_metrics(prometheus_logger): api_key_alias="test_alias", team="test_team", team_alias="test_team_alias", + org_id=None, + org_alias=None, model="gpt-3.5-turbo", model_id="model-123", client_ip=None, @@ -758,6 +774,8 @@ async def test_async_post_call_failure_hook(prometheus_logger): api_key_alias="test_alias", team="test_team", team_alias="test_team_alias", + org_id=None, + org_alias=None, requested_model="gpt-3.5-turbo", exception_status="429", exception_class="Openai.RateLimitError", @@ -776,6 +794,8 @@ async def test_async_post_call_failure_hook(prometheus_logger): requested_model="gpt-3.5-turbo", team="test_team", team_alias="test_team_alias", + org_id=None, + org_alias=None, user="test_user", status_code="429", user_email=None, @@ -955,6 +975,8 @@ def test_set_llm_deployment_success_metrics(prometheus_logger): api_key_alias=standard_logging_payload["metadata"]["user_api_key_alias"], team=standard_logging_payload["metadata"]["user_api_key_team_id"], team_alias=standard_logging_payload["metadata"]["user_api_key_team_alias"], + org_id=None, + org_alias=None, ) prometheus_logger.litellm_overhead_latency_metric.labels.assert_called_once_with( api_base="https://api.openai.com", diff --git a/tests/proxy_unit_tests/test_proxy_utils.py b/tests/proxy_unit_tests/test_proxy_utils.py index 6c6ec7bcd60..09f6a85938d 100644 --- a/tests/proxy_unit_tests/test_proxy_utils.py +++ b/tests/proxy_unit_tests/test_proxy_utils.py @@ -2637,3 +2637,50 @@ async def test_handle_logging_proxy_only_error_skips_handlers_for_pass_through() mock_async.assert_not_called() mock_sync.assert_not_called() assert logging_obj.call_type == CallTypes.pass_through.value + + +def test_handle_exception_on_proxy_preserves_status_code(): + """ + OpenAI batch creation returns 429 for rate limits. LiteLLM wraps this as a + RateLimitError with status_code=429. handle_exception_on_proxy must pass + that status code through instead of hardcoding 500. + """ + from litellm.proxy.utils import handle_exception_on_proxy + + rate_limit_error = litellm.RateLimitError( + message="Rate limit exceeded: batch creation limit of 2000/hour hit", + llm_provider="openai", + model="gpt-4o", + ) + + result = handle_exception_on_proxy(rate_limit_error) + + assert int(result.code) == 429, f"Expected 429, got {result.code}" + + +def test_handle_exception_on_proxy_defaults_to_500_for_unknown_exceptions(): + """ + Generic exceptions with no status_code should still return 500. + """ + from litellm.proxy.utils import handle_exception_on_proxy + + result = handle_exception_on_proxy(Exception("something went wrong")) + + assert int(result.code) == 500, f"Expected 500, got {result.code}" + + +def test_handle_exception_on_proxy_preserves_auth_error_status_code(): + """ + AuthenticationError (401) should also pass through correctly. + """ + from litellm.proxy.utils import handle_exception_on_proxy + + auth_error = litellm.AuthenticationError( + message="Invalid API key", + llm_provider="openai", + model="gpt-4o", + ) + + result = handle_exception_on_proxy(auth_error) + + assert int(result.code) == 401, f"Expected 401, got {result.code}" diff --git a/tests/test_litellm/integrations/test_prometheus_client_ip_user_agent.py b/tests/test_litellm/integrations/test_prometheus_client_ip_user_agent.py index 4bfa3a581e3..48d9cbd1bb1 100644 --- a/tests/test_litellm/integrations/test_prometheus_client_ip_user_agent.py +++ b/tests/test_litellm/integrations/test_prometheus_client_ip_user_agent.py @@ -118,6 +118,8 @@ async def test_async_post_call_success_hook_includes_client_ip_user_agent(): "user_api_key_alias": "alias_1", "user_api_key_team_id": "team_1", "user_api_key_team_alias": "team_alias_1", + "user_api_key_org_id": None, + "user_api_key_org_alias": None, "user_api_key_user_email": "test@example.com", "user_api_key_request_route": "/chat/completions", "requester_ip_address": "192.168.1.1", diff --git a/tests/test_litellm/integrations/test_prometheus_user_team_metrics.py b/tests/test_litellm/integrations/test_prometheus_user_team_metrics.py index 9bcf08fdd71..6b65f444046 100644 --- a/tests/test_litellm/integrations/test_prometheus_user_team_metrics.py +++ b/tests/test_litellm/integrations/test_prometheus_user_team_metrics.py @@ -525,6 +525,61 @@ async def test_set_user_budget_metrics_after_api_request_inf_when_genuinely_no_b ) +def test_per_request_metrics_emit_all_identity_labels(prometheus_logger): + """Verify org labels appear when flag is on and are absent when flag is off.""" + import litellm + from litellm.types.integrations.prometheus import UserAPIKeyLabelValues + + prometheus_logger.litellm_requests_metric = MagicMock() + prometheus_logger.litellm_spend_metric = MagicMock() + + enum_values = UserAPIKeyLabelValues( + hashed_api_key="hashed-key", + api_key_alias="my-key", + model="gpt-4", + team="team-abc", + team_alias="my-team", + org_id="org-abc", + org_alias="my-org", + user="user-1", + ) + + common_kwargs = dict( + end_user_id=None, + user_api_key="hashed-key", + user_api_key_alias="my-key", + model="gpt-4", + user_api_team="team-abc", + user_api_team_alias="my-team", + user_id="user-1", + response_cost=0.001, + enum_values=enum_values, + ) + + try: + # org labels are always included in per-request metrics + prometheus_logger._increment_top_level_request_and_spend_metrics(**common_kwargs) + label_kwargs = prometheus_logger.litellm_requests_metric.labels.call_args.kwargs + assert label_kwargs["org_id"] == "org-abc" + assert label_kwargs["org_alias"] == "my-org" + assert label_kwargs["team"] == "team-abc" + assert label_kwargs["user"] == "user-1" + + # Metrics not in the org-emission list must NOT get org labels + from litellm.types.integrations.prometheus import PrometheusMetricLabels + for metric in ("litellm_remaining_api_key_budget_metric", "litellm_remaining_team_budget_metric"): + labels = PrometheusMetricLabels.get_labels(metric) + assert "org_id" not in labels, f"{metric} should not have org_id" + assert "org_alias" not in labels, f"{metric} should not have org_alias" + + # org_id in custom_prometheus_metadata_labels must not produce duplicate labels + litellm.custom_prometheus_metadata_labels = ["org_id"] + labels = PrometheusMetricLabels.get_labels("litellm_requests_metric") + assert labels.count("org_id") == 1 + finally: + litellm.custom_prometheus_metadata_labels = [] + + # --------------------------------------------------------------------------- # Org budget metric tests # --------------------------------------------------------------------------- diff --git a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_handler.py b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_handler.py index 20427e8cc94..bc40919525e 100644 --- a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_handler.py +++ b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_handler.py @@ -1,7 +1,9 @@ -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock + +import pytest from litellm.constants import RESPONSE_FORMAT_TOOL_NAME -from litellm.llms.anthropic.chat.handler import ModelResponseIterator +from litellm.llms.anthropic.chat.handler import ModelResponseIterator, make_call from litellm.types.llms.openai import ( ChatCompletionToolCallChunk, ChatCompletionToolCallFunctionChunk, @@ -9,6 +11,33 @@ from litellm.types.llms.openai import ( from litellm.types.responses.main import OutputCodeInterpreterCall +@pytest.mark.asyncio +async def test_make_call_passes_logging_obj_to_client_post(): + """make_call must pass logging_obj to client.post so track_llm_api_timing can set llm_api_duration_ms for litellm_overhead_time_ms.""" + mock_client = AsyncMock() + mock_response = MagicMock() + mock_response.aiter_lines = MagicMock(return_value=iter([b'data: {"type":"message_start"}\n', b'data: {"type":"message_delta"}\n'])) + mock_client.post.return_value = mock_response + + logging_obj = MagicMock() + + await make_call( + client=mock_client, + api_base="https://api.anthropic.com/v1/messages", + headers={}, + data="{}", + model="claude-3-5-haiku", + messages=[{"role": "user", "content": "Hi"}], + logging_obj=logging_obj, + timeout=60.0, + json_mode=False, + ) + + mock_client.post.assert_called_once() + call_kwargs = mock_client.post.call_args[1] + assert call_kwargs.get("logging_obj") is logging_obj + + def test_redacted_thinking_content_block_delta(): chunk = { "type": "content_block_start", diff --git a/tests/test_litellm/llms/sap/chat/test_sap_tool_parameters.py b/tests/test_litellm/llms/sap/chat/test_sap_tool_parameters.py index 792d2f3fe6b..a5a3fa40d98 100644 --- a/tests/test_litellm/llms/sap/chat/test_sap_tool_parameters.py +++ b/tests/test_litellm/llms/sap/chat/test_sap_tool_parameters.py @@ -127,11 +127,12 @@ class TestToolTransformationIntegration: } validated_tool = validate_dict(openai_tool, ChatCompletionTool) - + # After validation, parameters should have type='object' assert validated_tool["function"]["parameters"]["type"] == "object" assert "properties" in validated_tool["function"]["parameters"] + def test_should_transform_tool_with_existing_parameters(self): """Tool with parameters should preserve them while ensuring type='object'.""" from litellm.llms.sap.chat.transformation import validate_dict diff --git a/tests/test_litellm/llms/sap/chat/test_sap_transformation.py b/tests/test_litellm/llms/sap/chat/test_sap_transformation.py new file mode 100644 index 00000000000..15ce1c85e8f --- /dev/null +++ b/tests/test_litellm/llms/sap/chat/test_sap_transformation.py @@ -0,0 +1,564 @@ +import warnings +import pytest +from pydantic import ValidationError + +class TestSAPTransformationIntegration: + """Integration tests for SAP transformation.""" + + @pytest.fixture + def mock_config(self): + from litellm.llms.sap.chat.transformation import GenAIHubOrchestrationConfig + + config = GenAIHubOrchestrationConfig() + config.token_creator = lambda: "Bearer TEST_TOKEN" + config._base_url = "https://api.test-sap.com" + config._resource_group = "test-group" + + return config + + def test_parameter_classification_in_transform_request(self, mock_config): + """Test parameter classification within the actual transform_request method.""" + + model = "gpt-4o" + messages = [{"role": "user", "content": "Hello"}] + + optional_params = { + "temperature": 0.7, + "max_tokens": 100, + "deployment_url": "https://custom.sap.com/deployment/123", + "model_version": "v1.5", + "tools": [{"type": "function", "function": {"name": "calculator"}}], + "frequency_penalty": 0.1 + } + + result = mock_config.transform_request( + model, messages, optional_params, {}, {} + ) + + model_params = result["config"]["modules"]["prompt_templating"]["model"]["params"] + + assert "temperature" in model_params + assert "frequency_penalty" in model_params + assert "deployment_url" not in model_params + assert "model_version" not in model_params + assert "tools" not in model_params + + model_version = result["config"]["modules"]["prompt_templating"]["model"]["version"] + assert model_version == "v1.5" + + prompt = result["config"]["modules"]["prompt_templating"]["prompt"] + if "tools" in prompt: + assert isinstance(prompt["tools"], list) + for tool in prompt["tools"]: + assert tool["function"]["parameters"]["type"] == "object", ( + "SAP API requires parameters.type == 'object'" + ) + assert "properties" in tool["function"]["parameters"] + + def test_transform_request_parameter_handling_robustness(self, mock_config): + """Test transform_request method handles various parameter combinations correctly.""" + + model = "gpt-4o" + messages = [{"role": "user", "content": "Hello"}] + + test_cases = [ + # Case 1: Basic parameters only + { + "params": {"temperature": 0.7, "max_tokens": 100}, + "expected_in_model": {"temperature", "max_tokens"}, + "expected_excluded": set() + }, + # Case 2: Parameters with auth/infrastructure components + { + "params": { + "temperature": 0.8, + "deployment_url": "https://api.sap.com/deployments/test", + "max_tokens": 150 + }, + "expected_in_model": {"temperature", "max_tokens"}, + "expected_excluded": {"deployment_url"} + }, + # Case 3: Parameters with framework components + { + "params": { + "temperature": 0.6, + "model_version": "v2.0", + "tools": [{"function": {"name": "test"}}], + "frequency_penalty": 0.1 + }, + "expected_in_model": {"temperature", "frequency_penalty"}, + "expected_excluded": {"model_version", "tools"} + } + ] + + for i, test_case in enumerate(test_cases): + filtered_params = { + k: v for k, v in test_case["params"].items() + if k not in {"tools", "model_version", "deployment_url"} + } + + for expected_param in test_case["expected_in_model"]: + assert expected_param in filtered_params, f"Case {i + 1}: {expected_param} should be in model params" + + for excluded_param in test_case["expected_excluded"]: + assert excluded_param not in filtered_params, f"Case {i + 1}: {excluded_param} should be excluded from model params" + + result = mock_config.transform_request( + model, messages, test_case["params"], {}, {} + ) + if result and "config" in result: + model_params = result["config"]["modules"]["prompt_templating"]["model"]["params"] + + for excluded_param in test_case["expected_excluded"]: + assert excluded_param not in model_params, ( + f"Case {i + 1}: {excluded_param} should not be in actual model params" + ) + + def test_config_transform_with_response_format_json_object(self, mock_config): + expected_dict = {'config': + {'modules': + {'prompt_templating': + {'prompt': + {'template': + [{'role': 'user', 'content': 'First man on the moon, answer in json'}], + 'response_format': {'type': 'json_object'}}, + 'model': {'name': 'gpt-4o', 'params': {}, 'version': 'latest'} + } + }, + } + } + config = mock_config.transform_request( + model="gpt-4o", + messages=[{'role': 'user', 'content': 'First man on the moon, answer in json'}], + optional_params={'response_format': {'type': 'json_object'}, + 'deployment_url': "shouldn't be in results"}, + litellm_params={}, + headers={} + ) + assert config == expected_dict + + def test_config_transform_with_response_format_json_schema(self, mock_config): + + expected_response_format = { + 'type': 'json_schema', + 'json_schema': { + 'description': 'Schema for person information', + 'name': 'person_info', + 'schema': { + 'type': 'object', + 'properties': { + 'name': { + 'type': 'string', + 'description': "The person's full name" + }, + 'age': { + 'type': 'integer', + 'description': "The person's age in years" + }, + 'occupation': { + 'type': 'string', + 'description': "The person's job title" + } + }, + 'required': ['name', 'age', 'occupation'], + 'additionalProperties': False + }, + 'strict': True + } + } + + config = mock_config.transform_request( + model="gpt-4o", + messages=[{'role': 'user', 'content': 'First man on the moon, answer in json'}], + optional_params={'response_format': expected_response_format, + 'deployment_url': "shouldn't be in results"}, + litellm_params={}, + headers={} + ) + assert config["config"]["modules"]["prompt_templating"]["prompt"]["response_format"] == expected_response_format + assert len(config["config"]["modules"]["prompt_templating"]["model"]["params"]) == 0 + + def test_config_transform_with_stream(self, mock_config): + expected_dict = { + 'config': { + 'modules': { + 'prompt_templating': { + 'prompt': { + 'template': [{'role': 'user', 'content': 'Hello, how are you?'}] + }, + 'model': { + 'name': 'anthropic--claude-4-sonnet', + 'params': {}, + 'version': 'latest' + } + } + }, + 'stream': {'chunk_size': 10} + } + } + config = mock_config.transform_request( + model="anthropic--claude-4-sonnet", + messages=[{'content': 'Hello, how are you?', 'role': 'user'}], + optional_params={'stream': True, + 'stream_options': {'chunk_size': 10}, + 'model_version': 'latest', + 'deployment_url': "shouldn't be in results"}, + litellm_params={}, + headers={} + ) + + assert config == expected_dict + + def test_sap_placeholder_defaults(self, mock_config): + config = mock_config.transform_request( + model="gpt-4o", + messages=[ + {"role": "user", "content": "Hello. Answer {{ ?user_query }}"} + ], + optional_params={'deployment_url': "shouldn't be in results", + "placeholder_defaults": {"user_query": "default value"}}, + litellm_params={}, + headers={} + ) + + assert config["config"]["modules"]["prompt_templating"]["prompt"]["defaults"] == { + "user_query": "default value"} + assert config["config"]["modules"]["prompt_templating"]["model"]["params"] == {} + + def test_sap_placeholder_values(self, mock_config): + placeholder_values = {"user_query": "Some text"} + config = mock_config.transform_request( + model="gpt-4o", + messages=[ + {"role": "user", "content": "Hello. Answer {{ ?user_query }}"} + ], + optional_params={'deployment_url': "shouldn't be in results", + "placeholder_values": placeholder_values}, + litellm_params={}, + headers={} + ) + + assert config["placeholder_values"] == placeholder_values + assert config["config"]["modules"]["prompt_templating"]["model"]["params"] == {} + + def test_sap_grounding(self, mock_config): + grounding_config = { + 'type': 'document_grounding_service', + 'config': { + 'filters': [ + {'id': 's3-docs', + 'data_repository_type': 'vector', + 'search_config': {'max_chunk_count': 2}, + 'data_repositories': ['123456890-test'] + } + ], + 'placeholders': {'input': ['user_query'], 'output': 'grounding_response'}, + 'metadata_params': ['source', 'webUrl', 'title', 'mimeType', 'fileSuffix'] + } + } + placeholder_values = {"user_query": "Some text"} + config = mock_config.transform_request( + model="gpt-4o", + messages=[ + {"role": "user", "content": "Hello. Answer {{ ?user_query }} using context: {{ ?grounding_response }}"} + ], + optional_params={'deployment_url': "shouldn't be in results", + "grounding": grounding_config, + "placeholder_values": placeholder_values}, + litellm_params={}, + headers={} + ) + assert config["placeholder_values"] == placeholder_values + modules = config["config"]["modules"] + assert modules["grounding"]["type"] == "document_grounding_service" + assert modules["grounding"]["config"]["placeholders"]["output"] == "grounding_response" + assert modules["grounding"]["config"]["filters"][0]["data_repository_type"] == "vector" + assert modules["prompt_templating"]["model"]["params"] == {} + + def test_grounding_search_config_rejects_both_count_fields(self, mock_config): + with pytest.raises(ValidationError): + mock_config.transform_request( + model="gpt-4o", + messages=[{"role": "user", "content": "Hi"}], + optional_params={ + "grounding": { + "type": "document_grounding_service", + "config": { + "filters": [{"data_repository_type": "vector", + "search_config": {"max_chunk_count": 2, + "max_document_count": 5}}], + "placeholders": {"input": ["q"], "output": "r"}, + } + } + }, + litellm_params={}, headers={} + ) + + def test_sap_filtering(self, mock_config): + filtering_config_azure = { + 'input': + { + 'filters': + [ + {'type': 'azure_content_safety', + 'config': + {'hate': 0, + 'sexual': 0, + 'violence': 0, + 'self_harm': 0 + } + } + ] + }, + 'output': + { + 'filters': + [ + {'type': 'azure_content_safety', + 'config': {'hate': 0, + 'sexual': 0, + 'violence': 0, + 'self_harm': 0 + } + } + ] + } + } + filtering_config_llama = { + 'input': + { + 'filters': + [ + { + 'type': 'llama_guard_3_8b', + 'config': {'hate': True, + "elections": True} + } + ] + }, + 'output': + { + 'filters': + [ + { + 'type': 'llama_guard_3_8b', + 'config': {'hate': True, "elections": True} + } + ] + } + } + config = mock_config.transform_request( + model="gpt-4o", + messages=[{"role": "user", "content": "Hello."}], + optional_params={'deployment_url': "shouldn't be in results", + "filtering": filtering_config_azure}, + litellm_params={}, + headers={} + ) + assert config["config"]["modules"]["filtering"] == filtering_config_azure + assert config["config"]["modules"]["prompt_templating"]["model"]["params"] == {} + + config = mock_config.transform_request( + model="gpt-4o", + messages=[{"role": "user", "content": "Hello."}], + optional_params={'deployment_url': "shouldn't be in results", + "filtering": filtering_config_llama}, + litellm_params={}, + headers={} + ) + assert config["config"]["modules"]["filtering"] == filtering_config_llama + assert config["config"]["modules"]["prompt_templating"]["model"]["params"] == {} + + def test_filtering_config_requires_at_least_one_property(self, mock_config): + with pytest.raises(ValidationError) as exc_info: + mock_config.transform_request( + model="gpt-4o", + messages=[{"role": "user", "content": "Hello"}], + optional_params={ + "filtering": {} + }, + litellm_params={}, + headers={} + ) + + assert "For using SAP Filtering Module you must provide at least one property" in str(exc_info.value) + + + def test_sap_masking(self, mock_config): + masking_config = { + 'providers': + [ + { + 'type': 'sap_data_privacy_integration', + 'method': 'anonymization', + 'entities': [ + {'type': 'profile-address'}, + {'type': 'profile-email'}, + {'type': 'profile-phone'}, + {'type': 'profile-person'}, + {'type': 'profile-location'} + ] + } + ] + } + + config = mock_config.transform_request( + model="gpt-4o", + messages=[{"role": "user", "content": "Hello."}], + optional_params={'deployment_url': "shouldn't be in results", + "masking": masking_config}, + litellm_params={}, + headers={} + ) + assert config["config"]["modules"]["masking"] == masking_config + assert config["config"]["modules"]["prompt_templating"]["model"]["params"] == {} + + def test_masking_config_requires_exactly_one_provider_list(self, mock_config): + masking_config = { + 'providers': + [ + { + 'type': 'sap_data_privacy_integration', + 'method': 'anonymization', + 'entities': [ + {'type': 'profile-address'}, + {'type': 'profile-email'}, + {'type': 'profile-phone'}, + {'type': 'profile-person'}, + {'type': 'profile-location'} + ] + } + ], + 'masking_providers': + [ + { + 'type': 'sap_data_privacy_integration', + 'method': 'anonymization', + 'entities': [ + {'type': 'profile-address'} + ] + } + ] + } + with pytest.raises(ValidationError) as exc_info: + mock_config.transform_request( + model="gpt-4o", + messages=[{"role": "user", "content": "Hello"}], + optional_params={ + "masking": masking_config + }, + litellm_params={}, + headers={} + ) + + assert "must set exactly one of: 'providers' or 'masking_providers'" in str(exc_info.value) + + def test_masking_providers_deprecated_emits_warning(self, mock_config): + masking_config = { + 'masking_providers': + [ + { + 'type': 'sap_data_privacy_integration', + 'method': 'anonymization', + 'entities': [ + {'type': 'profile-address'} + ] + } + ] + } + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + mock_config.transform_request( + model="gpt-4o", + messages=[{"role": "user", "content": "Hi"}], + optional_params={"masking": masking_config}, + litellm_params={}, + headers={}, + ) + assert any( + issubclass(warning.category, DeprecationWarning) + and "masking_providers" in str(warning.message) + for warning in w + ), "Expected DeprecationWarning for 'masking_providers'" + + def test_sap_translation(self, mock_config): + translation_config = { + 'input': + {'type': 'sap_document_translation', + 'config': + {'source_language': 'en-US', + 'target_language': 'de-DE'} + }, + 'output': + {'type': 'sap_document_translation', + 'config': + {'source_language': 'de-DE', + 'target_language': 'fr-FR'} + } + } + + config = mock_config.transform_request( + model="gpt-4o", + messages=[{"role": "user", "content": "Hello."}], + optional_params={'deployment_url': "shouldn't be in results", + "translation": translation_config}, + litellm_params={}, + headers={} + ) + assert config["config"]["modules"]["translation"] == translation_config + assert config["config"]["modules"]["prompt_templating"]["model"]["params"] == {} + + def test_translation_config_requires_at_least_one_property(self, mock_config): + with pytest.raises(ValidationError) as exc_info: + mock_config.transform_request( + model="gpt-4o", + messages=[{"role": "user", "content": "Hello"}], + optional_params={ + "translation": {} + }, + litellm_params={}, + headers={} + ) + + assert "TranslationModuleConfig requires at least one of 'input' or 'output'" in str(exc_info.value) + + def test_sap_multiple_modules(self, mock_config): + translation_config = { + 'input': + {'type': 'sap_document_translation', + 'config': + {'source_language': 'en-US', + 'target_language': 'de-DE'} + }, + 'output': + {'type': 'sap_document_translation', + 'config': + {'source_language': 'de-DE', + 'target_language': 'fr-FR'} + } + } + for model in ["sap/gpt-5", "gpt-5"]: + config = mock_config.transform_request( + model="gpt-4o", + messages=[{"role": "user", "content": "Hello."}], + optional_params={'deployment_url': "shouldn't be in results", + "fallback_sap_modules": [{"model": model, + "messages": [{"role": "user", "content": "Hello world!"}], + "translation": translation_config + }] + , + }, + litellm_params={}, + headers={} + ) + assert "translation" not in config["config"]["modules"][0] + translation = config["config"]["modules"][1]["translation"] + assert translation["input"]["config"]["source_language"] == "en-US" + assert translation["input"]["config"]["target_language"] == "de-DE" + assert translation["output"]["config"]["target_language"] == "fr-FR" + assert config["config"]["modules"][1]["prompt_templating"]["model"]["name"] == "gpt-5" + assert config["config"]["modules"][0]["prompt_templating"]["model"]["name"] == "gpt-4o" + assert config["config"]["modules"][0]["prompt_templating"]["model"]["params"] == {} + assert config["config"]["modules"][1]["prompt_templating"]["prompt"]["template"][0]["content"] == "Hello world!" + assert config["config"]["modules"][0]["prompt_templating"]["prompt"]["template"][0]["content"] == "Hello." + assert config["config"]["modules"][1]["translation"]["input"]["type"] == "sap_document_translation" diff --git a/tests/test_litellm/llms/sap/embed/test_sap_embed_transformation.py b/tests/test_litellm/llms/sap/embed/test_sap_embed_transformation.py new file mode 100644 index 00000000000..2d4be6f33c7 --- /dev/null +++ b/tests/test_litellm/llms/sap/embed/test_sap_embed_transformation.py @@ -0,0 +1,97 @@ +from unittest.mock import patch, PropertyMock + +import pytest + +from litellm.llms.sap.embed.transformation import GenAIHubEmbeddingConfig + +@pytest.fixture +def fake_token_creator(): + return (lambda: "Bearer FAKE_TOKEN", "https://api.ai.moke-sap.com", "fake-group") + + +@pytest.fixture +def fake_deployment_url(): + return "https://api.ai.moke-sap.com/v2/inference/deployments/mokeid" + +def test_basic_config_transform(fake_token_creator, fake_deployment_url): + expected_dict = { + 'config': { + 'modules': { + 'embeddings': { + 'model': { + 'name': 'text-embedding-3-small', + 'version': 'latest', + 'params': {} + } + } + } + }, + 'input': { + 'text': 'Hi' + } + } + with patch( + "litellm.llms.sap.embed.transformation.GenAIHubEmbeddingConfig.deployment_url", + new_callable=PropertyMock, + return_value=fake_deployment_url, + ), patch( + "litellm.llms.sap.embed.transformation.get_token_creator", + return_value=fake_token_creator, + ): + body = GenAIHubEmbeddingConfig().transform_embedding_request( + model="text-embedding-3-small", + input="Hi", + optional_params={}, + headers={} + ) + assert body == expected_dict + +def test_model_params(fake_token_creator, fake_deployment_url): + with patch( + "litellm.llms.sap.embed.transformation.GenAIHubEmbeddingConfig.deployment_url", + new_callable=PropertyMock, + return_value=fake_deployment_url, + ), patch( + "litellm.llms.sap.embed.transformation.get_token_creator", + return_value=fake_token_creator, + ): + body = GenAIHubEmbeddingConfig().transform_embedding_request( + model="text-embedding-3-small", + input="Hi", + optional_params={"parameters": {"truncate": "END"}}, + headers={} + ) + assert body["config"]["modules"]["embeddings"]["model"]["params"] == {"truncate": "END"} + +def test_embed_with_masking(fake_token_creator, fake_deployment_url): + masking_config = { + 'providers': + [ + { + 'type': 'sap_data_privacy_integration', + 'method': 'anonymization', + 'entities': [ + {'type': 'profile-address'}, + {'type': 'profile-phone'}, + {'type': 'profile-person'}, + {'type': 'profile-location'} + ] + } + ] + } + with patch( + "litellm.llms.sap.embed.transformation.GenAIHubEmbeddingConfig.deployment_url", + new_callable=PropertyMock, + return_value=fake_deployment_url, + ), patch( + "litellm.llms.sap.embed.transformation.get_token_creator", + return_value=fake_token_creator, + ): + body = GenAIHubEmbeddingConfig().transform_embedding_request( + model="text-embedding-3-small", + input="Hi", + optional_params={"parameters": {"truncate": "END"}, + "masking": masking_config}, + headers={} + ) + assert body["config"]["modules"]["masking"] == masking_config diff --git a/tests/test_litellm/llms/sap/test_sap_fetch_creds.py b/tests/test_litellm/llms/sap/test_sap_fetch_creds.py new file mode 100644 index 00000000000..7815c0b88d6 --- /dev/null +++ b/tests/test_litellm/llms/sap/test_sap_fetch_creds.py @@ -0,0 +1,142 @@ +import json +import pytest +import litellm.llms.sap.credentials as sap_credentials + +mock_sap_service_key_dict = { + "serviceurls": + { + "AI_API_URL":"https://testurl.hana.ondemand.com/" + }, + "clientid":"mockclientid", + "clientsecret":"mockclientsecret", + "url":"https://test.sap.hana.ondemand.com/" +} + +mock_wrapped_sap_service_key_dict = { + "credentials": { + "serviceurls": + { + "AI_API_URL":"https://testurl.hana.ondemand.com/" + }, + "clientid":"mockclientid", + "clientsecret":"mockclientsecret", + "url":"https://test.sap.hana.ondemand.com/" + } +} + +expected_creds = {'client_id': "mockclientid", + 'client_secret': "mockclientsecret", + 'auth_url': 'https://test.sap.hana.ondemand.com/oauth/token', + 'base_url': 'https://testurl.hana.ondemand.com/v2', + 'resource_group': 'default'} + +mock_sap_vcap_service_key_dict = { + 'aicore': [{ + 'label': 'aicore', + 'name': 'aicore-instance', + 'instance_guid': '53ad5b47-a49a-4fec-9f0b-cd921c00b828', + 'credentials': { + 'serviceurls': { + 'AI_API_URL': 'vcap-api-url' + }, + 'url': 'vcap-auth-url', + 'clientid': 'vcap-clientid', + 'clientsecret': 'vcap-clientsecret' + } + }] +} +def _prep_env(monkeypatch): + for var in ("AICORE_CLIENT_ID", "AICORE_CLIENT_SECRET", "AICORE_AUTH_URL", "AICORE_RESOURCE_GROUP", + "AICORE_BASE_URL", "AICORE_CERT_URL", "AICORE_SERVICE_KEY", "VCAP_SERVICES"): + monkeypatch.delenv(var, raising=False) + monkeypatch.setenv("AICORE_HOME", 'notexist') + monkeypatch.setattr('litellm.sap_service_key', None) + +def test_sap_fetch_creds_from_env_service_key(monkeypatch): + _prep_env(monkeypatch) + monkeypatch.setenv("AICORE_SERVICE_KEY", json.dumps(mock_sap_service_key_dict)) + creds = sap_credentials.fetch_credentials() + assert creds == expected_creds + +def test_sap_fetch_creds_from_env_wrapped_service_key(monkeypatch): + _prep_env(monkeypatch) + monkeypatch.setenv("AICORE_SERVICE_KEY", json.dumps(mock_wrapped_sap_service_key_dict)) + creds = sap_credentials.fetch_credentials() + assert creds == expected_creds + +def test_sap_fetch_creds_from_arg_service_key(monkeypatch): + _prep_env(monkeypatch) + creds = sap_credentials.fetch_credentials(service_key=json.dumps(mock_sap_service_key_dict)) + assert creds == expected_creds + +def test_fetch_creds_from_env_vcap_service(monkeypatch): + _prep_env(monkeypatch) + monkeypatch.setenv("VCAP_SERVICES", json.dumps(mock_sap_vcap_service_key_dict)) + creds = sap_credentials.fetch_credentials() + assert creds['client_id'] == "vcap-clientid" + assert creds['client_secret'] == "vcap-clientsecret" + assert creds['auth_url'] == "vcap-auth-url/oauth/token" + assert creds['base_url'] == "vcap-api-url/v2" + assert creds['resource_group'] == "default" + +def test_fetch_creds_from_env(monkeypatch): + _prep_env(monkeypatch) + monkeypatch.setenv("AICORE_CLIENT_ID", "env-client-id") + monkeypatch.setenv("AICORE_CLIENT_SECRET", "env-client-secret") + monkeypatch.setenv("AICORE_AUTH_URL", "env-auth-url") + monkeypatch.setenv("AICORE_BASE_URL", "env-base-url") + monkeypatch.setenv("AICORE_RESOURCE_GROUP", "env-resource-group") + + creds = sap_credentials.fetch_credentials() + + assert creds['client_id'] == "env-client-id" + assert creds['client_secret'] == "env-client-secret" + assert creds['auth_url'] == "env-auth-url/oauth/token" + assert creds['base_url'] == "env-base-url/v2" + assert creds['resource_group'] == "env-resource-group" + +def test_creds_priority_order(monkeypatch): + _prep_env(monkeypatch) + monkeypatch.setenv("AICORE_CLIENT_ID", "env-client-id") + monkeypatch.setenv("AICORE_CLIENT_SECRET", "env-client-secret") + monkeypatch.setenv("AICORE_AUTH_URL", "env-auth-url") + monkeypatch.setenv("AICORE_BASE_URL", "env-base-url") + monkeypatch.setenv("AICORE_RESOURCE_GROUP", "env-resource-group") + creds = sap_credentials.fetch_credentials(service_key=json.dumps(mock_sap_service_key_dict)) + assert creds['client_id'] == "mockclientid" + assert creds['resource_group'] == "env-resource-group" + +def test_no_credentials_configured(monkeypatch): + _prep_env(monkeypatch) + with pytest.raises(ValueError, match="No credentials found in any source"): + sap_credentials.fetch_credentials() + + +def test_partial_credentials_missing_auth_url(monkeypatch): + _prep_env(monkeypatch) + + # Set only client_id and base_url, missing auth_url + monkeypatch.setenv("AICORE_CLIENT_ID", "test-client-id") + monkeypatch.setenv("AICORE_BASE_URL", "test-base-url") + + # fetch_credentials should succeed (it returns whatever it finds) + creds = sap_credentials.fetch_credentials() + creds.pop('resource_group') + + with pytest.raises(ValueError, match="SAP AI Core credentials not found"): + sap_credentials.validate_credentials(**creds) + +def test_credentials_without_authentication_mode(monkeypatch): + _prep_env(monkeypatch) + + # Set all required fields but no authentication mode (no client_secret, no certs) + monkeypatch.setenv("AICORE_CLIENT_ID", "test-client-id") + monkeypatch.setenv("AICORE_AUTH_URL", "test-auth-url") + monkeypatch.setenv("AICORE_BASE_URL", "test-base-url") + + creds = sap_credentials.fetch_credentials() + creds.pop('resource_group') + + # validate_credentials should raise because no authentication mode is provided + with pytest.raises(ValueError, match="SAP AI Core credentials are incomplete"): + sap_credentials.validate_credentials(**creds) diff --git a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py index ce3d2daa743..98cdf830304 100644 --- a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py +++ b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py @@ -127,6 +127,24 @@ def test_vertex_ai_includes_labels(): assert result["labels"] == {"project": "test", "team": "ai"} +def test_service_tier_forwarded_to_vertex_ai(): + """Test that service_tier in optional_params is mapped to serviceTier in request body.""" + messages = [{"role": "user", "content": "test"}] + optional_params = {"service_tier": "flex"} + litellm_params = {} + + result = _transform_request_body( + messages=messages, + model="gemini-2.5-pro", + optional_params=optional_params, + custom_llm_provider="vertex_ai", + litellm_params=litellm_params, + cached_content=None, + ) + + assert "serviceTier" in result + assert result["serviceTier"] == "flex" + def test_extra_body_cache_not_forwarded_to_vertex_ai(): """ diff --git a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py index 3102a695961..ddc404cb8c7 100644 --- a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py +++ b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py @@ -3504,6 +3504,73 @@ def test_vertex_ai_traffic_type_preserved_in_hidden_params_non_streaming(): assert result._hidden_params["provider_specific_fields"]["traffic_type"] == "PROVISIONED_THROUGHPUT" +def test_vertex_ai_service_tier_streaming(): + """Test service_tier is preserved in model_response from headers for streaming.""" + from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( + ModelResponseIterator, + ) + + chunk = { + "candidates": [{"content": {"parts": [{"text": "Hello"}]}}], + } + + iterator = ModelResponseIterator( + streaming_response=[], + sync_stream=True, + logging_obj=MagicMock(), + response_headers={"x-gemini-service-tier": "FLEX"}, + ) + # Undefined when usageMetadata is missing + result = iterator.chunk_parser(chunk) + + # But definitely set when usageMetadata is present + chunk_with_usage = { + "candidates": [{"content": {"parts": [{"text": "hi"}]}}], + "usageMetadata": {"promptTokenCount": 1, "candidatesTokenCount": 1, "totalTokenCount": 2} + } + result_with_usage = iterator.chunk_parser(chunk_with_usage) + assert result_with_usage.service_tier == "flex" + + +def test_vertex_ai_service_tier_non_streaming(): + """Test service_tier is preserved in model_response from headers for non-streaming.""" + from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( + VertexGeminiConfig, + ) + + completion_response = { + "candidates": [ + { + "content": {"parts": [{"text": "Hello"}], "role": "model"}, + "finishReason": "STOP", + } + ], + "usageMetadata": { + "promptTokenCount": 50, + "candidatesTokenCount": 100, + "totalTokenCount": 150, + }, + } + + raw_response = MagicMock() + raw_response.json.return_value = completion_response + raw_response.headers = {"x-gemini-service-tier": "FLEX"} + + result = VertexGeminiConfig().transform_response( + model="gemini-pro", + raw_response=raw_response, + model_response=ModelResponse(), + logging_obj=MagicMock(), + request_data={}, + messages=[], + optional_params={}, + litellm_params={}, + encoding=None, + ) + + assert result.service_tier == "flex" + + def test_vertex_ai_traffic_type_surfaced_in_responses_api(): """Test trafficType is surfaced as provider_specific_fields in ResponsesAPIResponse.""" from litellm.responses.litellm_completion_transformation.transformation import ( @@ -3609,6 +3676,54 @@ def test_vertex_ai_web_search_options_in_map_openai_params(): assert "web_search_options" not in optional_params, "web_search_options should be removed after transformation" +def test_vertex_ai_service_tier_in_map_openai_params(): + """Test that service_tier is correctly mapped to optional_params.""" + from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( + VertexGeminiConfig, + ) + + v = VertexGeminiConfig() + + # Test pass-through + optional_params = {} + non_default_params = {"service_tier": "FLEX"} + + result = v.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model="gemini-3-pro-preview", + drop_params=True, + ) + + assert result["service_tier"] == "flex" + + # Test auto -> priority + optional_params_auto = {} + non_default_params_auto = {"service_tier": "auto"} + + result_auto = v.map_openai_params( + non_default_params=non_default_params_auto, + optional_params=optional_params_auto, + model="gemini-3-pro-preview", + drop_params=True, + ) + + assert result_auto["service_tier"] == "priority" + + # Test AUTO (uppercase) -> priority + optional_params_auto_upper = {} + non_default_params_auto_upper = {"service_tier": "AUTO"} + + result_auto_upper = v.map_openai_params( + non_default_params=non_default_params_auto_upper, + optional_params=optional_params_auto_upper, + model="gemini-3-pro-preview", + drop_params=True, + ) + + assert result_auto_upper["service_tier"] == "priority" + + def test_vertex_ai_usage_metadata_with_video_tokens_in_prompt(): """Test promptTokensDetails with VIDEO modality for video inputs. diff --git a/tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py b/tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py index f9fc730e1df..78caf4b9778 100644 --- a/tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py +++ b/tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py @@ -1050,6 +1050,85 @@ class TestVertexBase: mock_creds.with_scopes.assert_called_once_with(scopes) assert result == "scoped_creds" + def test_credentials_from_pluggable_implementation(self): + """Test _credentials_from_pluggable dispatches to pluggable.Credentials""" + vertex_base = VertexBase() + json_obj = { + "type": "external_account", + "credential_source": { + "executable": {"command": "/path/to/executable", "timeout_millis": 5000} + }, + } + scopes = ["https://www.googleapis.com/auth/cloud-platform"] + + mock_creds = MagicMock() + mock_creds.requires_scopes = True + mock_creds.with_scopes.return_value = "scoped_creds" + + with patch("google.auth.pluggable.Credentials") as MockCredentials: + MockCredentials.from_info.return_value = mock_creds + + result = vertex_base._credentials_from_pluggable(json_obj, scopes) + + MockCredentials.from_info.assert_called_once_with(json_obj) + mock_creds.with_scopes.assert_called_once_with(scopes) + assert result == "scoped_creds" + + def test_credentials_from_pluggable_no_scopes_needed(self): + """Test _credentials_from_pluggable when scopes are not needed""" + vertex_base = VertexBase() + json_obj = { + "type": "external_account", + "credential_source": { + "executable": {"command": "/path/to/executable"} + }, + } + scopes = ["https://www.googleapis.com/auth/cloud-platform"] + + mock_creds = MagicMock() + mock_creds.requires_scopes = False + + with patch("google.auth.pluggable.Credentials") as MockCredentials: + MockCredentials.from_info.return_value = mock_creds + + result = vertex_base._credentials_from_pluggable(json_obj, scopes) + + MockCredentials.from_info.assert_called_once_with(json_obj) + mock_creds.with_scopes.assert_not_called() + assert result == mock_creds + + def test_load_auth_dispatches_to_pluggable_for_executable(self): + """Test that load_auth routes executable credential_source to _credentials_from_pluggable""" + vertex_base = VertexBase() + json_obj = { + "type": "external_account", + "credential_source": { + "executable": {"command": "/path/to/executable", "timeout_millis": 5000} + }, + } + + mock_creds = MagicMock() + mock_creds.project_id = "test-project" + + with patch.object( + vertex_base, "_credentials_from_pluggable", return_value=mock_creds + ) as mock_pluggable, patch.object( + vertex_base, "_credentials_from_identity_pool" + ) as mock_identity_pool, patch.object( + vertex_base, "refresh_auth" + ): + creds, project_id = vertex_base.load_auth( + credentials=json.dumps(json_obj), project_id=None + ) + + mock_pluggable.assert_called_once_with( + json_obj, + scopes=["https://www.googleapis.com/auth/cloud-platform"], + ) + mock_identity_pool.assert_not_called() + assert creds == mock_creds + assert project_id == "test-project" + def test_extract_aws_params(self): """Test _extract_aws_params: extraction, empty case, and unrecognized keys.""" # Case 1: Extracts recognized aws_* keys, ignores GCP-standard fields diff --git a/tests/test_litellm/test_logging.py b/tests/test_litellm/test_logging.py index 6f65ada7459..fe1d7208d78 100644 --- a/tests/test_litellm/test_logging.py +++ b/tests/test_litellm/test_logging.py @@ -177,6 +177,72 @@ def test_json_formatter_parses_embedded_python_dict_repr(): assert obj["model_info"]["db_model"] is False +def test_json_formatter_includes_component_field(): + """ + Test that JsonFormatter always emits a 'component' field equal to the logger name. + This allows filtering by component (e.g. "LiteLLM Proxy") in Datadog / third-party log services. + """ + formatter = JsonFormatter() + for logger_name in ("LiteLLM Proxy", "LiteLLM Router", "LiteLLM"): + record = logging.LogRecord( + name=logger_name, + level=logging.ERROR, + pathname="proxy_server.py", + lineno=42, + msg="something went wrong", + args=(), + exc_info=None, + ) + output = formatter.format(record) + obj = json.loads(output) + assert obj["component"] == logger_name, ( + f"Expected component={logger_name!r}, got {obj.get('component')!r}" + ) + + +def test_json_formatter_includes_logger_field(): + """ + Test that JsonFormatter always emits a 'logger' field with filename:lineno. + This allows pinpointing the exact source of a log line in third-party services. + """ + formatter = JsonFormatter() + record = logging.LogRecord( + name="LiteLLM Proxy", + level=logging.INFO, + pathname="/app/litellm/proxy/proxy_server.py", + lineno=123, + msg="request received", + args=(), + exc_info=None, + ) + output = formatter.format(record) + obj = json.loads(output) + assert obj["logger"] == "proxy_server.py:123", ( + f"Expected logger='proxy_server.py:123', got {obj['logger']!r}" + ) + + +def test_json_formatter_extra_component_not_overwritten(): + """ + User-supplied extra={"component": "..."} must not be silently dropped. + """ + formatter = JsonFormatter() + record = logging.LogRecord( + name="LiteLLM Proxy", + level=logging.INFO, + pathname="proxy_server.py", + lineno=1, + msg="event", + args=(), + exc_info=None, + ) + record.component = "auth-service" + obj = json.loads(formatter.format(record)) + assert obj["component"] == "auth-service", ( + f"User-supplied component was overwritten, got {obj['component']!r}" + ) + + def test_initialize_loggers_with_handler_sets_propagate_false(): """ Test that the initialize_loggers_with_handler function sets propagate to False for all loggers From 97f722f5586ef00787733e09c3631c8fe52ba8c1 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Thu, 9 Apr 2026 10:09:58 +0530 Subject: [PATCH 010/130] feat(cost): add baseten model api pricing entries (#25358) Add Baseten Model API pricing entries for Nemotron, GLM, Kimi, GPT OSS, and DeepSeek models with validated model slugs. Include a focused regression test to assert provider and per-token pricing values. Made-with: Cursor --- ...odel_prices_and_context_window_backup.json | 66 +++++++++++++++++++ model_prices_and_context_window.json | 66 +++++++++++++++++++ tests/test_litellm/test_cost_calculator.py | 59 ++++++++++++----- 3 files changed, 175 insertions(+), 16 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 479231deac8..63ca003a26d 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -16934,6 +16934,72 @@ "mode": "chat", "output_cost_per_token": 1.2e-06 }, + "baseten/MiniMaxAI/MiniMax-M2.5": { + "input_cost_per_token": 3e-07, + "litellm_provider": "baseten", + "mode": "chat", + "output_cost_per_token": 1.2e-06 + }, + "baseten/nvidia/Nemotron-120B-A12B": { + "input_cost_per_token": 3e-07, + "litellm_provider": "baseten", + "mode": "chat", + "output_cost_per_token": 7.5e-07 + }, + "baseten/zai-org/GLM-5": { + "input_cost_per_token": 9.5e-07, + "litellm_provider": "baseten", + "mode": "chat", + "output_cost_per_token": 3.15e-06 + }, + "baseten/zai-org/GLM-4.7": { + "input_cost_per_token": 6e-07, + "litellm_provider": "baseten", + "mode": "chat", + "output_cost_per_token": 2.2e-06 + }, + "baseten/zai-org/GLM-4.6": { + "input_cost_per_token": 6e-07, + "litellm_provider": "baseten", + "mode": "chat", + "output_cost_per_token": 2.2e-06 + }, + "baseten/moonshotai/Kimi-K2.5": { + "input_cost_per_token": 6e-07, + "litellm_provider": "baseten", + "mode": "chat", + "output_cost_per_token": 3e-06 + }, + "baseten/moonshotai/Kimi-K2-Thinking": { + "input_cost_per_token": 6e-07, + "litellm_provider": "baseten", + "mode": "chat", + "output_cost_per_token": 2.5e-06 + }, + "baseten/moonshotai/Kimi-K2-Instruct-0905": { + "input_cost_per_token": 6e-07, + "litellm_provider": "baseten", + "mode": "chat", + "output_cost_per_token": 2.5e-06 + }, + "baseten/openai/gpt-oss-120b": { + "input_cost_per_token": 1e-07, + "litellm_provider": "baseten", + "mode": "chat", + "output_cost_per_token": 5e-07 + }, + "baseten/deepseek-ai/DeepSeek-V3.1": { + "input_cost_per_token": 5e-07, + "litellm_provider": "baseten", + "mode": "chat", + "output_cost_per_token": 1.5e-06 + }, + "baseten/deepseek-ai/DeepSeek-V3-0324": { + "input_cost_per_token": 7.7e-07, + "litellm_provider": "baseten", + "mode": "chat", + "output_cost_per_token": 7.7e-07 + }, "gmi/Qwen/Qwen3-VL-235B-A22B-Instruct-FP8": { "input_cost_per_token": 3e-07, "litellm_provider": "gmi", diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index e579ab692b2..90ff7d1103c 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -16934,6 +16934,72 @@ "mode": "chat", "output_cost_per_token": 1.2e-06 }, + "baseten/MiniMaxAI/MiniMax-M2.5": { + "input_cost_per_token": 3e-07, + "litellm_provider": "baseten", + "mode": "chat", + "output_cost_per_token": 1.2e-06 + }, + "baseten/nvidia/Nemotron-120B-A12B": { + "input_cost_per_token": 3e-07, + "litellm_provider": "baseten", + "mode": "chat", + "output_cost_per_token": 7.5e-07 + }, + "baseten/zai-org/GLM-5": { + "input_cost_per_token": 9.5e-07, + "litellm_provider": "baseten", + "mode": "chat", + "output_cost_per_token": 3.15e-06 + }, + "baseten/zai-org/GLM-4.7": { + "input_cost_per_token": 6e-07, + "litellm_provider": "baseten", + "mode": "chat", + "output_cost_per_token": 2.2e-06 + }, + "baseten/zai-org/GLM-4.6": { + "input_cost_per_token": 6e-07, + "litellm_provider": "baseten", + "mode": "chat", + "output_cost_per_token": 2.2e-06 + }, + "baseten/moonshotai/Kimi-K2.5": { + "input_cost_per_token": 6e-07, + "litellm_provider": "baseten", + "mode": "chat", + "output_cost_per_token": 3e-06 + }, + "baseten/moonshotai/Kimi-K2-Thinking": { + "input_cost_per_token": 6e-07, + "litellm_provider": "baseten", + "mode": "chat", + "output_cost_per_token": 2.5e-06 + }, + "baseten/moonshotai/Kimi-K2-Instruct-0905": { + "input_cost_per_token": 6e-07, + "litellm_provider": "baseten", + "mode": "chat", + "output_cost_per_token": 2.5e-06 + }, + "baseten/openai/gpt-oss-120b": { + "input_cost_per_token": 1e-07, + "litellm_provider": "baseten", + "mode": "chat", + "output_cost_per_token": 5e-07 + }, + "baseten/deepseek-ai/DeepSeek-V3.1": { + "input_cost_per_token": 5e-07, + "litellm_provider": "baseten", + "mode": "chat", + "output_cost_per_token": 1.5e-06 + }, + "baseten/deepseek-ai/DeepSeek-V3-0324": { + "input_cost_per_token": 7.7e-07, + "litellm_provider": "baseten", + "mode": "chat", + "output_cost_per_token": 7.7e-07 + }, "gmi/Qwen/Qwen3-VL-235B-A22B-Instruct-FP8": { "input_cost_per_token": 3e-07, "litellm_provider": "gmi", diff --git a/tests/test_litellm/test_cost_calculator.py b/tests/test_litellm/test_cost_calculator.py index 8f5c3ece0ca..0258eaabe33 100644 --- a/tests/test_litellm/test_cost_calculator.py +++ b/tests/test_litellm/test_cost_calculator.py @@ -67,6 +67,32 @@ def test_cost_calculator_with_response_cost_in_additional_headers(): assert result == 1000 +def test_baseten_model_api_pricing_entries(): + os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" + litellm.model_cost = litellm.get_model_cost_map(url="") + + expected_pricing = { + "baseten/nvidia/Nemotron-120B-A12B": (3e-07, 7.5e-07), + "baseten/MiniMaxAI/MiniMax-M2.5": (3e-07, 1.2e-06), + "baseten/zai-org/GLM-5": (9.5e-07, 3.15e-06), + "baseten/zai-org/GLM-4.7": (6e-07, 2.2e-06), + "baseten/zai-org/GLM-4.6": (6e-07, 2.2e-06), + "baseten/moonshotai/Kimi-K2.5": (6e-07, 3e-06), + "baseten/moonshotai/Kimi-K2-Thinking": (6e-07, 2.5e-06), + "baseten/moonshotai/Kimi-K2-Instruct-0905": (6e-07, 2.5e-06), + "baseten/openai/gpt-oss-120b": (1e-07, 5e-07), + "baseten/deepseek-ai/DeepSeek-V3.1": (5e-07, 1.5e-06), + "baseten/deepseek-ai/DeepSeek-V3-0324": (7.7e-07, 7.7e-07), + } + + for model_name, (input_cost, output_cost) in expected_pricing.items(): + model_info = litellm.model_cost.get(model_name) + assert model_info is not None, f"Missing model pricing entry: {model_name}" + assert model_info["litellm_provider"] == "baseten" + assert model_info["input_cost_per_token"] == input_cost + assert model_info["output_cost_per_token"] == output_cost + + def test_cost_calculator_with_usage(monkeypatch): os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" litellm.model_cost = litellm.get_model_cost_map(url="") @@ -123,6 +149,7 @@ def test_cost_calculator_with_usage(monkeypatch): # Invalidate caches after modifying litellm.model_cost from litellm.utils import _invalidate_model_cost_lowercase_map + _invalidate_model_cost_lowercase_map() result = response_cost_calculator( @@ -528,9 +555,7 @@ def test_azure_audio_output_cost_calculation(): model_info = litellm.get_model_info("azure/gpt-audio-2025-08-28") # Calculate expected cost - expected_input_cost = ( - model_info["input_cost_per_token"] * 17 # text tokens - ) + expected_input_cost = model_info["input_cost_per_token"] * 17 # text tokens expected_output_cost = ( model_info["output_cost_per_token"] * 110 # text tokens + model_info["output_cost_per_audio_token"] * 482 # audio tokens @@ -542,14 +567,14 @@ def test_azure_audio_output_cost_calculation(): wrong_total_cost = expected_input_cost + wrong_output_cost # Verify audio tokens are NOT charged at text rate (the bug) - assert abs(cost - wrong_total_cost) > 0.001, ( - "Bug: Audio tokens are being charged at text token rate" - ) + assert ( + abs(cost - wrong_total_cost) > 0.001 + ), "Bug: Audio tokens are being charged at text token rate" # Verify cost matches - assert abs(cost - expected_total_cost) < 0.0000001, ( - f"Expected cost {expected_total_cost}, got {cost}" - ) + assert ( + abs(cost - expected_total_cost) < 0.0000001 + ), f"Expected cost {expected_total_cost}, got {cost}" def test_default_image_cost_calculator(monkeypatch): @@ -1056,12 +1081,12 @@ def test_azure_ai_cache_cost_calculation(): print(f"Output cost: {output_cost}, Expected: {expected_output_cost}") print(f"Total cost: {total_cost}") - assert abs(input_cost - expected_input_cost) < 1e-10, ( - f"Input cost mismatch: got {input_cost}, expected {expected_input_cost}" - ) - assert abs(output_cost - expected_output_cost) < 1e-10, ( - f"Output cost mismatch: got {output_cost}, expected {expected_output_cost}" - ) + assert ( + abs(input_cost - expected_input_cost) < 1e-10 + ), f"Input cost mismatch: got {input_cost}, expected {expected_input_cost}" + assert ( + abs(output_cost - expected_output_cost) < 1e-10 + ), f"Output cost mismatch: got {output_cost}, expected {expected_output_cost}" def test_cost_discount_vertex_ai(): @@ -1929,7 +1954,9 @@ def test_gemini_implicit_caching_cost_calculation(): f"Cached tokens may not be using reduced pricing." ) - print("✅ Issue #16341 fix verified: Gemini implicit caching cost calculated correctly") + print( + "✅ Issue #16341 fix verified: Gemini implicit caching cost calculated correctly" + ) def test_additional_costs_only_for_azure_ai(): From 20ed120d1a845211b6d854a68f67e6249896d571 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 8 Apr 2026 22:13:48 -0700 Subject: [PATCH 011/130] [Fix] Let setSecureItem propagate storage errors to callers Remove the silent try/catch from setSecureItem so OAuth hooks can surface actionable "enable storage" guidance instead of a cryptic "state lost" error after the round-trip. Add a local try/catch in ChatUI where the storage write is non-critical. --- .../src/components/playground/chat_ui/ChatUI.tsx | 8 ++++++-- ui/litellm-dashboard/src/utils/secureStorage.ts | 6 +----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx b/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx index b93921a29eb..06a09ca2c37 100644 --- a/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx +++ b/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx @@ -348,8 +348,12 @@ const ChatUI: React.FC = ({ ]); useEffect(() => { - setSecureItem("apiKeySource", JSON.stringify(apiKeySource)); - setSecureItem("apiKey", apiKey); + try { + setSecureItem("apiKeySource", JSON.stringify(apiKeySource)); + setSecureItem("apiKey", apiKey); + } catch { + // Storage full or unavailable — non-critical, skip persisting. + } sessionStorage.setItem("endpointType", endpointType); sessionStorage.setItem("selectedTags", JSON.stringify(selectedTags)); sessionStorage.setItem("selectedVectorStores", JSON.stringify(selectedVectorStores)); diff --git a/ui/litellm-dashboard/src/utils/secureStorage.ts b/ui/litellm-dashboard/src/utils/secureStorage.ts index 6942368bcf2..183b572e737 100644 --- a/ui/litellm-dashboard/src/utils/secureStorage.ts +++ b/ui/litellm-dashboard/src/utils/secureStorage.ts @@ -18,11 +18,7 @@ function decode(encoded: string): string { } export function setSecureItem(key: string, value: string): void { - try { - window.sessionStorage.setItem(key, encode(value)); - } catch { - // Storage full or unavailable — silently ignore. - } + window.sessionStorage.setItem(key, encode(value)); } export function getSecureItem(key: string): string | null { From e42baeb5abf2ae37d64117dc70a9dc0138daf4d3 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 8 Apr 2026 23:52:25 -0700 Subject: [PATCH 012/130] [Refactor] UI - Virtual Keys: migrate regenerate key modal to AntD Replace Tremor components in the regenerate key modal with Ant Design equivalents and move the component to a new PascalCase file. The form layout now uses Row/Col to place Max Budget, TPM Limit, and RPM Limit on one row and Expire Key with Grace Period on another, reducing the vertical footprint. The success view shows an Alert banner, the key alias as secondary context, and the regenerated key in a monospace block with an inline primary Copy button. Also adds unit tests for the new component and updates the existing Playwright spec to match the new banner and button text. --- .../e2e_tests/tests/proxy-admin/keys.spec.ts | 5 +- .../organisms/RegenerateKeyModal.test.tsx | 271 ++++++++++++++++++ ...e_key_modal.tsx => RegenerateKeyModal.tsx} | 180 ++++++++---- .../KeyInfoView.handleKeyUpdate.test.tsx | 2 +- .../components/templates/key_info_view.tsx | 2 +- 5 files changed, 394 insertions(+), 66 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.test.tsx rename ui/litellm-dashboard/src/components/organisms/{regenerate_key_modal.tsx => RegenerateKeyModal.tsx} (60%) diff --git a/ui/litellm-dashboard/e2e_tests/tests/proxy-admin/keys.spec.ts b/ui/litellm-dashboard/e2e_tests/tests/proxy-admin/keys.spec.ts index aba37e25be3..24e8d4f4b32 100644 --- a/ui/litellm-dashboard/e2e_tests/tests/proxy-admin/keys.spec.ts +++ b/ui/litellm-dashboard/e2e_tests/tests/proxy-admin/keys.spec.ts @@ -63,8 +63,9 @@ test.describe("Proxy Admin - Keys", () => { await page.getByRole("button", { name: "Regenerate Key" }).click(); await page.getByRole("button", { name: "Regenerate", exact: true }).click(); - // Success shows "Copy Virtual Key" button in the regenerated key dialog - await expect(page.getByText("Copy Virtual Key")).toBeVisible({ timeout: 10_000 }); + // Success view shows the warning banner and a Copy button for the regenerated key + await expect(page.getByText("Save it now, you will not see it again")).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole("button", { name: /Copy/ })).toBeVisible({ timeout: 10_000 }); }); test("Update key TPM and RPM limits", async ({ page }) => { diff --git a/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.test.tsx b/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.test.tsx new file mode 100644 index 00000000000..1cb77bb9afd --- /dev/null +++ b/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.test.tsx @@ -0,0 +1,271 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders, screen, waitFor } from "../../../tests/test-utils"; +import { RegenerateKeyModal } from "./RegenerateKeyModal"; +import { KeyResponse } from "../key_team_helpers/key_list"; + +// Mock the networking call +const mockRegenerateKeyCall = vi.fn(); +vi.mock("../networking", () => ({ + regenerateKeyCall: (...args: unknown[]) => mockRegenerateKeyCall(...args), +})); + +// Mock CopyToClipboard to render a simple button +vi.mock("react-copy-to-clipboard", () => ({ + CopyToClipboard: ({ children, onCopy }: { children: React.ReactElement; onCopy: () => void }) => { + const React = require("react"); + return React.cloneElement(children, { onClick: onCopy }); + }, +})); + +const makeToken = (overrides: Partial = {}): KeyResponse => + ({ + token: "token-hash-123", + token_id: "token-id-123", + key_name: "sk-test-key", + key_alias: "my-test-key", + max_budget: 100, + tpm_limit: 5000, + rpm_limit: 500, + duration: "30d", + expires: "2026-12-31T00:00:00Z", + ...overrides, + }) as KeyResponse; + +describe("RegenerateKeyModal", () => { + const mockOnClose = vi.fn(); + const mockOnKeyUpdate = vi.fn(); + + const defaultProps = { + selectedToken: makeToken(), + visible: true, + onClose: mockOnClose, + onKeyUpdate: mockOnKeyUpdate, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render the modal with correct title", () => { + renderWithProviders(); + expect(screen.getByText("Regenerate Virtual Key")).toBeInTheDocument(); + }); + + it("should not render the modal when visible is false", () => { + renderWithProviders(); + expect(screen.queryByText("Regenerate Virtual Key")).not.toBeInTheDocument(); + }); + + it("should display the form with pre-filled values", () => { + renderWithProviders(); + + const keyAliasInput = screen.getByLabelText("Key Alias") as HTMLInputElement; + expect(keyAliasInput).toBeDisabled(); + expect(keyAliasInput).toHaveValue("my-test-key"); + }); + + it("should display the current expiry when token has expires", () => { + renderWithProviders(); + expect(screen.getByText(/Current expiry:/)).toBeInTheDocument(); + }); + + it("should display 'Never' when token has no expires", () => { + renderWithProviders( + , + ); + expect(screen.getByText("Current expiry: Never")).toBeInTheDocument(); + }); + + it("should show Cancel and Regenerate buttons in form view", () => { + renderWithProviders(); + expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Regenerate/ })).toBeInTheDocument(); + }); + + it("should call onClose when Cancel is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(mockOnClose).toHaveBeenCalledOnce(); + }); + + it("should call onClose when the X close button is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: "Close" })); + expect(mockOnClose).toHaveBeenCalledOnce(); + }); + + it("should render form fields for budget and rate limits", () => { + renderWithProviders(); + + expect(screen.getByText("Max Budget (USD)")).toBeInTheDocument(); + expect(screen.getByText("TPM Limit")).toBeInTheDocument(); + expect(screen.getByText("RPM Limit")).toBeInTheDocument(); + }); + + it("should render duration and grace period fields", () => { + renderWithProviders(); + + expect(screen.getByText("Expire Key")).toBeInTheDocument(); + expect(screen.getByText("Grace Period")).toBeInTheDocument(); + }); + + it("should display grace period recommendation text", () => { + renderWithProviders(); + expect( + screen.getByText("Recommended: 24h to 72h for production keys"), + ).toBeInTheDocument(); + }); + + it("should call regenerateKeyCall and show success view on successful regeneration", async () => { + const user = userEvent.setup(); + mockRegenerateKeyCall.mockResolvedValue({ + key: "sk-new-regenerated-key", + token: "new-token-hash", + }); + + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /Regenerate/ })); + + await waitFor(() => { + expect(mockRegenerateKeyCall).toHaveBeenCalledOnce(); + }); + + await waitFor(() => { + expect(screen.getByText("sk-new-regenerated-key")).toBeInTheDocument(); + }); + + expect(screen.getByText(/will not see it again/)).toBeInTheDocument(); + }); + + it("should show Close button after successful regeneration", async () => { + const user = userEvent.setup(); + mockRegenerateKeyCall.mockResolvedValue({ + key: "sk-new-regenerated-key", + token: "new-token-hash", + }); + + renderWithProviders(); + await user.click(screen.getByRole("button", { name: /Regenerate/ })); + + await waitFor(() => { + expect(screen.getByText("sk-new-regenerated-key")).toBeInTheDocument(); + }); + + // Should show Close buttons (footer + modal X), not Cancel/Regenerate + const closeButtons = screen.getAllByRole("button", { name: "Close" }); + expect(closeButtons.length).toBeGreaterThanOrEqual(1); + expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Regenerate/ })).not.toBeInTheDocument(); + }); + + it("should show Copy Virtual Key button after successful regeneration", async () => { + const user = userEvent.setup(); + mockRegenerateKeyCall.mockResolvedValue({ + key: "sk-new-regenerated-key", + token: "new-token-hash", + }); + + renderWithProviders(); + await user.click(screen.getByRole("button", { name: /Regenerate/ })); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /Copy/ })).toBeInTheDocument(); + }); + }); + + it("should call onKeyUpdate with updated data after successful regeneration", async () => { + const user = userEvent.setup(); + mockRegenerateKeyCall.mockResolvedValue({ + key: "sk-new-regenerated-key", + token: "new-token-hash", + }); + + renderWithProviders(); + await user.click(screen.getByRole("button", { name: /Regenerate/ })); + + await waitFor(() => { + expect(mockOnKeyUpdate).toHaveBeenCalledOnce(); + }); + + const updateCall = mockOnKeyUpdate.mock.calls[0][0]; + expect(updateCall.key_name).toBe("sk-new-regenerated-key"); + }); + + it("should display key alias in success view", async () => { + const user = userEvent.setup(); + mockRegenerateKeyCall.mockResolvedValue({ + key: "sk-new-regenerated-key", + token: "new-token-hash", + }); + + renderWithProviders(); + await user.click(screen.getByRole("button", { name: /Regenerate/ })); + + await waitFor(() => { + expect(screen.getByText("my-test-key")).toBeInTheDocument(); + }); + }); + + it("should display 'No alias set' when key has no alias", async () => { + const user = userEvent.setup(); + mockRegenerateKeyCall.mockResolvedValue({ + key: "sk-new-regenerated-key", + token: "new-token-hash", + }); + + renderWithProviders( + , + ); + await user.click(screen.getByRole("button", { name: /Regenerate/ })); + + await waitFor(() => { + expect(screen.getByText("No alias set")).toBeInTheDocument(); + }); + }); + + it("should not call regenerateKeyCall when selectedToken is null", async () => { + const user = userEvent.setup(); + renderWithProviders( + , + ); + + // The form shouldn't even be populated, but we check the button doesn't trigger a call + const regenerateBtn = screen.queryByRole("button", { name: /Regenerate/ }); + if (regenerateBtn) { + await user.click(regenerateBtn); + } + + expect(mockRegenerateKeyCall).not.toHaveBeenCalled(); + }); + + it("should pass the correct token identifier to regenerateKeyCall", async () => { + const user = userEvent.setup(); + mockRegenerateKeyCall.mockResolvedValue({ + key: "sk-new-key", + token: "new-hash", + }); + + renderWithProviders(); + await user.click(screen.getByRole("button", { name: /Regenerate/ })); + + await waitFor(() => { + expect(mockRegenerateKeyCall).toHaveBeenCalledWith( + "123", // accessToken from mocked useAuthorized + "token-hash-123", // selectedToken.token + expect.any(Object), + ); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/organisms/regenerate_key_modal.tsx b/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.tsx similarity index 60% rename from ui/litellm-dashboard/src/components/organisms/regenerate_key_modal.tsx rename to ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.tsx index 2fad101c20f..e888713fe05 100644 --- a/ui/litellm-dashboard/src/components/organisms/regenerate_key_modal.tsx +++ b/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.tsx @@ -1,6 +1,6 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; -import { Button, Col, Grid, Text, TextInput, Title } from "@tremor/react"; -import { Form, InputNumber, Modal } from "antd"; +import { CopyOutlined, SyncOutlined } from "@ant-design/icons"; +import { Alert, Button, Col, Form, Input, InputNumber, Modal, Row, Space, Typography } from "antd"; import { add } from "date-fns"; import { useEffect, useState } from "react"; import { CopyToClipboard } from "react-copy-to-clipboard"; @@ -8,6 +8,10 @@ import { KeyResponse } from "../key_team_helpers/key_list"; import NotificationManager from "../molecules/notifications_manager"; import { regenerateKeyCall } from "../networking"; +const { Text } = Typography; + + + interface RegenerateKeyModalProps { selectedToken: KeyResponse | null; visible: boolean; @@ -151,6 +155,7 @@ export function RegenerateKeyModal({ selectedToken, visible, onClose, onKeyUpdat title="Regenerate Virtual Key" open={visible} onCancel={handleClose} + width={520} footer={ regeneratedKey ? [ @@ -159,46 +164,69 @@ export function RegenerateKeyModal({ selectedToken, visible, onClose, onKeyUpdat , ] : [ - , - , + + + + , ] } > {regeneratedKey ? ( - - Regenerated Key - -

- Please replace your old key with the new key generated. For security reasons,{" "} - you will not be able to view it again through your LiteLLM account. If you lose this secret key, - you will need to generate a new one. -

- - - Key Alias: -
-
{selectedToken?.key_alias || "No alias set"}
-
- New Virtual Key: -
-
{regeneratedKey}
+
+ + +
+
Key Alias
+
+ {selectedToken?.key_alias || "No alias set"}
+
+ +
+ + {regeneratedKey} + NotificationManager.success("Virtual Key copied to clipboard")} > - + - - +
+
) : (
{ if ("duration" in changedValues) { setRegenerateFormData((prev: { duration?: string }) => ({ ...prev, duration: changedValues.duration })); @@ -206,41 +234,69 @@ export function RegenerateKeyModal({ selectedToken, visible, onClose, onKeyUpdat }} > - + - - - - - - - - - - - - -
- Current expiry: {selectedToken?.expires ? new Date(selectedToken.expires).toLocaleString() : "Never"} -
- {newExpiryTime &&
New expiry: {newExpiryTime}
} - - - -
- Recommended: 24h to 72h for production keys to allow seamless client migration. -
+ + + + + + + + + + + + + + + + + + + + + + + + Current expiry: {selectedToken?.expires ? new Date(selectedToken.expires).toLocaleString() : "Never"} + + {newExpiryTime && ( +
+ New expiry: {newExpiryTime} +
+ )} + + } + > + +
+ + + + Recommended: 24h to 72h for production keys + + } + rules={[ + { + pattern: /^(\d+(s|m|h|d|w|mo))?$/, + message: "Must be a duration like 30s, 30m, 24h, 2d, 1w, or 1mo", + }, + ]} + > + + + +
)} diff --git a/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx b/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx index 590864637af..abbc92f0210 100644 --- a/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx @@ -196,7 +196,7 @@ vi.mock("lucide-react", async () => { }); // Heavy children -> async factories & local React -vi.mock("../organisms/regenerate_key_modal", async () => { +vi.mock("../organisms/RegenerateKeyModal", async () => { const React = await import("react"); function RegenerateKeyModal() { return null; diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx index 24e3e18b93c..5b5e7722c09 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -20,7 +20,7 @@ import NotificationManager from "../molecules/notifications_manager"; import { getPolicyInfoWithGuardrails, keyDeleteCall, keyUpdateCall } from "../networking"; import { useResetKeySpend } from "@/app/(dashboard)/hooks/keys/useResetKeySpend"; import ObjectPermissionsView from "../object_permissions_view"; -import { RegenerateKeyModal } from "../organisms/regenerate_key_modal"; +import { RegenerateKeyModal } from "../organisms/RegenerateKeyModal"; import { parseErrorMessage } from "../shared/errorUtils"; import { KeyEditView } from "./key_edit_view"; From cb057ad44bced6d530bd6092ca3429f046cbb8dc Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Thu, 9 Apr 2026 18:48:38 +0530 Subject: [PATCH 013/130] fix(websearch_interception): ensure spend/cost logging runs when stream=True MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deployment hook now converts stream=True→False in wrapper_async's scope so the streaming early-return path is skipped and logging executes. logging_obj.stream is synced after the hook, and the original stream intent is recovered for the short-circuit path. Made-with: Cursor --- .../websearch_interception/handler.py | 12 +++-- .../messages/handler.py | 8 ++-- litellm/utils.py | 5 ++ .../test_websearch_interception_handler.py | 48 ++++++++++++++++++- 4 files changed, 64 insertions(+), 9 deletions(-) diff --git a/litellm/integrations/websearch_interception/handler.py b/litellm/integrations/websearch_interception/handler.py index 2e5a8734085..30fd55a3e9d 100644 --- a/litellm/integrations/websearch_interception/handler.py +++ b/litellm/integrations/websearch_interception/handler.py @@ -230,8 +230,15 @@ class WebSearchInterceptionLogger(CustomLogger): # Keep other tools as-is converted_tools.append(tool) - # Update tools in-place and return full kwargs kwargs["tools"] = converted_tools + + if kwargs.get("stream"): + verbose_logger.debug( + "WebSearchInterception: deployment hook converting stream=True to stream=False" + ) + kwargs["stream"] = False + kwargs["_websearch_interception_converted_stream"] = True + return kwargs @classmethod @@ -344,13 +351,12 @@ class WebSearchInterceptionLogger(CustomLogger): else: converted_tools.append(tool) - # Update kwargs with converted tools kwargs["tools"] = converted_tools verbose_logger.debug( f"WebSearchInterception: Tools after conversion: {[t.get('name') for t in converted_tools]}" ) - # Convert stream=True to stream=False for WebSearch interception + # Also convert here for direct callers that bypass the deployment hook. if kwargs.get("stream"): verbose_logger.debug( "WebSearchInterception: Converting stream=True to stream=False" diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py index d117d74e4f7..3da118fd349 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py @@ -187,11 +187,9 @@ async def anthropic_messages( """ Async: Make llm api request in Anthropic /messages API spec """ - # Save original stream flag before pre-request hooks can convert it. - # The websearch interception hook converts stream=True → stream=False - # for the agentic loop, but the short-circuit path needs to know - # whether the caller originally requested streaming. - original_stream = stream + original_stream = stream or kwargs.get( + "_websearch_interception_converted_stream", False + ) # Execute pre-request hooks to allow CustomLoggers to modify request request_kwargs = await _execute_pre_request_hooks( diff --git a/litellm/utils.py b/litellm/utils.py index f902644e760..38be20488fa 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -1811,6 +1811,11 @@ def client(original_function): # noqa: PLR0915 if modified_kwargs is not None: kwargs = modified_kwargs + # Sync logging_obj.stream after deployment hooks (they may convert it). + _hook_stream = kwargs.get("stream") + if _hook_stream is not None and logging_obj.stream != _hook_stream: + logging_obj.stream = _hook_stream + kwargs["litellm_logging_obj"] = logging_obj ## LOAD CREDENTIALS load_credentials_from_list(kwargs) diff --git a/tests/test_litellm/integrations/websearch_interception/test_websearch_interception_handler.py b/tests/test_litellm/integrations/websearch_interception/test_websearch_interception_handler.py index 020c171a666..4afb948e47f 100644 --- a/tests/test_litellm/integrations/websearch_interception/test_websearch_interception_handler.py +++ b/tests/test_litellm/integrations/websearch_interception/test_websearch_interception_handler.py @@ -4,7 +4,7 @@ Unit tests for WebSearch Interception Handler Tests the WebSearchInterceptionLogger class and helper functions. """ -from unittest.mock import Mock +from unittest.mock import MagicMock, Mock import pytest @@ -273,3 +273,49 @@ async def test_async_pre_call_deployment_hook_provider_derived_from_model_name() # Full kwargs preserved assert result["model"] == "openai/gpt-4o-mini" assert result["api_key"] == "fake-key" + + +@pytest.mark.asyncio +async def test_deployment_hook_converts_stream_and_logging_obj_syncs(): + """ + Regression test: websearch interception with stream=True must not skip logging. + + Before the fix, the stream conversion only happened in async_pre_request_hook + (inside the anthropic_messages function scope). wrapper_async still saw + stream=True, took the streaming early-return path, and skipped all spend/cost + logging. The fix moves stream conversion into the deployment hook so + wrapper_async sees stream=False, and then syncs logging_obj.stream. + + This test verifies: + 1. The deployment hook sets stream=False and the converted flag. + 2. wrapper_async syncs logging_obj.stream after the hook runs. + """ + logger = WebSearchInterceptionLogger(enabled_providers=["bedrock"]) + + kwargs = { + "model": "anthropic.claude-opus-4-6-20250219-v1:0", + "messages": [{"role": "user", "content": "Search for LiteLLM"}], + "tools": [ + {"type": "web_search_20250305", "name": "web_search", "max_uses": 3}, + ], + "custom_llm_provider": "bedrock", + "stream": True, + } + + result = await logger.async_pre_call_deployment_hook(kwargs=kwargs, call_type=None) + + assert result is not None + assert result["stream"] is False + assert result["_websearch_interception_converted_stream"] is True + + # Simulate what wrapper_async does after the deployment hook: + # logging_obj.stream was set to True during function_setup (before hook). + # After the hook, wrapper_async must sync it. + logging_obj = MagicMock() + logging_obj.stream = True # original value from function_setup + + _hook_stream = result.get("stream") + if _hook_stream is not None and logging_obj.stream != _hook_stream: + logging_obj.stream = _hook_stream + + assert logging_obj.stream is False From cd9c511df65f89d2ca4c9c62cabe600e87f42e3a Mon Sep 17 00:00:00 2001 From: michelligabriele Date: Thu, 9 Apr 2026 16:22:27 +0200 Subject: [PATCH 014/130] feat(proxy): add credential overrides per team/project via model_config metadata (#24438) --- .../docs/proxy/credential_routing.md | 274 +++++++++ docs/my-website/sidebars.js | 3 +- litellm/__init__.py | 1 + litellm/proxy/litellm_pre_call_utils.py | 181 ++++++ .../proxy/test_litellm_pre_call_utils.py | 543 ++++++++++++++++++ 5 files changed, 1001 insertions(+), 1 deletion(-) create mode 100644 docs/my-website/docs/proxy/credential_routing.md diff --git a/docs/my-website/docs/proxy/credential_routing.md b/docs/my-website/docs/proxy/credential_routing.md new file mode 100644 index 00000000000..2af57c6b496 --- /dev/null +++ b/docs/my-website/docs/proxy/credential_routing.md @@ -0,0 +1,274 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# Per-Team/Project Credential Routing + +Route the same model to different LLM provider endpoints (e.g. different Azure instances) based on which team or project makes the request. + +## Overview + +In multi-tenant deployments, different teams often need the same model name (e.g., `gpt-4`) to hit different provider endpoints — for example, separate Azure OpenAI instances per business unit for cost isolation, data residency, or rate limit separation. + +**Credential routing** lets you configure this in team/project metadata using the existing [credentials table](./ui_credentials.md), without duplicating model definitions or creating separate model groups per team. + +``` +Hotel Team → gpt-4 → https://hotel-eastus.openai.azure.com/ +Flight Team → gpt-4 → https://flight-centralus.openai.azure.com/ +``` + +### Precedence Chain + +When a request comes in, the system walks this precedence chain (first match wins): + +1. **Clientside credentials** — `api_base`/`api_key` passed in the request body ([docs](./clientside_auth.md)) +2. **Project model-specific** — override for this exact model in the project's `model_config` +3. **Project default** — `defaultconfig` in the project's `model_config` +4. **Team model-specific** — override for this exact model in the team's `model_config` +5. **Team default** — `defaultconfig` in the team's `model_config` +6. **Deployment default** — the model's `litellm_params` as configured in `config.yaml` + +## Quick Start + +### Step 1: Create Credentials + +Store your Azure endpoint credentials in the credentials table. You can do this via the [UI](./ui_credentials.md) or API: + +```bash showLineNumbers +# Create credential for Hotel team's Azure endpoint +curl -X POST 'http://0.0.0.0:4000/credentials' \ +-H 'Authorization: Bearer sk-1234' \ +-H 'Content-Type: application/json' \ +-d '{ + "credential_name": "hotel-azure-eastus", + "credential_values": { + "api_base": "https://hotel-eastus.openai.azure.com/", + "api_key": "sk-azure-hotel-key-xxx" + } +}' +``` + +```bash showLineNumbers +# Create credential for Flight team's Azure endpoint +curl -X POST 'http://0.0.0.0:4000/credentials' \ +-H 'Authorization: Bearer sk-1234' \ +-H 'Content-Type: application/json' \ +-d '{ + "credential_name": "flight-azure-centralus", + "credential_values": { + "api_base": "https://flight-centralus.openai.azure.com/", + "api_key": "sk-azure-flight-key-xxx" + } +}' +``` + +### Step 2: Set `model_config` on Teams + +Add a `model_config` key to the team's metadata referencing the credential by name: + +```bash showLineNumbers +# Hotel team — default Azure endpoint for all models +curl -X PATCH 'http://0.0.0.0:4000/team/update' \ +-H 'Authorization: Bearer sk-1234' \ +-H 'Content-Type: application/json' \ +-d '{ + "team_id": "hotel-team-id", + "metadata": { + "model_config": { + "defaultconfig": { + "azure": { + "litellm_credentials": "hotel-azure-eastus" + } + } + } + } +}' +``` + +```bash showLineNumbers +# Flight team — default Azure endpoint for all models +curl -X PATCH 'http://0.0.0.0:4000/team/update' \ +-H 'Authorization: Bearer sk-1234' \ +-H 'Content-Type: application/json' \ +-d '{ + "team_id": "flight-team-id", + "metadata": { + "model_config": { + "defaultconfig": { + "azure": { + "litellm_credentials": "flight-azure-centralus" + } + } + } + } +}' +``` + +### Step 3: Make Requests + +Requests are automatically routed to the correct Azure endpoint based on the API key's team: + +```bash showLineNumbers +# Request using Hotel team's API key → routes to hotel-eastus.openai.azure.com +curl http://localhost:4000/v1/chat/completions \ +-H 'Content-Type: application/json' \ +-H 'Authorization: Bearer sk-hotel-team-key' \ +-d '{"model": "gpt-4", "messages": [{"role": "user", "content": "Hello"}]}' + +# Request using Flight team's API key → routes to flight-centralus.openai.azure.com +curl http://localhost:4000/v1/chat/completions \ +-H 'Content-Type: application/json' \ +-H 'Authorization: Bearer sk-flight-team-key' \ +-d '{"model": "gpt-4", "messages": [{"role": "user", "content": "Hello"}]}' +``` + +## Per-Model Overrides + +You can set different credentials for specific models while keeping a default for everything else: + +```bash showLineNumbers +curl -X PATCH 'http://0.0.0.0:4000/team/update' \ +-H 'Authorization: Bearer sk-1234' \ +-H 'Content-Type: application/json' \ +-d '{ + "team_id": "hotel-team-id", + "metadata": { + "model_config": { + "defaultconfig": { + "azure": { + "litellm_credentials": "hotel-azure-eastus" + } + }, + "gpt-4": { + "azure": { + "litellm_credentials": "hotel-azure-westus" + } + } + } + } +}' +``` + +With this config: +- `gpt-4` requests → `hotel-azure-westus` credential (model-specific) +- All other models → `hotel-azure-eastus` credential (default) + +## Project-Level Overrides + +Projects inherit their team's `model_config` but can override at the project level. Project overrides take precedence over team overrides. + +```bash showLineNumbers +# Project overrides the team default for all models +curl -X PATCH 'http://0.0.0.0:4000/project/update' \ +-H 'Authorization: Bearer sk-1234' \ +-H 'Content-Type: application/json' \ +-d '{ + "project_id": "hotel-rec-app-id", + "metadata": { + "model_config": { + "defaultconfig": { + "azure": { + "litellm_credentials": "hotel-rec-azure" + } + }, + "gpt-4-vision": { + "azure": { + "litellm_credentials": "hotel-rec-vision" + } + } + } + } +}' +``` + +### Full Example: Hotel Team with Two Projects + +**Setup:** +- **Hotel Team**: default `hotel-azure-eastus`, GPT-4 override to `hotel-azure-westus` +- **Hotel Rec App** (project): default `hotel-rec-azure`, GPT-4-Vision override to `hotel-rec-vision` +- **Hotel Review App** (project): no overrides — inherits team config + +**Resolution:** + +| Request | Resolved Credential | Why | +|---|---|---| +| Hotel Rec App → `gpt-4` | `hotel-rec-azure` | Project default (no project model-specific match for gpt-4) | +| Hotel Rec App → `gpt-4-vision` | `hotel-rec-vision` | Project model-specific | +| Hotel Review App → `gpt-3.5` | `hotel-azure-eastus` | Team default (no project config) | +| Hotel Review App → `gpt-4` | `hotel-azure-westus` | Team model-specific | + +## `model_config` Schema + +The `model_config` key is a JSON object in team/project `metadata`: + +```json +{ + "model_config": { + "defaultconfig": { + "": { + "litellm_credentials": "" + } + }, + "": { + "": { + "litellm_credentials": "" + } + } + } +} +``` + +| Field | Description | +|---|---| +| `defaultconfig` | Fallback credential for any model not explicitly listed | +| `` | Model-specific override — must match the LiteLLM model group name | +| `` | Provider key (e.g. `azure`, `openai`, `bedrock`). When the model name includes a provider prefix (e.g. `azure/gpt-4`), the system prefers the matching provider key | +| `litellm_credentials` | Name of a credential in the [credentials table](./ui_credentials.md) | + +### Credential Values + +The referenced credential can contain any combination of: + +| Key | Description | +|---|---| +| `api_base` | Provider endpoint URL | +| `api_key` | API key for the provider | +| `api_version` | API version (e.g. for Azure) | + +Only keys present in the credential are applied. Keys already in the request (e.g. clientside `api_version`) are never overwritten. + +## Enabling the Feature + +This feature is **disabled by default** and must be explicitly enabled. To enable it: + + + + + +```yaml +litellm_settings: + enable_model_config_credential_overrides: true +``` + + + + + +```bash +export LITELLM_ENABLE_MODEL_CONFIG_CREDENTIAL_OVERRIDES=true +``` + + + + + +:::info +The feature flag must be enabled before `model_config` entries in team/project metadata take effect. Without it, credential routing is completely inert — no metadata is read, no credentials are resolved. +::: + +## Related Documentation + +- [Adding LLM Credentials](./ui_credentials.md) — Create and manage reusable credentials +- [Project Management](./project_management.md) — Project hierarchy and API +- [Team Budgets](./team_budgets.md) — Team-level budget management +- [Clientside LLM Credentials](./clientside_auth.md) — Passing credentials in the request body +- [Credential Usage Tracking](./credential_usage_tracking.md) — Track spend by credential diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index ab8f257c7d5..300abc83ca9 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -563,7 +563,8 @@ const sidebars = { "proxy/model_access", "proxy/model_access_groups", "proxy/access_groups", - "proxy/team_model_add" + "proxy/team_model_add", + "proxy/credential_routing" ] }, { diff --git a/litellm/__init__.py b/litellm/__init__.py index d4418c661a3..64c60ca3374 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -318,6 +318,7 @@ return_response_headers: bool = ( False # get response headers from LLM Api providers - example x-remaining-requests, ) enable_json_schema_validation: bool = False +enable_model_config_credential_overrides: bool = False enable_key_alias_format_validation: bool = ( False # opt-in validation of key_alias format on /key/generate and /key/update ) diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index ece72b1060e..2b8c16ed12d 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -10,6 +10,7 @@ from starlette.datastructures import Headers import litellm from litellm._logging import verbose_logger, verbose_proxy_logger from litellm._service_logger import ServiceLogging +from litellm.litellm_core_utils.credential_accessor import CredentialAccessor from litellm.litellm_core_utils.safe_json_loads import safe_json_loads from litellm.proxy._types import ( AddTeamCallback, @@ -1264,6 +1265,9 @@ async def add_litellm_data_to_request( # noqa: PLR0915 user_api_key_dict=user_api_key_dict, ) + # Save pre-alias model name for credential override lookup + _pre_alias_model = data.get("model") + # Team Model Aliases _update_model_if_team_alias_exists( data=data, @@ -1280,6 +1284,14 @@ async def add_litellm_data_to_request( # noqa: PLR0915 "[PROXY] returned data from litellm_pre_call_utils: %s", data ) + # Team/Project credential overrides from model_config + # Placed after the debug log to avoid leaking credential secrets in logs + _apply_credential_overrides_from_model_config( + data=data, + user_api_key_dict=user_api_key_dict, + pre_alias_model_name=_pre_alias_model, + ) + ## ENFORCED PARAMS CHECK # loop through each enforced param # example enforced_params ['user', 'metadata', 'metadata.generation_name'] @@ -1407,6 +1419,175 @@ def _update_model_if_key_alias_exists( return +def _apply_credential_overrides_from_model_config( + data: dict, + user_api_key_dict: UserAPIKeyAuth, + pre_alias_model_name: Optional[str] = None, +) -> None: + """ + Walk the model_config precedence chain in team/project metadata. + If a matching credential is found, set api_base/api_key/api_version on data + so they override deployment defaults in the router. + + Precedence (highest to lowest): + 1. Clientside credentials (already in data — skip if present) + 2. Project model-specific override + 3. Project default override (defaultconfig) + 4. Team model-specific override + 5. Team default override (defaultconfig) + 6. Deployment default (no action needed) + """ + # Feature flag gate — disabled by default, opt in with litellm.enable_model_config_credential_overrides = True + if not litellm.enable_model_config_credential_overrides: + return + + # Respect clientside credentials — highest precedence + if data.get("api_base") is not None or data.get("api_key") is not None: + return + + model_name = data.get("model") + if not model_name: + return + + project_metadata = user_api_key_dict.project_metadata or {} + team_metadata = user_api_key_dict.team_metadata or {} + + project_model_config = project_metadata.get("model_config") + team_model_config = team_metadata.get("model_config") + + if not project_model_config and not team_model_config: + return + + # Extract provider hint from model name (e.g. "azure/gpt-4" -> "azure") + provider: Optional[str] = None + if "/" in model_name: + provider = model_name.split("/", 1)[0] + + credential_name = _resolve_credential_from_model_config( + model_name=model_name, + project_model_config=project_model_config, + team_model_config=team_model_config, + pre_alias_model_name=pre_alias_model_name, + provider=provider, + ) + + if not credential_name: + return + + credential_values = CredentialAccessor.get_credential_values(credential_name) + if not credential_values: + _safe_cred = str(credential_name).replace("\n", "").replace("\r", "") + verbose_proxy_logger.warning( + "model_config references credential '%s' but it was not found or has no values", + _safe_cred, + ) + return + + # Apply credential overrides only for keys not already in the request + for key in ("api_base", "api_key", "api_version"): + if key in credential_values and key not in data: + data[key] = credential_values[key] + + _safe_model = str(model_name).replace("\n", "").replace("\r", "") + _safe_cred = str(credential_name).replace("\n", "").replace("\r", "") + verbose_proxy_logger.debug( + "Applied credential override '%s' for model '%s'", + _safe_cred, + _safe_model, + ) + + +def _resolve_credential_from_model_config( + model_name: str, + project_model_config: Optional[dict], + team_model_config: Optional[dict], + pre_alias_model_name: Optional[str] = None, + provider: Optional[str] = None, +) -> Optional[str]: + """ + Walk the precedence chain and return the first matching credential name. + + Checks (in order): + 1. project_model_config[model_name][provider] — project model-specific + 2. project_model_config[pre_alias_model_name][provider] — project pre-alias + 3. project_model_config["defaultconfig"][provider] — project default + 4. team_model_config[model_name][provider] — team model-specific + 5. team_model_config[pre_alias_model_name][provider] — team pre-alias + 6. team_model_config["defaultconfig"][provider] — team default + + When a model-specific entry exists but contains no litellm_credentials, + the function falls through to defaultconfig. This is intentional — + an entry without litellm_credentials is treated as incomplete config, + not as an explicit "no override" signal. + """ + # Build the list of model names to try (post-alias first, then pre-alias) + model_names_to_try = [model_name] + if pre_alias_model_name and pre_alias_model_name != model_name: + model_names_to_try.append(pre_alias_model_name) + + for model_config in (project_model_config, team_model_config): + if not model_config or not isinstance(model_config, dict): + continue + + # Model-specific check (try resolved name, then pre-alias name) + for name in model_names_to_try: + model_entry = model_config.get(name) + if model_entry: + credential_name = _extract_credential_from_entry( + model_entry, provider=provider + ) + if credential_name: + return credential_name + _safe_name = str(name).replace("\n", "").replace("\r", "") + verbose_proxy_logger.debug( + "model_config entry '%s' found but has no litellm_credentials, " + "trying next candidate", + _safe_name, + ) + + # Default check + default_entry = model_config.get("defaultconfig") + if default_entry: + credential_name = _extract_credential_from_entry( + default_entry, provider=provider + ) + if credential_name: + return credential_name + + return None + + +def _extract_credential_from_entry( + entry: dict, provider: Optional[str] = None +) -> Optional[str]: + """ + Extract litellm_credentials from a model_config entry. + + Entry structure: {"azure": {"litellm_credentials": "name"}, ...} + + When provider is given (e.g. "azure"), tries an exact provider match first. + Falls back to the first credential found across all provider keys. + """ + if not isinstance(entry, dict): + return None + + # Prefer exact provider match when provider hint is available + if provider and provider in entry: + provider_config = entry[provider] + if isinstance(provider_config, dict): + credential_name = provider_config.get("litellm_credentials") + if credential_name: + return credential_name + + # Fall back to first available provider + for provider_config in entry.values(): + if isinstance(provider_config, dict): + credential_name = provider_config.get("litellm_credentials") + if credential_name: + return credential_name + return None + + def _get_enforced_params( general_settings: Optional[dict], user_api_key_dict: UserAPIKeyAuth ) -> Optional[list]: 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 04af5cd0086..cf7e71b14d4 100644 --- a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py +++ b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py @@ -13,14 +13,18 @@ from litellm.proxy._types import TeamCallbackMetadata, UserAPIKeyAuth from litellm.proxy.litellm_pre_call_utils import ( KeyAndTeamLoggingSettings, LiteLLMProxyRequestSetup, + _apply_credential_overrides_from_model_config, + _extract_credential_from_entry, _get_dynamic_logging_metadata, _get_enforced_params, _get_metadata_variable_name, + _resolve_credential_from_model_config, _update_model_if_key_alias_exists, add_guardrails_from_policy_engine, add_litellm_data_to_request, check_if_token_is_service_account, ) +from litellm.types.utils import CredentialItem sys.path.insert( 0, os.path.abspath("../../..") @@ -1912,3 +1916,542 @@ async def test_bearer_token_not_in_debug_logs(): f"Bearer token leaked in debug logs. " f"Found token in log output:\n{log_output[:500]}" ) + + +# ============================================================================ +# Tests for credential overrides from model_config (team/project metadata) +# ============================================================================ + + +@pytest.fixture() +def setup_test_credentials(): + """Populate litellm.credential_list with test credentials and enable feature flag, clean up after.""" + original = litellm.credential_list[:] + original_flag = litellm.enable_model_config_credential_overrides + litellm.enable_model_config_credential_overrides = True + litellm.credential_list.extend( + [ + CredentialItem( + credential_name="hotel-azure-eastus", + credential_info={}, + credential_values={ + "api_base": "https://hotel-eastus.openai.azure.com/", + "api_key": "key-hotel-eastus", + }, + ), + CredentialItem( + credential_name="hotel-azure-westus", + credential_info={}, + credential_values={ + "api_base": "https://hotel-westus.openai.azure.com/", + "api_key": "key-hotel-westus", + }, + ), + CredentialItem( + credential_name="hotel-rec-azure", + credential_info={}, + credential_values={ + "api_base": "https://hotel-rec-app.openai.azure.com/", + "api_key": "key-hotel-rec", + }, + ), + CredentialItem( + credential_name="hotel-rec-vision", + credential_info={}, + credential_values={ + "api_base": "https://hotel-rec-vision.openai.azure.com/", + "api_key": "key-hotel-rec-vision", + "api_version": "2024-06-01", + }, + ), + CredentialItem( + credential_name="flight-azure-centralus", + credential_info={}, + credential_values={ + "api_base": "https://flight-centralus.openai.azure.com/", + "api_key": "key-flight-centralus", + }, + ), + ] + ) + yield + litellm.credential_list[:] = original + litellm.enable_model_config_credential_overrides = original_flag + + +# --- Unit tests for _extract_credential_from_entry --- + + +def test_extract_credential_from_entry_azure(): + entry = {"azure": {"litellm_credentials": "my-cred"}} + assert _extract_credential_from_entry(entry) == "my-cred" + + +def test_extract_credential_from_entry_no_credential(): + entry = {"azure": {"some_other_key": "value"}} + assert _extract_credential_from_entry(entry) is None + + +def test_extract_credential_from_entry_empty(): + assert _extract_credential_from_entry({}) is None + + +def test_extract_credential_from_entry_non_dict_value(): + entry = {"azure": "not-a-dict"} + assert _extract_credential_from_entry(entry) is None + + +def test_extract_credential_from_entry_non_dict_entry(): + """Non-dict entry (e.g. string) should return None, not crash.""" + assert _extract_credential_from_entry("my-cred-name") is None + assert _extract_credential_from_entry(["a", "list"]) is None + assert _extract_credential_from_entry(42) is None + + +# --- Unit tests for _resolve_credential_from_model_config --- + + +def test_resolve_project_model_specific_wins(): + project_config = { + "gpt-4": {"azure": {"litellm_credentials": "proj-gpt4"}}, + "defaultconfig": {"azure": {"litellm_credentials": "proj-default"}}, + } + team_config = { + "gpt-4": {"azure": {"litellm_credentials": "team-gpt4"}}, + "defaultconfig": {"azure": {"litellm_credentials": "team-default"}}, + } + result = _resolve_credential_from_model_config( + "gpt-4", project_config, team_config + ) + assert result == "proj-gpt4" + + +def test_resolve_project_default_wins_over_team(): + project_config = { + "defaultconfig": {"azure": {"litellm_credentials": "proj-default"}}, + } + team_config = { + "gpt-4": {"azure": {"litellm_credentials": "team-gpt4"}}, + "defaultconfig": {"azure": {"litellm_credentials": "team-default"}}, + } + result = _resolve_credential_from_model_config( + "gpt-4", project_config, team_config + ) + assert result == "proj-default" + + +def test_resolve_team_model_specific_wins_over_team_default(): + team_config = { + "gpt-4": {"azure": {"litellm_credentials": "team-gpt4"}}, + "defaultconfig": {"azure": {"litellm_credentials": "team-default"}}, + } + result = _resolve_credential_from_model_config("gpt-4", None, team_config) + assert result == "team-gpt4" + + +def test_resolve_team_default_used_as_fallback(): + team_config = { + "defaultconfig": {"azure": {"litellm_credentials": "team-default"}}, + } + result = _resolve_credential_from_model_config("gpt-3.5", None, team_config) + assert result == "team-default" + + +def test_resolve_no_match_returns_none(): + result = _resolve_credential_from_model_config("gpt-4", None, None) + assert result is None + + +def test_resolve_empty_configs_returns_none(): + result = _resolve_credential_from_model_config("gpt-4", {}, {}) + assert result is None + + +def test_resolve_model_not_in_any_config(): + project_config = {"gpt-4": {"azure": {"litellm_credentials": "x"}}} + result = _resolve_credential_from_model_config("gpt-3.5", project_config, None) + assert result is None + + +# --- Integration tests for _apply_credential_overrides_from_model_config --- + + +def test_apply_overrides_project_model_specific(setup_test_credentials): + """Scenario 2: Hotel Rec App -> gpt-4-vision -> project model-specific.""" + data = {"model": "gpt-4-vision"} + user_api_key_dict = UserAPIKeyAuth( + api_key="test-key", + team_metadata={ + "model_config": { + "defaultconfig": { + "azure": {"litellm_credentials": "hotel-azure-eastus"} + }, + "gpt-4": {"azure": {"litellm_credentials": "hotel-azure-westus"}}, + } + }, + project_metadata={ + "model_config": { + "defaultconfig": { + "azure": {"litellm_credentials": "hotel-rec-azure"} + }, + "gpt-4-vision": { + "azure": {"litellm_credentials": "hotel-rec-vision"} + }, + } + }, + ) + _apply_credential_overrides_from_model_config( + data=data, user_api_key_dict=user_api_key_dict + ) + assert data["api_base"] == "https://hotel-rec-vision.openai.azure.com/" + assert data["api_key"] == "key-hotel-rec-vision" + assert data["api_version"] == "2024-06-01" + + +def test_apply_overrides_project_default(setup_test_credentials): + """Scenario 1: Hotel Rec App -> gpt-4 -> project default.""" + data = {"model": "gpt-4"} + user_api_key_dict = UserAPIKeyAuth( + api_key="test-key", + team_metadata={ + "model_config": { + "defaultconfig": { + "azure": {"litellm_credentials": "hotel-azure-eastus"} + }, + "gpt-4": {"azure": {"litellm_credentials": "hotel-azure-westus"}}, + } + }, + project_metadata={ + "model_config": { + "defaultconfig": { + "azure": {"litellm_credentials": "hotel-rec-azure"} + }, + "gpt-4-vision": { + "azure": {"litellm_credentials": "hotel-rec-vision"} + }, + } + }, + ) + _apply_credential_overrides_from_model_config( + data=data, user_api_key_dict=user_api_key_dict + ) + assert data["api_base"] == "https://hotel-rec-app.openai.azure.com/" + assert data["api_key"] == "key-hotel-rec" + + +def test_apply_overrides_team_model_specific(setup_test_credentials): + """Scenario 4: Hotel Review App -> gpt-4 -> team model-specific.""" + data = {"model": "gpt-4"} + user_api_key_dict = UserAPIKeyAuth( + api_key="test-key", + team_metadata={ + "model_config": { + "defaultconfig": { + "azure": {"litellm_credentials": "hotel-azure-eastus"} + }, + "gpt-4": {"azure": {"litellm_credentials": "hotel-azure-westus"}}, + } + }, + project_metadata={}, + ) + _apply_credential_overrides_from_model_config( + data=data, user_api_key_dict=user_api_key_dict + ) + assert data["api_base"] == "https://hotel-westus.openai.azure.com/" + assert data["api_key"] == "key-hotel-westus" + + +def test_apply_overrides_team_default(setup_test_credentials): + """Scenario 3: Hotel Review App -> gpt-3.5 -> team default.""" + data = {"model": "gpt-3.5"} + user_api_key_dict = UserAPIKeyAuth( + api_key="test-key", + team_metadata={ + "model_config": { + "defaultconfig": { + "azure": {"litellm_credentials": "hotel-azure-eastus"} + }, + "gpt-4": {"azure": {"litellm_credentials": "hotel-azure-westus"}}, + } + }, + project_metadata={}, + ) + _apply_credential_overrides_from_model_config( + data=data, user_api_key_dict=user_api_key_dict + ) + assert data["api_base"] == "https://hotel-eastus.openai.azure.com/" + assert data["api_key"] == "key-hotel-eastus" + + +def test_apply_overrides_no_config(setup_test_credentials): + """Scenario 6: No model_config anywhere -> data unchanged.""" + data = {"model": "gpt-4"} + user_api_key_dict = UserAPIKeyAuth( + api_key="test-key", + team_metadata={}, + project_metadata={}, + ) + _apply_credential_overrides_from_model_config( + data=data, user_api_key_dict=user_api_key_dict + ) + assert "api_base" not in data + assert "api_key" not in data + + +def test_apply_overrides_clientside_credentials_take_precedence( + setup_test_credentials, +): + """Clientside api_base/api_key in data should block model_config override.""" + data = { + "model": "gpt-4", + "api_base": "https://my-custom-endpoint.openai.azure.com/", + "api_key": "my-custom-key", + } + user_api_key_dict = UserAPIKeyAuth( + api_key="test-key", + team_metadata={ + "model_config": { + "defaultconfig": { + "azure": {"litellm_credentials": "hotel-azure-eastus"} + } + } + }, + ) + _apply_credential_overrides_from_model_config( + data=data, user_api_key_dict=user_api_key_dict + ) + assert data["api_base"] == "https://my-custom-endpoint.openai.azure.com/" + assert data["api_key"] == "my-custom-key" + + +def test_apply_overrides_missing_credential_name(setup_test_credentials): + """model_config references a credential that doesn't exist -> no override.""" + data = {"model": "gpt-4"} + user_api_key_dict = UserAPIKeyAuth( + api_key="test-key", + team_metadata={ + "model_config": { + "gpt-4": { + "azure": {"litellm_credentials": "nonexistent-credential"} + } + } + }, + ) + _apply_credential_overrides_from_model_config( + data=data, user_api_key_dict=user_api_key_dict + ) + assert "api_base" not in data + assert "api_key" not in data + + +def test_apply_overrides_api_version_only_if_present(setup_test_credentials): + """api_version should only be set if the credential contains it.""" + data = {"model": "gpt-3.5"} + user_api_key_dict = UserAPIKeyAuth( + api_key="test-key", + team_metadata={ + "model_config": { + "defaultconfig": { + "azure": {"litellm_credentials": "hotel-azure-eastus"} + } + } + }, + ) + _apply_credential_overrides_from_model_config( + data=data, user_api_key_dict=user_api_key_dict + ) + assert data["api_base"] == "https://hotel-eastus.openai.azure.com/" + assert data["api_key"] == "key-hotel-eastus" + assert "api_version" not in data + + +def test_apply_overrides_no_model_in_data(setup_test_credentials): + """No model in request data -> skip override.""" + data = {"messages": [{"role": "user", "content": "hello"}]} + user_api_key_dict = UserAPIKeyAuth( + api_key="test-key", + team_metadata={ + "model_config": { + "defaultconfig": { + "azure": {"litellm_credentials": "some-cred"} + } + } + }, + ) + _apply_credential_overrides_from_model_config( + data=data, user_api_key_dict=user_api_key_dict + ) + assert "api_base" not in data + + +def test_apply_overrides_none_metadata(setup_test_credentials): + """None metadata on both team and project -> skip override.""" + data = {"model": "gpt-4"} + user_api_key_dict = UserAPIKeyAuth( + api_key="test-key", + team_metadata=None, + project_metadata=None, + ) + _apply_credential_overrides_from_model_config( + data=data, user_api_key_dict=user_api_key_dict + ) + assert "api_base" not in data + + +def test_apply_overrides_clientside_api_version_preserved(setup_test_credentials): + """Clientside api_version should not be overwritten by credential.""" + data = {"model": "gpt-4-vision", "api_version": "2025-01-01"} + user_api_key_dict = UserAPIKeyAuth( + api_key="test-key", + team_metadata={ + "model_config": { + "gpt-4-vision": { + "azure": {"litellm_credentials": "hotel-rec-vision"} + } + } + }, + ) + _apply_credential_overrides_from_model_config( + data=data, user_api_key_dict=user_api_key_dict + ) + # api_base and api_key should be set from credential + assert data["api_base"] == "https://hotel-rec-vision.openai.azure.com/" + assert data["api_key"] == "key-hotel-rec-vision" + # api_version should be preserved from the request, not overwritten + assert data["api_version"] == "2025-01-01" + + +def test_resolve_non_dict_model_config_ignored(): + """Non-dict model_config (e.g. string) should be safely skipped.""" + result = _resolve_credential_from_model_config("gpt-4", "not-a-dict", None) + assert result is None + + result = _resolve_credential_from_model_config( + "gpt-4", None, ["also", "not", "a", "dict"] + ) + assert result is None + + # Valid config still works alongside invalid one + result = _resolve_credential_from_model_config( + "gpt-4", + "invalid", + {"gpt-4": {"azure": {"litellm_credentials": "valid-cred"}}}, + ) + assert result == "valid-cred" + + +def test_resolve_pre_alias_model_name_fallback(): + """model_config keyed on pre-alias name should match after alias resolution.""" + team_config = { + "gpt-4": {"azure": {"litellm_credentials": "team-gpt4"}}, + } + # Post-alias name doesn't match, but pre-alias does (team scope) + result = _resolve_credential_from_model_config( + "azure/gpt-4-0613", None, team_config, pre_alias_model_name="gpt-4" + ) + assert result == "team-gpt4" + + # Same test for project scope + project_config = { + "gpt-4": {"azure": {"litellm_credentials": "proj-gpt4"}}, + } + result = _resolve_credential_from_model_config( + "azure/gpt-4-0613", project_config, None, pre_alias_model_name="gpt-4" + ) + assert result == "proj-gpt4" + + +def test_resolve_post_alias_name_takes_priority(): + """Post-alias (resolved) name should be tried before pre-alias name.""" + team_config = { + "gpt-4": {"azure": {"litellm_credentials": "pre-alias-cred"}}, + "gpt-4o-team-1": {"azure": {"litellm_credentials": "post-alias-cred"}}, + } + # Team scope + result = _resolve_credential_from_model_config( + "gpt-4o-team-1", None, team_config, pre_alias_model_name="gpt-4" + ) + assert result == "post-alias-cred" + + # Project scope + result = _resolve_credential_from_model_config( + "gpt-4o-team-1", team_config, None, pre_alias_model_name="gpt-4" + ) + assert result == "post-alias-cred" + + +def test_apply_overrides_with_alias(setup_test_credentials): + """Credential override should work when model name was changed by alias.""" + # Simulate: user called "my-gpt4", alias resolved to "azure/gpt-4-custom" + # model_config is keyed on "my-gpt4" (the pre-alias name) + data = {"model": "azure/gpt-4-custom"} + user_api_key_dict = UserAPIKeyAuth( + api_key="test-key", + team_metadata={ + "model_config": { + "my-gpt4": {"azure": {"litellm_credentials": "hotel-azure-eastus"}}, + } + }, + ) + _apply_credential_overrides_from_model_config( + data=data, + user_api_key_dict=user_api_key_dict, + pre_alias_model_name="my-gpt4", + ) + assert data["api_base"] == "https://hotel-eastus.openai.azure.com/" + assert data["api_key"] == "key-hotel-eastus" + + +def test_apply_overrides_feature_flag_disabled_by_default(): + """Feature flag defaults to False — credential overrides are inert until explicitly enabled.""" + assert litellm.enable_model_config_credential_overrides is False + data = {"model": "gpt-4"} + user_api_key_dict = UserAPIKeyAuth( + api_key="test-key", + team_metadata={ + "model_config": { + "gpt-4": {"azure": {"litellm_credentials": "hotel-azure-eastus"}} + } + }, + ) + _apply_credential_overrides_from_model_config( + data=data, user_api_key_dict=user_api_key_dict + ) + assert "api_base" not in data + assert "api_key" not in data + + +def test_extract_credential_provider_hint_prefers_exact_match(): + """Provider hint selects the correct provider in a multi-provider entry.""" + entry = { + "openai": {"litellm_credentials": "openai-cred"}, + "azure": {"litellm_credentials": "azure-cred"}, + } + # With provider hint, should pick the exact match + assert _extract_credential_from_entry(entry, provider="azure") == "azure-cred" + assert _extract_credential_from_entry(entry, provider="openai") == "openai-cred" + + # Without provider hint, falls back to first key (insertion order) + result = _extract_credential_from_entry(entry) + assert result in ("openai-cred", "azure-cred") + + # Unknown provider falls back to first available + result = _extract_credential_from_entry(entry, provider="bedrock") + assert result in ("openai-cred", "azure-cred") + + +def test_resolve_provider_hint_from_model_name(): + """Provider prefix in model name (e.g. azure/gpt-4) threads through to entry extraction.""" + config = { + "gpt-4": { + "openai": {"litellm_credentials": "openai-cred"}, + "azure": {"litellm_credentials": "azure-cred"}, + }, + } + # Model name "azure/gpt-4" -> provider="azure" -> should prefer azure-cred + # But _resolve_credential_from_model_config tries "azure/gpt-4" first (no match), + # then falls to defaultconfig (no match). So we need to use pre_alias_model_name. + result = _resolve_credential_from_model_config( + "azure/gpt-4", config, None, pre_alias_model_name="gpt-4", provider="azure" + ) + assert result == "azure-cred" From f6dde296fa8a9ef4747ff5c6e4df0af808124134 Mon Sep 17 00:00:00 2001 From: joereyna Date: Thu, 9 Apr 2026 10:33:20 -0700 Subject: [PATCH 015/130] fix(responses-ws): append ?model= to backend WebSocket URL --- litellm/llms/custom_httpx/llm_http_handler.py | 4 +++ .../test_responses_websocket_all_providers.py | 32 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 4c9abaad908..4e5b7a3410b 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -5027,6 +5027,10 @@ class BaseLLMHTTPHandler: litellm_params={}, ) ws_url = http_url.replace("https://", "wss://").replace("http://", "ws://") + # OpenAI's WebSocket responses endpoint requires ?model= in the URL, + # matching the Realtime API convention (wss://.../v1/realtime?model=...). + if "?" not in ws_url: + ws_url = f"{ws_url}?model={model}" try: ssl_context = get_shared_realtime_ssl_context() diff --git a/tests/test_litellm/responses/test_responses_websocket_all_providers.py b/tests/test_litellm/responses/test_responses_websocket_all_providers.py index 0d83b9f88de..09bde86593e 100644 --- a/tests/test_litellm/responses/test_responses_websocket_all_providers.py +++ b/tests/test_litellm/responses/test_responses_websocket_all_providers.py @@ -971,3 +971,35 @@ class TestWebSocketChunkTypes: ) assert len(messages) == 1 assert messages[0]["content"][0]["text"] == "Part 1Part 2" + + +class TestNativeWebSocketUrlConstruction: + """Test that native WebSocket URLs include the model query parameter.""" + + def test_openai_ws_url_includes_model(self): + """ws_url for OpenAI native WebSocket must include ?model= so OpenAI + knows which model to use before the first response.create event.""" + config = OpenAIResponsesAPIConfig() + http_url = config.get_complete_url(api_base=None, litellm_params={}) + base_ws_url = http_url.replace("https://", "wss://").replace("http://", "ws://") + + # get_complete_url should not include query params + assert "?" not in base_ws_url + + # The handler appends ?model= when none is present + model = "gpt-4o-mini" + ws_url = f"{base_ws_url}?model={model}" if "?" not in base_ws_url else base_ws_url + assert ws_url == "wss://api.openai.com/v1/responses?model=gpt-4o-mini" + + def test_ws_url_model_not_duplicated_if_query_already_present(self): + """If api_base already has query params, the ?model= should not be appended.""" + http_url = "https://custom.example.com/v1/responses?api-version=2024-05-01" + ws_url = http_url.replace("https://", "wss://").replace("http://", "ws://") + + # Fix: only append when no query string present + if "?" not in ws_url: + ws_url = f"{ws_url}?model=gpt-4o" + + assert "api-version=2024-05-01" in ws_url + assert ws_url.count("?") == 1 + assert "model=gpt-4o" not in ws_url From 3ac4333be115a134f21cadba709e7d36289b0198 Mon Sep 17 00:00:00 2001 From: joereyna Date: Thu, 9 Apr 2026 11:14:34 -0700 Subject: [PATCH 016/130] fix(responses-ws): use urllib.parse to append model param, fix test mocking --- litellm/llms/custom_httpx/llm_http_handler.py | 11 +- .../test_responses_websocket_all_providers.py | 117 ++++++++++++++---- 2 files changed, 103 insertions(+), 25 deletions(-) diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 4e5b7a3410b..7a8820a8785 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -1,5 +1,6 @@ import json import ssl +from urllib.parse import parse_qs, urlencode, urlparse, urlunparse from typing import ( TYPE_CHECKING, Any, @@ -5029,8 +5030,14 @@ class BaseLLMHTTPHandler: ws_url = http_url.replace("https://", "wss://").replace("http://", "ws://") # OpenAI's WebSocket responses endpoint requires ?model= in the URL, # matching the Realtime API convention (wss://.../v1/realtime?model=...). - if "?" not in ws_url: - ws_url = f"{ws_url}?model={model}" + # Use urllib.parse so existing query params (e.g. api-version) are preserved. + _parsed = urlparse(ws_url) + _qs = parse_qs(_parsed.query) + if "model" not in _qs: + _qs["model"] = [model] + ws_url = urlunparse( + _parsed._replace(query=urlencode({k: v[0] for k, v in _qs.items()})) + ) try: ssl_context = get_shared_realtime_ssl_context() diff --git a/tests/test_litellm/responses/test_responses_websocket_all_providers.py b/tests/test_litellm/responses/test_responses_websocket_all_providers.py index 09bde86593e..efec841cc4d 100644 --- a/tests/test_litellm/responses/test_responses_websocket_all_providers.py +++ b/tests/test_litellm/responses/test_responses_websocket_all_providers.py @@ -974,32 +974,103 @@ class TestWebSocketChunkTypes: class TestNativeWebSocketUrlConstruction: - """Test that native WebSocket URLs include the model query parameter.""" + """Test that native WebSocket URLs include the model query parameter. - def test_openai_ws_url_includes_model(self): - """ws_url for OpenAI native WebSocket must include ?model= so OpenAI - knows which model to use before the first response.create event.""" - config = OpenAIResponsesAPIConfig() - http_url = config.get_complete_url(api_base=None, litellm_params={}) - base_ws_url = http_url.replace("https://", "wss://").replace("http://", "ws://") + These tests mock websockets.connect so they exercise the actual URL-building + code inside BaseLLMHTTPHandler.async_responses_websocket rather than + reimplementing the logic themselves. + """ - # get_complete_url should not include query params - assert "?" not in base_ws_url + @pytest.mark.asyncio + async def test_openai_ws_url_includes_model(self): + """Handler must pass ?model= in the URL to the backend WebSocket.""" + from unittest.mock import AsyncMock, MagicMock, patch - # The handler appends ?model= when none is present - model = "gpt-4o-mini" - ws_url = f"{base_ws_url}?model={model}" if "?" not in base_ws_url else base_ws_url - assert ws_url == "wss://api.openai.com/v1/responses?model=gpt-4o-mini" + captured_urls = [] - def test_ws_url_model_not_duplicated_if_query_already_present(self): - """If api_base already has query params, the ?model= should not be appended.""" - http_url = "https://custom.example.com/v1/responses?api-version=2024-05-01" - ws_url = http_url.replace("https://", "wss://").replace("http://", "ws://") + class FakeConnect: + def __init__(self, url, **kwargs): + captured_urls.append(url) - # Fix: only append when no query string present - if "?" not in ws_url: - ws_url = f"{ws_url}?model=gpt-4o" + async def __aenter__(self): + raise Exception("stop") - assert "api-version=2024-05-01" in ws_url - assert ws_url.count("?") == 1 - assert "model=gpt-4o" not in ws_url + async def __aexit__(self, *args): + pass + + mock_config = MagicMock(spec=OpenAIResponsesAPIConfig) + mock_config.supports_native_websocket.return_value = True + mock_config.get_complete_url.return_value = "https://api.openai.com/v1/responses" + mock_config.validate_environment.return_value = {} + + mock_logging = MagicMock() + mock_logging.pre_call = MagicMock() + + from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler + + handler = BaseLLMHTTPHandler() + + mock_ws = MagicMock() + mock_ws.close = AsyncMock() + + with patch("websockets.connect", FakeConnect): + await handler.async_responses_websocket( + model="gpt-4o-mini", + websocket=mock_ws, + logging_obj=mock_logging, + responses_api_provider_config=mock_config, + api_key="sk-test", + ) + + assert len(captured_urls) == 1 + from urllib.parse import parse_qs, urlparse + qs = parse_qs(urlparse(captured_urls[0]).query) + assert qs.get("model") == ["gpt-4o-mini"], f"Expected model in URL, got: {captured_urls[0]}" + + @pytest.mark.asyncio + async def test_ws_url_preserves_existing_params_and_adds_model(self): + """When api_base already has query params, model is added alongside them.""" + from unittest.mock import AsyncMock, MagicMock, patch + + captured_urls = [] + + class FakeConnect: + def __init__(self, url, **kwargs): + captured_urls.append(url) + + async def __aenter__(self): + raise Exception("stop") + + async def __aexit__(self, *args): + pass + + mock_config = MagicMock(spec=OpenAIResponsesAPIConfig) + mock_config.supports_native_websocket.return_value = True + mock_config.get_complete_url.return_value = ( + "https://custom.example.com/v1/responses?api-version=2024-05-01" + ) + mock_config.validate_environment.return_value = {} + + mock_logging = MagicMock() + mock_logging.pre_call = MagicMock() + + from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler + + handler = BaseLLMHTTPHandler() + mock_ws = MagicMock() + mock_ws.close = AsyncMock() + + with patch("websockets.connect", FakeConnect): + await handler.async_responses_websocket( + model="gpt-4o", + websocket=mock_ws, + logging_obj=mock_logging, + responses_api_provider_config=mock_config, + api_key="sk-test", + ) + + assert len(captured_urls) == 1 + from urllib.parse import parse_qs, urlparse + qs = parse_qs(urlparse(captured_urls[0]).query) + assert qs.get("model") == ["gpt-4o"], f"model missing from URL: {captured_urls[0]}" + assert qs.get("api-version") == ["2024-05-01"], f"existing param lost: {captured_urls[0]}" From 3a6db708ce9d7f47a99d3143412100807af3db8b Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Thu, 9 Apr 2026 11:50:15 -0700 Subject: [PATCH 017/130] docs: add Docker Image Security Guide for cosign verification and deployment best practices (#25439) - New doc page covering all signed image variants, verification commands, CI/CD enforcement (K8s Sigstore Policy Controller, GCP Binary Authorization, AWS/EKS, GitHub Actions), digest pinning, and safe upgrade patterns - Added to sidebar under Setup & Deployment - Cross-linked from the existing deploy.md cosign section Co-authored-by: Cursor Agent Co-authored-by: Krrish Dholakia --- docs/my-website/docs/proxy/deploy.md | 2 +- .../docs/proxy/docker_image_security.md | 189 ++++++++++++++++++ docs/my-website/sidebars.js | 1 + 3 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 docs/my-website/docs/proxy/docker_image_security.md diff --git a/docs/my-website/docs/proxy/deploy.md b/docs/my-website/docs/proxy/deploy.md index 4b087afd841..f7833fc02c7 100644 --- a/docs/my-website/docs/proxy/deploy.md +++ b/docs/my-website/docs/proxy/deploy.md @@ -99,7 +99,7 @@ The following checks were performed on each of these signatures: - The signatures were verified against the specified public key ``` -Learn more about LiteLLM's release signing in the [CI/CD v2 announcement](https://docs.litellm.ai/blog/ci-cd-v2-improvements#verify-docker-image-signatures). +Learn more about LiteLLM's release signing in the [CI/CD v2 announcement](https://docs.litellm.ai/blog/ci-cd-v2-improvements#verify-docker-image-signatures). For a complete guide covering all image variants, CI/CD enforcement, and deployment best practices, see the [Docker Image Security Guide](./docker_image_security.md). ### Docker Run diff --git a/docs/my-website/docs/proxy/docker_image_security.md b/docs/my-website/docs/proxy/docker_image_security.md new file mode 100644 index 00000000000..41ace2174b3 --- /dev/null +++ b/docs/my-website/docs/proxy/docker_image_security.md @@ -0,0 +1,189 @@ +# Docker Image Security Guide + +LiteLLM signs every Docker image published to GHCR with [cosign](https://docs.sigstore.dev/cosign/overview/) starting from **v1.83.0**. This page covers how to verify signatures, enforce verification in CI/CD, and follow recommended deployment patterns. + +## Signed images + +All image variants published to `ghcr.io/berriai/` are signed with the same cosign key: + +| Image | Description | +|---|---| +| `ghcr.io/berriai/litellm` | Core proxy | +| `ghcr.io/berriai/litellm-database` | Proxy with Postgres dependencies | +| `ghcr.io/berriai/litellm-non_root` | Non-root variant | +| `ghcr.io/berriai/litellm-spend_logs` | Spend-logs sidecar | + +The signing key was introduced in [commit `0112e53`](https://github.com/BerriAI/litellm/commit/0112e53046018d726492c814b3644b7d376029d0) and the public key is checked into the repository at [`cosign.pub`](https://github.com/BerriAI/litellm/blob/main/cosign.pub). + +:::info Enterprise images +Enterprise images (`litellm-ee`) follow the same signing process. Contact [support@berri.ai](mailto:support@berri.ai) to confirm coverage for your specific enterprise image tag. +::: + +## Verify image signatures + +Install cosign following the [official instructions](https://docs.sigstore.dev/cosign/system_config/installation/). + +### Verify with the pinned commit hash (recommended) + +A commit hash is cryptographically immutable, making this the strongest verification method: + +```bash +cosign verify \ + --key https://raw.githubusercontent.com/BerriAI/litellm/0112e53046018d726492c814b3644b7d376029d0/cosign.pub \ + ghcr.io/berriai/litellm:v1.83.0-stable +``` + +Replace the image reference with any signed variant: + +```bash +# litellm-database +cosign verify \ + --key https://raw.githubusercontent.com/BerriAI/litellm/0112e53046018d726492c814b3644b7d376029d0/cosign.pub \ + ghcr.io/berriai/litellm-database:v1.83.0-stable + +# litellm-non_root +cosign verify \ + --key https://raw.githubusercontent.com/BerriAI/litellm/0112e53046018d726492c814b3644b7d376029d0/cosign.pub \ + ghcr.io/berriai/litellm-non_root:v1.83.0-stable +``` + +### Verify with a release tag (convenience) + +Tags are protected in this repository and resolve to the same key: + +```bash +cosign verify \ + --key https://raw.githubusercontent.com/BerriAI/litellm/v1.83.0-stable/cosign.pub \ + ghcr.io/berriai/litellm-database:v1.83.0-stable +``` + +### Expected output + +``` +The following checks were performed on each of these signatures: + - The cosign claims were validated + - The signatures were verified against the specified public key +``` + +## Enforce verification in CI/CD + +### Kubernetes — Sigstore Policy Controller + +The [Sigstore Policy Controller](https://docs.sigstore.dev/policy-controller/overview/) rejects pods whose images fail cosign verification. + +1. Install the controller: + +```bash +helm repo add sigstore https://sigstore.github.io/helm-charts +helm install policy-controller sigstore/policy-controller \ + -n cosign-system --create-namespace +``` + +2. Create a `ClusterImagePolicy` with the LiteLLM public key: + +```yaml +apiVersion: policy.sigstore.dev/v1beta1 +kind: ClusterImagePolicy +metadata: + name: litellm-signed-images +spec: + images: + - glob: "ghcr.io/berriai/litellm*" + authorities: + - key: + data: | + -----BEGIN PUBLIC KEY----- + MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEKi4ivqGpE231OGH50PKbqy1Y1Kkb + POJC8+i2Wko82gBOUCe3M0Vw86H/4rhUhfoYEti4gdJ9wZbYmK0I2EE96g== + -----END PUBLIC KEY----- +``` + +3. Label the namespace to enable enforcement: + +```bash +kubectl label namespace litellm policy.sigstore.dev/include=true +``` + +Any pod in that namespace using an unsigned `ghcr.io/berriai/litellm*` image will be rejected at admission. + +### GCP — Binary Authorization + +[Binary Authorization](https://cloud.google.com/binary-authorization/docs) can enforce cosign signatures on Cloud Run and GKE. + +1. Create a cosign-based attestor using the LiteLLM public key: + +```bash +# Import the public key into a Cloud KMS keyring or use a PGP/PKIX attestor. +# See: https://cloud.google.com/binary-authorization/docs/creating-attestors-console +``` + +2. Configure a Binary Authorization policy that requires the attestor for `ghcr.io/berriai/litellm*` images. + +3. Enable the policy on your Cloud Run service or GKE cluster. + +Refer to the [GCP Binary Authorization docs](https://cloud.google.com/binary-authorization/docs/setting-up) for full setup steps. + +### AWS — ECS / ECR + +AWS does not natively verify cosign signatures at deploy time. Common approaches: + +- **CI/CD gate**: Run `cosign verify` in your deployment pipeline before pushing to ECR or updating the ECS task definition. Fail the pipeline if verification fails. +- **OPA/Gatekeeper on EKS**: If running on EKS, use the Sigstore Policy Controller (same as the Kubernetes approach above). + +### GitHub Actions gate + +Add a verification step before any deployment job: + +```yaml +- name: Verify LiteLLM image signature + run: | + cosign verify \ + --key https://raw.githubusercontent.com/BerriAI/litellm/0112e53046018d726492c814b3644b7d376029d0/cosign.pub \ + ghcr.io/berriai/litellm-database:${{ env.LITELLM_VERSION }} +``` + +## Recommended deployment patterns + +### Pin by digest + +Digest pinning guarantees the exact image content regardless of tag mutations: + +```yaml +image: ghcr.io/berriai/litellm-database@sha256: +``` + +Get the digest after pulling: + +```bash +docker inspect --format='{{index .RepoDigests 0}}' \ + ghcr.io/berriai/litellm-database:v1.83.0-stable +``` + +Cosign verification works with digests too: + +```bash +cosign verify \ + --key https://raw.githubusercontent.com/BerriAI/litellm/0112e53046018d726492c814b3644b7d376029d0/cosign.pub \ + ghcr.io/berriai/litellm-database@sha256: +``` + +### Use stable release tags + +If digest pinning is too rigid for your workflow, use `-stable` release tags (e.g. `v1.83.0-stable`). These are immutable release tags that will not be overwritten. + +Avoid `main-latest` or `main-stable` in production — these rolling tags point to the most recent build and can change between deployments. + +### Safe upgrade checklist + +1. **Verify the new image** — run `cosign verify` against the new release tag or digest. +2. **Test in staging** — deploy the verified image to a non-production environment. +3. **Update your pinned reference** — change the digest or tag in your deployment manifest. +4. **Deploy to production** — roll out using your standard deployment process. +5. **Monitor `/health`** — confirm the proxy is healthy after the upgrade. + +## Further reading + +- [CI/CD v2 announcement](https://docs.litellm.ai/blog/ci-cd-v2-improvements) — background on LiteLLM's signing infrastructure +- [Docker deployment guide](./deploy.md) — full Docker, Helm, and Terraform setup +- [cosign documentation](https://docs.sigstore.dev/cosign/overview/) — cosign usage and key management +- [Sigstore Policy Controller](https://docs.sigstore.dev/policy-controller/overview/) — Kubernetes admission control diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index 300abc83ca9..54581dceb95 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -349,6 +349,7 @@ const sidebars = { "proxy/debugging", "proxy/error_diagnosis", "proxy/deploy", + "proxy/docker_image_security", "proxy/health", "proxy/master_key_rotations", "proxy/model_management", From ce2add3b16267845d8f2351ab76f9c1c832b6f8d Mon Sep 17 00:00:00 2001 From: Chetan Soni Date: Thu, 9 Apr 2026 12:42:42 -0700 Subject: [PATCH 018/130] feat(mcp): add per-user OAuth token storage for interactive MCP flows --- litellm/constants.py | 9 + litellm/proxy/_experimental/mcp_server/db.py | 145 ++++- .../mcp_server/discoverable_endpoints.py | 195 ++++++- .../mcp_server/mcp_server_manager.py | 31 ++ .../mcp_server/oauth2_token_cache.py | 108 ++++ .../proxy/_experimental/mcp_server/server.py | 120 +++- .../types/mcp_server/mcp_server_manager.py | 9 + tests/mcp_tests/test_per_user_oauth_cache.py | 527 ++++++++++++++++++ .../mcp_tools/OAuthFormFields.test.tsx | 208 +++++++ .../components/mcp_tools/OAuthFormFields.tsx | 46 +- .../mcp_tools/create_mcp_server.test.tsx | 141 +++++ .../mcp_tools/create_mcp_server.tsx | 14 + .../mcp_tools/mcp_server_edit.test.tsx | 249 +++++++++ .../components/mcp_tools/mcp_server_edit.tsx | 73 ++- .../src/components/mcp_tools/types.tsx | 4 + 15 files changed, 1851 insertions(+), 28 deletions(-) create mode 100644 tests/mcp_tests/test_per_user_oauth_cache.py create mode 100644 ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.test.tsx diff --git a/litellm/constants.py b/litellm/constants.py index a7d86ddb16b..337cb1243fb 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -135,6 +135,15 @@ MCP_OAUTH2_TOKEN_CACHE_DEFAULT_TTL = int( MCP_NPM_CACHE_DIR = os.getenv("MCP_NPM_CACHE_DIR", "/tmp/.npm_mcp_cache") MCP_OAUTH2_TOKEN_CACHE_MIN_TTL = int(os.getenv("MCP_OAUTH2_TOKEN_CACHE_MIN_TTL", "10")) +# Per-user OAuth token Redis cache (for server-side token storage) +MCP_PER_USER_TOKEN_REDIS_KEY_PREFIX = "mcp:per_user_token" +MCP_PER_USER_TOKEN_DEFAULT_TTL = int( + os.getenv("MCP_PER_USER_TOKEN_DEFAULT_TTL", "43200") # 12 hours +) +MCP_PER_USER_TOKEN_EXPIRY_BUFFER_SECONDS = int( + os.getenv("MCP_PER_USER_TOKEN_EXPIRY_BUFFER_SECONDS", "60") +) + # MCP timeout defaults (seconds). Override via env vars for slow/custom MCP servers. MCP_CLIENT_TIMEOUT = float(os.getenv("LITELLM_MCP_CLIENT_TIMEOUT", "60.0")) MCP_TOOL_LISTING_TIMEOUT = float(os.getenv("LITELLM_MCP_TOOL_LISTING_TIMEOUT", "30.0")) diff --git a/litellm/proxy/_experimental/mcp_server/db.py b/litellm/proxy/_experimental/mcp_server/db.py index fbef33c32ed..e9bd41bb951 100644 --- a/litellm/proxy/_experimental/mcp_server/db.py +++ b/litellm/proxy/_experimental/mcp_server/db.py @@ -21,7 +21,9 @@ from litellm.proxy.common_utils.encrypt_decrypt_utils import ( decrypt_value_helper, encrypt_value_helper, ) +from litellm.llms.custom_httpx.http_handler import get_async_httpx_client from litellm.proxy.utils import PrismaClient +from litellm.types.llms.custom_http import httpxSpecialProvider from litellm.types.mcp import MCPCredentials @@ -576,6 +578,7 @@ async def store_user_oauth_credential( refresh_token: Optional[str] = None, expires_in: Optional[int] = None, scopes: Optional[List[str]] = None, + skip_byok_guard: bool = False, ) -> None: """Persist an OAuth2 access token for a user+server pair. @@ -604,21 +607,26 @@ async def store_user_oauth_credential( # Guard against silently overwriting a BYOK credential with an OAuth token. # BYOK credentials lack a "type" field (or use a non-"oauth2" type). - existing = await prisma_client.db.litellm_mcpusercredentials.find_unique( - where={"user_id_server_id": {"user_id": user_id, "server_id": server_id}} - ) - if existing is not None: - _byok_error = ValueError( - f"A non-OAuth2 credential already exists for user {user_id} " - f"and server {server_id}. Refusing to overwrite." + # Skip the guard when the caller knows the row is already an OAuth2 credential + # (e.g. during token refresh), saving an extra DB round-trip. + if not skip_byok_guard: + existing = await prisma_client.db.litellm_mcpusercredentials.find_unique( + where={"user_id_server_id": {"user_id": user_id, "server_id": server_id}} ) - try: - raw = json.loads(base64.urlsafe_b64decode(existing.credential_b64).decode()) - except Exception: - # Credential is not base64+JSON — it's a plain-text BYOK key. - raise _byok_error - if raw.get("type") != "oauth2": - raise _byok_error + if existing is not None: + _byok_error = ValueError( + f"A non-OAuth2 credential already exists for user {user_id} " + f"and server {server_id}. Refusing to overwrite." + ) + try: + raw = json.loads( + base64.urlsafe_b64decode(existing.credential_b64).decode() + ) + except Exception: + # Credential is not base64+JSON — it's a plain-text BYOK key. + raise _byok_error + if raw.get("type") != "oauth2": + raise _byok_error encoded = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode() await prisma_client.db.litellm_mcpusercredentials.upsert( @@ -697,6 +705,115 @@ async def list_user_oauth_credentials( return results +async def refresh_user_oauth_token( + prisma_client: PrismaClient, + user_id: str, + server: Any, + cred: Dict[str, Any], +) -> Optional[Dict[str, Any]]: + """Attempt to refresh a per-user OAuth2 token using its stored refresh_token. + + POSTs to ``server.token_url`` with ``grant_type=refresh_token``. + + On success: persists the new credential via ``store_user_oauth_credential`` + and returns the updated payload dict. + On failure (network error, invalid_grant, missing refresh_token, …): logs a + warning and returns ``None`` — the caller is responsible for clearing the + stale credential and triggering re-authentication. + """ + refresh_token: Optional[str] = cred.get("refresh_token") + token_url: Optional[str] = getattr(server, "token_url", None) + server_id: str = getattr(server, "server_id", "") + client_id: Optional[str] = getattr(server, "client_id", None) + client_secret: Optional[str] = getattr(server, "client_secret", None) + + if not refresh_token: + verbose_proxy_logger.debug( + "refresh_user_oauth_token: no refresh_token stored for user=%s server=%s", + user_id, + server_id, + ) + return None + if not token_url: + verbose_proxy_logger.debug( + "refresh_user_oauth_token: server=%s has no token_url configured", + server_id, + ) + return None + + token_data: Dict[str, str] = { + "grant_type": "refresh_token", + "refresh_token": refresh_token, + } + if client_id: + token_data["client_id"] = client_id + if client_secret: + token_data["client_secret"] = client_secret + + try: + async_client = get_async_httpx_client( + llm_provider=httpxSpecialProvider.Oauth2Check + ) + response = await async_client.post( + token_url, + headers={"Accept": "application/json"}, + data=token_data, + ) + response.raise_for_status() + body: Dict[str, Any] = response.json() + except Exception as exc: + verbose_proxy_logger.warning( + "refresh_user_oauth_token: refresh request failed for user=%s server=%s: %s", + user_id, + server_id, + exc, + ) + return None + + access_token: Optional[str] = body.get("access_token") + if not access_token: + verbose_proxy_logger.warning( + "refresh_user_oauth_token: token response missing access_token for " + "user=%s server=%s", + user_id, + server_id, + ) + return None + + expires_in: Optional[int] = None + raw_expires = body.get("expires_in") + try: + expires_in = int(raw_expires) if raw_expires is not None else None + except (TypeError, ValueError): + pass + + # Rotate refresh token when the provider returns a new one + new_refresh_token: Optional[str] = body.get("refresh_token") or refresh_token + + raw_scope = body.get("scope") + scopes: Optional[List[str]] = ( + raw_scope.split() if isinstance(raw_scope, str) and raw_scope else None + ) or cred.get("scopes") + + await store_user_oauth_credential( + prisma_client=prisma_client, + user_id=user_id, + server_id=server_id, + access_token=access_token, + refresh_token=new_refresh_token, + expires_in=expires_in, + scopes=scopes, + skip_byok_guard=True, # Row is already OAuth2; skip the extra find_unique check + ) + + verbose_proxy_logger.info( + "refresh_user_oauth_token: refreshed token for user=%s server=%s", + user_id, + server_id, + ) + return await get_user_oauth_credential(prisma_client, user_id, server_id) + + async def approve_mcp_server( prisma_client: PrismaClient, server_id: str, diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py index 07309eb57f2..d0d61986322 100644 --- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -1,10 +1,11 @@ import json -from typing import Optional +from typing import Any, Dict, Optional from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse from fastapi import APIRouter, Form, HTTPException, Request from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from litellm._logging import verbose_logger from litellm.llms.custom_httpx.http_handler import ( get_async_httpx_client, httpxSpecialProvider, @@ -147,6 +148,160 @@ def _resolve_oauth2_server_for_root_endpoints( return None +def _validate_token_response( + token_response: Dict[str, Any], + validation_rules: Dict[str, Any], + server_id: str, +) -> None: + """Raise HTTPException 403 if any validation rule doesn't match the token response. + + Supports dot-notation for nested fields (e.g. ``"team.enterprise_id"`` checks + ``token_response["team"]["enterprise_id"]``). Top-level keys are tried first, + then dot-split traversal. All comparisons are string-coerced so that numeric + values in the response (e.g. ``"org_id": 12345``) match string rules + (``"org_id": "12345"``). + """ + for key, expected in validation_rules.items(): + actual: Any = token_response.get(key) + # Try dot-notation traversal when top-level lookup returns None + if actual is None and "." in key: + obj: Any = token_response + for part in key.split("."): + if isinstance(obj, dict): + obj = obj.get(part) + else: + obj = None + break + actual = obj + # Treat absent fields as a distinct failure from a mismatched value + if actual is None: + raise HTTPException( + status_code=403, + detail={ + "error": "token_validation_failed", + "server_id": server_id, + "field": key, + "message": ( + f"OAuth token rejected: required field '{key}' is absent" + ), + }, + ) + if str(actual) != str(expected): + raise HTTPException( + status_code=403, + detail={ + "error": "token_validation_failed", + "server_id": server_id, + "field": key, + "message": ( + f"OAuth token rejected: '{key}' = '{actual}', " + f"expected '{expected}'" + ), + }, + ) + + +async def _extract_user_id_from_request(request: Request) -> Optional[str]: + """Best-effort extraction of LiteLLM user_id from the request's Authorization header. + + Called at the OAuth token endpoint so that per-user tokens can be stored + server-side. Uses a read-only cache lookup to avoid re-running the full + auth pipeline (which has side effects such as rate-limit increments and + spend logging). Returns ``None`` if no cached credential is found. + """ + auth_header = request.headers.get("Authorization") or request.headers.get( + "authorization" + ) + if not auth_header: + return None + lower = auth_header.lower() + if not lower.startswith("bearer "): + return None + token = auth_header[7:].strip() + try: + from litellm.proxy._types import hash_token # noqa: PLC0415 + from litellm.proxy.proxy_server import user_api_key_cache # noqa: PLC0415 + + cached = await user_api_key_cache.async_get_cache(hash_token(token)) + return getattr(cached, "user_id", None) + except Exception: + return None + + +async def _store_per_user_token_server_side( + server: MCPServer, + user_id: str, + token_response: Dict[str, Any], +) -> None: + """Persist the OAuth token server-side and warm the Redis cache. + + Called from the token endpoint after a successful code exchange or refresh. + Errors are logged but NOT re-raised — the token is always returned to the + client even when server-side storage fails. + """ + from litellm.proxy._experimental.mcp_server.oauth2_token_cache import ( # noqa: PLC0415 + _compute_per_user_token_ttl, + mcp_per_user_token_cache, + ) + from litellm.proxy.utils import get_prisma_client_or_throw # noqa: PLC0415 + + access_token: Optional[str] = token_response.get("access_token") + if not access_token: + return + + raw_expires = token_response.get("expires_in") + try: + expires_in: Optional[int] = int(raw_expires) if raw_expires is not None else None + except (TypeError, ValueError): + expires_in = None + + refresh_token: Optional[str] = token_response.get("refresh_token") or None + raw_scope = token_response.get("scope") + scopes: Optional[list] = ( + raw_scope.split() if isinstance(raw_scope, str) and raw_scope else None + ) + + try: + prisma_client = get_prisma_client_or_throw( + "Database not connected. Cannot store per-user OAuth token." + ) + from litellm.proxy._experimental.mcp_server.db import ( # noqa: PLC0415 + store_user_oauth_credential, + ) + + await store_user_oauth_credential( + prisma_client=prisma_client, + user_id=user_id, + server_id=server.server_id, + access_token=access_token, + refresh_token=refresh_token, + expires_in=expires_in, + scopes=scopes, + ) + verbose_logger.info( + "_store_per_user_token_server_side: stored token for user=%s server=%s", + user_id, + server.server_id, + ) + except Exception as exc: + verbose_logger.warning( + "_store_per_user_token_server_side: DB storage failed for user=%s server=%s: %s", + user_id, + server.server_id, + exc, + ) + return # Don't warm Redis if DB write failed + + # Warm the Redis cache so the first subsequent MCP call is a cache hit + ttl = _compute_per_user_token_ttl(server, expires_in) + await mcp_per_user_token_cache.set( + user_id=user_id, + server_id=server.server_id, + access_token=access_token, + ttl=ttl, + ) + + async def authorize_with_server( request: Request, mcp_server: MCPServer, @@ -266,6 +421,44 @@ async def exchange_token_with_server( token_response = response.json() access_token = token_response["access_token"] + # Validate token response against server-configured rules before any storage. + # This rejects tokens from wrong Slack workspaces, Atlassian orgs, etc. + if mcp_server.token_validation and isinstance(mcp_server.token_validation, dict): + _validate_token_response( + token_response=token_response, + validation_rules=mcp_server.token_validation, + server_id=mcp_server.server_id, + ) + + # Store server-side when the server is configured for per-user OAuth and + # the calling client has provided a valid LiteLLM identity. + # Errors are non-fatal: the token is still returned to the client. + if mcp_server.needs_user_oauth_token: + user_id = await _extract_user_id_from_request(request) + if user_id: + try: + await _store_per_user_token_server_side( + server=mcp_server, + user_id=user_id, + token_response=token_response, + ) + except Exception as exc: + verbose_logger.warning( + "exchange_token_with_server: server-side storage failed " + "for user=%s server=%s: %s", + user_id, + mcp_server.server_id, + exc, + ) + else: + verbose_logger.debug( + "exchange_token_with_server: no LiteLLM user_id found in request; " + "per-user token for server=%s will not be stored server-side. " + "The client should call POST /mcp/server/{id}/oauth-user-credential " + "to store it manually.", + mcp_server.server_id, + ) + result = { "access_token": access_token, "token_type": token_response.get("token_type", "Bearer"), diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 402e12d9356..8d3831e75fb 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -2455,6 +2455,37 @@ class MCPServerManager: ) tasks.append(during_hook_task) + # For per-user OAuth servers: if the client didn't supply a token in + # oauth2_headers, look up the stored token from Redis / DB. This is the + # call_tool equivalent of _get_user_oauth_extra_headers_from_db used in + # list_tools. + if ( + mcp_server.needs_user_oauth_token + and not oauth2_headers + and user_api_key_auth is not None + ): + user_id = getattr(user_api_key_auth, "user_id", None) + if user_id: + try: + from litellm.proxy._experimental.mcp_server.server import ( # noqa: PLC0415 + _get_user_oauth_extra_headers_from_db, + ) + + stored_headers = await _get_user_oauth_extra_headers_from_db( + server=mcp_server, + user_api_key_auth=user_api_key_auth, + ) + if stored_headers: + oauth2_headers = stored_headers + except Exception as _lookup_exc: + verbose_logger.debug( + "call_tool: per-user token lookup failed for " + "user=%s server=%s: %s", + user_id, + mcp_server.server_id, + _lookup_exc, + ) + # For OpenAPI servers, call the tool handler directly instead of via MCP client if mcp_server.spec_path: verbose_logger.debug( diff --git a/litellm/proxy/_experimental/mcp_server/oauth2_token_cache.py b/litellm/proxy/_experimental/mcp_server/oauth2_token_cache.py index 84a2e94467b..476e215666e 100644 --- a/litellm/proxy/_experimental/mcp_server/oauth2_token_cache.py +++ b/litellm/proxy/_experimental/mcp_server/oauth2_token_cache.py @@ -17,8 +17,15 @@ from litellm.constants import ( MCP_OAUTH2_TOKEN_CACHE_MAX_SIZE, MCP_OAUTH2_TOKEN_CACHE_MIN_TTL, MCP_OAUTH2_TOKEN_EXPIRY_BUFFER_SECONDS, + MCP_PER_USER_TOKEN_DEFAULT_TTL, + MCP_PER_USER_TOKEN_EXPIRY_BUFFER_SECONDS, + MCP_PER_USER_TOKEN_REDIS_KEY_PREFIX, ) from litellm.llms.custom_httpx.http_handler import get_async_httpx_client +from litellm.proxy.common_utils.encrypt_decrypt_utils import ( + decrypt_value_helper, + encrypt_value_helper, +) from litellm.types.llms.custom_http import httpxSpecialProvider if TYPE_CHECKING: @@ -152,6 +159,107 @@ class MCPOAuth2TokenCache(InMemoryCache): mcp_oauth2_token_cache = MCPOAuth2TokenCache() +def _compute_per_user_token_ttl(server: "MCPServer", expires_in: Optional[int]) -> int: + """Compute Redis TTL for a per-user token. + + Uses server.token_storage_ttl_seconds when configured; otherwise derives + TTL from expires_in minus the expiry buffer; falls back to the default TTL. + """ + if server.token_storage_ttl_seconds is not None: + return max(server.token_storage_ttl_seconds, 1) + if expires_in is not None: + return max( + expires_in - MCP_PER_USER_TOKEN_EXPIRY_BUFFER_SECONDS, + 1, + ) + return MCP_PER_USER_TOKEN_DEFAULT_TTL + + +class MCPPerUserTokenCache: + """Redis-backed cache for per-user OAuth2 access tokens. + + Uses LiteLLM's existing ``user_api_key_cache`` (DualCache with optional + Redis backend). Tokens are NaCl-encrypted with ``encrypt_value_helper`` + before storage so they are safe at rest in Redis. + + Redis key format: ``mcp:per_user_token:{user_id}:{server_id}`` + Redis value: ``encrypt_value_helper(access_token)`` — URL-safe base64 + """ + + def _cache_key(self, user_id: str, server_id: str) -> str: + return f"{MCP_PER_USER_TOKEN_REDIS_KEY_PREFIX}:{user_id}:{server_id}" + + async def get(self, user_id: str, server_id: str) -> Optional[str]: + """Return the plaintext access_token, or None on miss/error.""" + try: + from litellm.proxy.proxy_server import user_api_key_cache # noqa: PLC0415 + + key = self._cache_key(user_id, server_id) + encrypted = await user_api_key_cache.async_get_cache(key) + if encrypted is None: + return None + plaintext = decrypt_value_helper( + encrypted, + key="mcp_per_user_token", + exception_type="debug", + ) + return plaintext or None + except Exception as exc: + verbose_logger.debug( + "MCPPerUserTokenCache.get failed for user=%s server=%s: %s", + user_id, + server_id, + exc, + ) + return None + + async def set( + self, + user_id: str, + server_id: str, + access_token: str, + ttl: int, + ) -> None: + """Store NaCl-encrypted access_token in Redis with the given TTL.""" + try: + from litellm.proxy.proxy_server import user_api_key_cache # noqa: PLC0415 + + key = self._cache_key(user_id, server_id) + encrypted = encrypt_value_helper(access_token) + await user_api_key_cache.async_set_cache(key, encrypted, ttl=ttl) + verbose_logger.debug( + "MCPPerUserTokenCache.set: cached token for user=%s server=%s ttl=%ds", + user_id, + server_id, + ttl, + ) + except Exception as exc: + verbose_logger.debug( + "MCPPerUserTokenCache.set failed for user=%s server=%s: %s", + user_id, + server_id, + exc, + ) + + async def delete(self, user_id: str, server_id: str) -> None: + """Invalidate the cached token (removes from both in-memory and Redis layers).""" + try: + from litellm.proxy.proxy_server import user_api_key_cache # noqa: PLC0415 + + key = self._cache_key(user_id, server_id) + await user_api_key_cache.async_delete_cache(key) + except Exception as exc: + verbose_logger.debug( + "MCPPerUserTokenCache.delete failed for user=%s server=%s: %s", + user_id, + server_id, + exc, + ) + + +mcp_per_user_token_cache = MCPPerUserTokenCache() + + async def resolve_mcp_auth( server: "MCPServer", mcp_auth_header: Optional[Union[str, Dict[str, str]]] = None, diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 7fc28b68e9c..99578d006e1 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -896,11 +896,17 @@ if MCP_AVAILABLE: user_api_key_auth: Optional[UserAPIKeyAuth], prefetched_creds: Optional[Dict[str, Dict[str, Any]]] = None, ) -> Optional[Dict[str, str]]: - """Look up stored OAuth2 token for (user, server) from DB and return as extra_headers dict. + """Look up stored OAuth2 token for (user, server) and return as extra_headers dict. + + Lookup order: + 1. Redis cache (fast path, NaCl-decrypted) — skipped when prefetched_creds supplied + 2. prefetched_creds dict (pre-fetched batch DB query) or fresh DB query + 3. Auto-refresh when the stored token is expired and a refresh_token exists Args: prefetched_creds: Optional dict keyed by server_id with credential payloads. - When provided, avoids a per-server DB round-trip. + When provided, the Redis and individual DB lookups are + skipped in favour of the pre-fetched batch result. """ if server.auth_type != MCPAuth.oauth2: return None @@ -914,8 +920,27 @@ if MCP_AVAILABLE: from litellm.proxy._experimental.mcp_server.db import ( # noqa: PLC0415 get_user_oauth_credential, is_oauth_credential_expired, + refresh_user_oauth_token, + ) + from litellm.proxy._experimental.mcp_server.oauth2_token_cache import ( # noqa: PLC0415 + _compute_per_user_token_ttl, + mcp_per_user_token_cache, ) + # ── Fast path: Redis cache ──────────────────────────────────────── + # Only used when prefetched_creds is not supplied (individual lookup). + if prefetched_creds is None: + cached_token = await mcp_per_user_token_cache.get(user_id, server_id) + if cached_token is not None: + verbose_logger.debug( + "_get_user_oauth_extra_headers_from_db: Redis hit for " + "user=%s server=%s", + user_id, + server_id, + ) + return {"Authorization": f"Bearer {cached_token}"} + + # ── Slow path: DB lookup ────────────────────────────────────────── if prefetched_creds is not None: cred = prefetched_creds.get(server_id) else: @@ -929,18 +954,83 @@ if MCP_AVAILABLE: cred = await get_user_oauth_credential( prisma_client, user_id, server_id ) - if cred and cred.get("access_token"): - if is_oauth_credential_expired(cred): - verbose_logger.debug( - f"_get_user_oauth_extra_headers_from_db: token expired for " - f"user={user_id} server={server_id}" - ) + + if not cred or not cred.get("access_token"): + return None + + if is_oauth_credential_expired(cred): + verbose_logger.debug( + "_get_user_oauth_extra_headers_from_db: token expired for " + "user=%s server=%s — attempting refresh", + user_id, + server_id, + ) + # Attempt token refresh; requires a DB client (not available from prefetch) + if cred.get("refresh_token"): + try: + from litellm.proxy.utils import ( # noqa: PLC0415 + get_prisma_client_or_throw, + ) + + prisma_client = get_prisma_client_or_throw( + "Database not connected. Cannot refresh OAuth token." + ) + cred = await refresh_user_oauth_token( + prisma_client=prisma_client, + user_id=user_id, + server=server, + cred=cred, + ) + except Exception as refresh_exc: + verbose_logger.warning( + "_get_user_oauth_extra_headers_from_db: refresh failed " + "for user=%s server=%s: %s", + user_id, + server_id, + refresh_exc, + ) + cred = None + + if not cred or not cred.get("access_token"): + # Clear stale Redis/cache entry so we don't serve it again. + # Do this for both the individual and prefetch paths so the + # next request doesn't get a stale cache hit. + await mcp_per_user_token_cache.delete(user_id, server_id) return None - return {"Authorization": f"Bearer {cred['access_token']}"} + + access_token: str = cred["access_token"] + + # Warm (or re-warm) the Redis cache from the DB result. + # Always write regardless of whether expires_at is present — tokens + # without an expiry are still valid and should be cached using the + # server/default TTL so subsequent requests are fast. + if prefetched_creds is None: + raw_expires = None + expires_at = cred.get("expires_at") + if expires_at: + from datetime import datetime, timezone # noqa: PLC0415 + + try: + exp_dt = datetime.fromisoformat(expires_at) + if exp_dt.tzinfo is None: + exp_dt = exp_dt.replace(tzinfo=timezone.utc) + remaining = int( + (exp_dt - datetime.now(timezone.utc)).total_seconds() + ) + raw_expires = max(remaining, 0) if remaining > 0 else None + except (ValueError, TypeError): + pass + ttl = _compute_per_user_token_ttl(server, raw_expires) + await mcp_per_user_token_cache.set(user_id, server_id, access_token, ttl) + + return {"Authorization": f"Bearer {access_token}"} except Exception as e: verbose_logger.warning( - f"_get_user_oauth_extra_headers_from_db: failed to retrieve credential for " - f"user={user_id} server={server_id}: {e}" + "_get_user_oauth_extra_headers_from_db: failed to retrieve credential for " + "user=%s server=%s: %s", + user_id, + server_id, + e, ) return None @@ -2504,6 +2594,14 @@ if MCP_AVAILABLE: server_name, client_ip=_client_ip ) if server and server.auth_type == MCPAuth.oauth2 and not oauth2_headers: + # For servers that store per-user tokens server-side, skip the + # pre-emptive 401 — the call_tool / list_tools dispatch will look + # up the stored token from Redis / DB and only fail at the MCP + # protocol level if none is found, giving the client a proper + # tool-execution error rather than an HTTP 401. + if server.needs_user_oauth_token: + continue + request = StarletteRequest(scope) base_url = get_request_base_url(request) diff --git a/litellm/types/mcp_server/mcp_server_manager.py b/litellm/types/mcp_server/mcp_server_manager.py index db7657a0174..a7d0968c0ef 100644 --- a/litellm/types/mcp_server/mcp_server_manager.py +++ b/litellm/types/mcp_server/mcp_server_manager.py @@ -71,6 +71,15 @@ class MCPServer(BaseModel): # OAuth2 flow type. Defaults to None (interactive / authorization_code). # Set to "client_credentials" to enable M2M token fetching. oauth2_flow: Optional[Literal["client_credentials", "authorization_code"]] = None + # Per-user OAuth server-side storage config. + # token_validation: key-value pairs that must match fields in the OAuth token + # response (supports dot-notation for nested fields, e.g. "team.enterprise_id"). + # Tokens that fail validation are rejected before storage. + token_validation: Optional[Dict[str, Any]] = None + # Optional TTL override (seconds) for the Redis per-user token cache. + # Defaults to the token's expires_in minus the expiry buffer, or + # MCP_PER_USER_TOKEN_DEFAULT_TTL when expires_in is absent. + token_storage_ttl_seconds: Optional[int] = None model_config = ConfigDict(arbitrary_types_allowed=True) @property diff --git a/tests/mcp_tests/test_per_user_oauth_cache.py b/tests/mcp_tests/test_per_user_oauth_cache.py new file mode 100644 index 00000000000..36c26a5a505 --- /dev/null +++ b/tests/mcp_tests/test_per_user_oauth_cache.py @@ -0,0 +1,527 @@ +""" +Unit tests for per-user MCP OAuth token storage: +- MCPPerUserTokenCache (NaCl-encrypted Redis cache) +- _validate_token_response (token validation rules) +- _compute_per_user_token_ttl (TTL computation) +- refresh_user_oauth_token (token refresh flow) +""" + +import sys +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, Optional +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +# Stub out modules that aren't available in the unit-test environment +# so we can import the targets without a full proxy stack. +for _mod in ("orjson",): + if _mod not in sys.modules: + sys.modules[_mod] = MagicMock() + +from litellm.proxy._experimental.mcp_server.oauth2_token_cache import ( # noqa: E402 + MCPPerUserTokenCache, + _compute_per_user_token_ttl, + mcp_per_user_token_cache, +) +from litellm.types.mcp import MCPAuth, MCPTransport # noqa: E402 +from litellm.types.mcp_server.mcp_server_manager import MCPServer # noqa: E402 + + +def _import_validate(): + """Lazy import to avoid pulling orjson at collection time.""" + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + _validate_token_response, + ) + + return _validate_token_response + + +# ── Fixtures ───────────────────────────────────────────────────────────────── + + +def _make_server(**kwargs) -> MCPServer: + defaults: Dict[str, Any] = { + "server_id": "slack-test", + "name": "Slack", + "server_name": "slack", + "url": "https://slack-mcp.example.com/mcp", + "transport": MCPTransport.http, + "auth_type": MCPAuth.oauth2, + "client_id": "SLACK_CLIENT_ID", + "client_secret": "SLACK_CLIENT_SECRET", + "token_url": "https://slack.com/api/oauth.v2.access", + "authorization_url": "https://slack.com/oauth/v2/authorize", + } + defaults.update(kwargs) + return MCPServer(**defaults) + + +# ── _validate_token_response ────────────────────────────────────────────────── + + +class TestValidateTokenResponse: + def test_passes_when_all_rules_match(self): + _validate_token_response = _import_validate() + token_response = { + "access_token": "xoxb-123", + "enterprise_id": "E04XXXXXX", + "team": {"id": "T123", "name": "Acme"}, + } + # Should not raise + _validate_token_response( + token_response=token_response, + validation_rules={"enterprise_id": "E04XXXXXX"}, + server_id="slack-test", + ) + + def test_raises_on_mismatch(self): + from fastapi import HTTPException + + _validate_token_response = _import_validate() + token_response = {"access_token": "xoxb-123", "enterprise_id": "E99999999"} + with pytest.raises(HTTPException) as exc_info: + _validate_token_response( + token_response=token_response, + validation_rules={"enterprise_id": "E04XXXXXX"}, + server_id="slack-test", + ) + assert exc_info.value.status_code == 403 + detail = exc_info.value.detail + assert detail["error"] == "token_validation_failed" + assert detail["field"] == "enterprise_id" + + def test_raises_when_field_absent(self): + from fastapi import HTTPException + + _validate_token_response = _import_validate() + token_response = {"access_token": "xoxb-123"} + with pytest.raises(HTTPException) as exc_info: + _validate_token_response( + token_response=token_response, + validation_rules={"enterprise_id": "E04XXXXXX"}, + server_id="slack-test", + ) + assert exc_info.value.status_code == 403 + # Absent field should produce a distinct "absent" message, not str(None) + assert "absent" in exc_info.value.detail["message"] + + def test_absent_field_does_not_match_string_none(self): + """str(None)='None' must NOT match the string rule value 'None'.""" + from fastapi import HTTPException + + _validate_token_response = _import_validate() + token_response = {"access_token": "tok"} # enterprise_id absent + # Even if admin writes validation_rules={"enterprise_id": "None"}, absent + # field should raise, not pass. + with pytest.raises(HTTPException) as exc_info: + _validate_token_response( + token_response=token_response, + validation_rules={"enterprise_id": "None"}, + server_id="slack-test", + ) + assert exc_info.value.status_code == 403 + assert "absent" in exc_info.value.detail["message"] + + def test_dot_notation_nested_field(self): + _validate_token_response = _import_validate() + token_response = { + "access_token": "xoxb-123", + "team": {"enterprise_id": "E04XXXXXX"}, + } + # Should not raise — dot-notation traverses nested dict + _validate_token_response( + token_response=token_response, + validation_rules={"team.enterprise_id": "E04XXXXXX"}, + server_id="slack-test", + ) + + def test_dot_notation_mismatch(self): + from fastapi import HTTPException + + _validate_token_response = _import_validate() + token_response = { + "access_token": "xoxb-123", + "team": {"enterprise_id": "WRONG"}, + } + with pytest.raises(HTTPException) as exc_info: + _validate_token_response( + token_response=token_response, + validation_rules={"team.enterprise_id": "E04XXXXXX"}, + server_id="slack-test", + ) + assert exc_info.value.status_code == 403 + assert exc_info.value.detail["field"] == "team.enterprise_id" + + def test_numeric_value_string_coercion(self): + """Numeric values in token response should match string rules.""" + _validate_token_response = _import_validate() + token_response = {"access_token": "tok", "org_id": 12345} + # Should not raise — str(12345) == "12345" + _validate_token_response( + token_response=token_response, + validation_rules={"org_id": "12345"}, + server_id="test", + ) + + def test_multiple_rules_all_must_match(self): + from fastapi import HTTPException + + _validate_token_response = _import_validate() + token_response = { + "access_token": "tok", + "enterprise_id": "E04XXXXXX", + "cloud_id": "WRONG_CLOUD", + } + with pytest.raises(HTTPException): + _validate_token_response( + token_response=token_response, + validation_rules={ + "enterprise_id": "E04XXXXXX", + "cloud_id": "abc-123", + }, + server_id="atlassian", + ) + + +# ── _compute_per_user_token_ttl ────────────────────────────────────────────── + + +class TestComputePerUserTokenTtl: + def test_uses_server_override_when_set(self): + server = _make_server(token_storage_ttl_seconds=7200) + assert _compute_per_user_token_ttl(server, expires_in=99999) == 7200 + + def test_uses_expires_in_minus_buffer(self): + server = _make_server() + # Default buffer is 60s + ttl = _compute_per_user_token_ttl(server, expires_in=3600) + assert ttl == 3600 - 60 + + def test_minimum_ttl_is_1(self): + server = _make_server() + # expires_in smaller than buffer → clamp to 1 + ttl = _compute_per_user_token_ttl(server, expires_in=30) + assert ttl == 1 + + def test_default_ttl_when_expires_in_none(self): + from litellm.constants import MCP_PER_USER_TOKEN_DEFAULT_TTL + + server = _make_server() + ttl = _compute_per_user_token_ttl(server, expires_in=None) + assert ttl == MCP_PER_USER_TOKEN_DEFAULT_TTL + + +# ── MCPPerUserTokenCache ────────────────────────────────────────────────────── + + +class TestMCPPerUserTokenCache: + """Tests for Redis-backed per-user token cache. + + Patches ``user_api_key_cache`` to avoid needing a real Redis instance. + Patches ``encrypt_value_helper`` / ``decrypt_value_helper`` to verify + encryption is applied before Redis writes and decryption after reads. + """ + + @pytest.fixture + def cache(self): + return MCPPerUserTokenCache() + + @pytest.fixture + def mock_dual_cache(self): + dc = MagicMock() + dc.async_get_cache = AsyncMock(return_value=None) + dc.async_set_cache = AsyncMock() + return dc + + @pytest.mark.asyncio + async def test_get_returns_none_on_miss(self, cache, mock_dual_cache): + with patch( + "litellm.proxy._experimental.mcp_server.oauth2_token_cache.decrypt_value_helper" + ) as mock_decrypt, patch( + "litellm.proxy.proxy_server.user_api_key_cache", mock_dual_cache + ): + mock_dual_cache.async_get_cache.return_value = None + result = await cache.get("alice", "slack-test") + assert result is None + mock_decrypt.assert_not_called() + + @pytest.mark.asyncio + async def test_get_decrypts_cached_value(self, cache, mock_dual_cache): + fake_encrypted = "encrypted_blob_abc123" + fake_plaintext = "xoxb-slack-token" + with patch( + "litellm.proxy._experimental.mcp_server.oauth2_token_cache.decrypt_value_helper", + return_value=fake_plaintext, + ) as mock_decrypt, patch( + "litellm.proxy.proxy_server.user_api_key_cache", mock_dual_cache + ): + mock_dual_cache.async_get_cache.return_value = fake_encrypted + result = await cache.get("alice", "slack-test") + + assert result == fake_plaintext + mock_decrypt.assert_called_once_with( + fake_encrypted, + key="mcp_per_user_token", + exception_type="debug", + ) + + @pytest.mark.asyncio + async def test_set_encrypts_before_storing(self, cache, mock_dual_cache): + fake_encrypted = "encrypted_blob_xyz" + with patch( + "litellm.proxy._experimental.mcp_server.oauth2_token_cache.encrypt_value_helper", + return_value=fake_encrypted, + ) as mock_encrypt, patch( + "litellm.proxy.proxy_server.user_api_key_cache", mock_dual_cache + ): + await cache.set("alice", "slack-test", "xoxb-token", ttl=3540) + + mock_encrypt.assert_called_once_with("xoxb-token") + mock_dual_cache.async_set_cache.assert_called_once() + call_kwargs = mock_dual_cache.async_set_cache.call_args + assert call_kwargs[0][1] == fake_encrypted # encrypted value stored + assert call_kwargs[1]["ttl"] == 3540 + + @pytest.mark.asyncio + async def test_set_uses_correct_cache_key(self, cache, mock_dual_cache): + with patch( + "litellm.proxy._experimental.mcp_server.oauth2_token_cache.encrypt_value_helper", + return_value="enc", + ), patch( + "litellm.proxy.proxy_server.user_api_key_cache", mock_dual_cache + ): + await cache.set("bob", "github-server", "ghp_token", ttl=3600) + + key_used = mock_dual_cache.async_set_cache.call_args[0][0] + assert key_used == "mcp:per_user_token:bob:github-server" + + @pytest.mark.asyncio + async def test_delete_calls_async_delete_cache(self, cache, mock_dual_cache): + mock_dual_cache.async_delete_cache = AsyncMock() + with patch( + "litellm.proxy.proxy_server.user_api_key_cache", mock_dual_cache + ): + await cache.delete("alice", "slack-test") + + mock_dual_cache.async_delete_cache.assert_called_once_with( + "mcp:per_user_token:alice:slack-test" + ) + mock_dual_cache.async_set_cache.assert_not_called() + + @pytest.mark.asyncio + async def test_get_returns_none_on_decrypt_failure(self, cache, mock_dual_cache): + """Cache misses and decrypt errors should both return None without raising.""" + with patch( + "litellm.proxy._experimental.mcp_server.oauth2_token_cache.decrypt_value_helper", + return_value=None, # decrypt returns None on failure + ), patch( + "litellm.proxy.proxy_server.user_api_key_cache", mock_dual_cache + ): + mock_dual_cache.async_get_cache.return_value = "bad_encrypted_data" + result = await cache.get("alice", "slack-test") + + assert result is None + + @pytest.mark.asyncio + async def test_set_is_noop_on_cache_error(self, cache, mock_dual_cache): + """Errors in the cache layer must not propagate to the caller.""" + mock_dual_cache.async_set_cache.side_effect = RuntimeError("Redis down") + with patch( + "litellm.proxy._experimental.mcp_server.oauth2_token_cache.encrypt_value_helper", + return_value="enc", + ), patch( + "litellm.proxy.proxy_server.user_api_key_cache", mock_dual_cache + ): + # Should not raise + await cache.set("alice", "slack-test", "token", ttl=3600) + + +# ── refresh_user_oauth_token ────────────────────────────────────────────────── + + +class TestRefreshUserOauthToken: + """Tests for the DB-level token refresh helper.""" + + @pytest.fixture + def server(self): + return _make_server() + + @pytest.fixture + def cred(self): + return { + "type": "oauth2", + "access_token": "OLD_TOKEN", + "refresh_token": "REFRESH_TOKEN_123", + "expires_at": ( + datetime.now(timezone.utc) - timedelta(hours=1) + ).isoformat(), + } + + @pytest.mark.asyncio + async def test_returns_none_when_no_refresh_token(self, server): + from litellm.proxy._experimental.mcp_server.db import refresh_user_oauth_token + + cred = {"type": "oauth2", "access_token": "OLD"} # no refresh_token + result = await refresh_user_oauth_token( + prisma_client=MagicMock(), + user_id="alice", + server=server, + cred=cred, + ) + assert result is None + + @pytest.mark.asyncio + async def test_returns_none_when_no_token_url(self, cred): + from litellm.proxy._experimental.mcp_server.db import refresh_user_oauth_token + + server = _make_server(token_url=None) + result = await refresh_user_oauth_token( + prisma_client=MagicMock(), + user_id="alice", + server=server, + cred=cred, + ) + assert result is None + + @pytest.mark.asyncio + async def test_returns_none_on_http_error(self, server, cred): + from litellm.proxy._experimental.mcp_server.db import refresh_user_oauth_token + + mock_client = AsyncMock() + mock_client.post.side_effect = Exception("Connection refused") + + with patch( + "litellm.proxy._experimental.mcp_server.db.get_async_httpx_client", + return_value=mock_client, + ): + result = await refresh_user_oauth_token( + prisma_client=MagicMock(), + user_id="alice", + server=server, + cred=cred, + ) + assert result is None + + @pytest.mark.asyncio + async def test_stores_and_returns_new_credential(self, server, cred): + from litellm.proxy._experimental.mcp_server.db import refresh_user_oauth_token + + new_token_response = MagicMock() + new_token_response.json.return_value = { + "access_token": "NEW_TOKEN", + "expires_in": 3600, + "refresh_token": "NEW_REFRESH", + "scope": "channels:read chat:write", + } + new_token_response.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.post.return_value = new_token_response + + stored_cred = { + "type": "oauth2", + "access_token": "NEW_TOKEN", + "refresh_token": "NEW_REFRESH", + } + mock_prisma = AsyncMock() + + with patch( + "litellm.proxy._experimental.mcp_server.db.get_async_httpx_client", + return_value=mock_client, + ), patch( + "litellm.proxy._experimental.mcp_server.db.store_user_oauth_credential", + new_callable=AsyncMock, + ) as mock_store, patch( + "litellm.proxy._experimental.mcp_server.db.get_user_oauth_credential", + new_callable=AsyncMock, + return_value=stored_cred, + ): + result = await refresh_user_oauth_token( + prisma_client=mock_prisma, + user_id="alice", + server=server, + cred=cred, + ) + + assert result == stored_cred + mock_store.assert_called_once() + call_kwargs = mock_store.call_args[1] + assert call_kwargs["access_token"] == "NEW_TOKEN" + assert call_kwargs["refresh_token"] == "NEW_REFRESH" + assert call_kwargs["expires_in"] == 3600 + assert call_kwargs["scopes"] == ["channels:read", "chat:write"] + # Refresh path must skip the BYOK guard (row is already OAuth2) + assert call_kwargs.get("skip_byok_guard") is True + + @pytest.mark.asyncio + async def test_falls_back_to_old_refresh_token_when_not_rotated( + self, server, cred + ): + """When provider doesn't return a new refresh_token, keep the old one.""" + from litellm.proxy._experimental.mcp_server.db import refresh_user_oauth_token + + new_token_response = MagicMock() + new_token_response.json.return_value = { + "access_token": "NEW_TOKEN", + "expires_in": 3600, + # No refresh_token in response + } + new_token_response.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.post.return_value = new_token_response + + with patch( + "litellm.proxy._experimental.mcp_server.db.get_async_httpx_client", + return_value=mock_client, + ), patch( + "litellm.proxy._experimental.mcp_server.db.store_user_oauth_credential", + new_callable=AsyncMock, + ) as mock_store, patch( + "litellm.proxy._experimental.mcp_server.db.get_user_oauth_credential", + new_callable=AsyncMock, + return_value={"type": "oauth2", "access_token": "NEW_TOKEN"}, + ): + await refresh_user_oauth_token( + prisma_client=AsyncMock(), + user_id="alice", + server=server, + cred=cred, + ) + + call_kwargs = mock_store.call_args[1] + # Old refresh_token preserved when provider doesn't rotate + assert call_kwargs["refresh_token"] == "REFRESH_TOKEN_123" + + +# ── MCPServer new fields ────────────────────────────────────────────────────── + + +class TestMCPServerNewFields: + def test_token_validation_default_none(self): + server = _make_server() + assert server.token_validation is None + + def test_token_validation_set(self): + server = _make_server(token_validation={"enterprise_id": "E04XXXXXX"}) + assert server.token_validation == {"enterprise_id": "E04XXXXXX"} + + def test_token_storage_ttl_default_none(self): + server = _make_server() + assert server.token_storage_ttl_seconds is None + + def test_token_storage_ttl_set(self): + server = _make_server(token_storage_ttl_seconds=7200) + assert server.token_storage_ttl_seconds == 7200 + + def test_needs_user_oauth_token_true_for_oauth2_without_m2m(self): + server = _make_server(auth_type=MCPAuth.oauth2) + assert server.needs_user_oauth_token is True + + def test_needs_user_oauth_token_false_for_m2m(self): + server = _make_server( + auth_type=MCPAuth.oauth2, + oauth2_flow="client_credentials", + ) + assert server.needs_user_oauth_token is False diff --git a/ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.test.tsx new file mode 100644 index 00000000000..888f3066252 --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.test.tsx @@ -0,0 +1,208 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor, act, fireEvent } from "@testing-library/react"; +import { Form } from "antd"; +import OAuthFormFields from "./OAuthFormFields"; + +// ── helpers ────────────────────────────────────────────────────────────────── + +/** Minimal Ant Form wrapper so Form.Item registers correctly. */ +const WithForm: React.FC<{ children: React.ReactNode; onFinish?: (values: any) => void }> = ({ + children, + onFinish, +}) => { + const [form] = Form.useForm(); + return ( +
+ {children} + +
+ ); +}; + +// ── tests ───────────────────────────────────────────────────────────────────── + +describe("OAuthFormFields", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ── visibility by flow type ───────────────────────────────────────────────── + + describe("interactive mode (isM2M=false)", () => { + it("renders Token Validation Rules field", () => { + render( + + + , + ); + expect(screen.getByText("Token Validation Rules (optional)")).toBeInTheDocument(); + }); + + it("renders Token Storage TTL field", () => { + render( + + + , + ); + expect(screen.getByText("Token Storage TTL (seconds, optional)")).toBeInTheDocument(); + }); + + it("renders standard interactive fields alongside the new fields", () => { + render( + + + , + ); + expect(screen.getByText("Authorization URL (optional)")).toBeInTheDocument(); + expect(screen.getByText("Registration URL (optional)")).toBeInTheDocument(); + expect(screen.getByText("Token Validation Rules (optional)")).toBeInTheDocument(); + expect(screen.getByText("Token Storage TTL (seconds, optional)")).toBeInTheDocument(); + }); + }); + + describe("M2M mode (isM2M=true)", () => { + it("does NOT render Token Validation Rules field", () => { + render( + + + , + ); + expect(screen.queryByText("Token Validation Rules (optional)")).not.toBeInTheDocument(); + }); + + it("does NOT render Token Storage TTL field", () => { + render( + + + , + ); + expect(screen.queryByText("Token Storage TTL (seconds, optional)")).not.toBeInTheDocument(); + }); + + it("still renders M2M-specific fields", () => { + render( + + + , + ); + expect(screen.getByText("Client ID")).toBeInTheDocument(); + expect(screen.getByText("Token URL")).toBeInTheDocument(); + }); + }); + + // ── token_validation_json inline JSON validator ────────────────────────────── + + describe("token_validation_json validation", () => { + it("accepts empty value without error", async () => { + const onFinish = vi.fn(); + render( + + + , + ); + + // Leave the textarea empty and submit + const submitBtn = screen.getByRole("button", { name: "Submit" }); + await act(async () => { + fireEvent.click(submitBtn); + }); + + await waitFor(() => { + expect(screen.queryByText("Must be valid JSON")).not.toBeInTheDocument(); + }); + }); + + it("accepts a valid JSON object without error", async () => { + render( + + + , + ); + + const textarea = document.getElementById("token_validation_json") as HTMLTextAreaElement; + await act(async () => { + fireEvent.change(textarea, { target: { value: '{"organization": "my-org"}' } }); + }); + + const submitBtn = screen.getByRole("button", { name: "Submit" }); + await act(async () => { + fireEvent.click(submitBtn); + }); + + await waitFor(() => { + expect(screen.queryByText("Must be valid JSON")).not.toBeInTheDocument(); + }); + }); + + it("shows 'Must be valid JSON' error for malformed JSON", async () => { + render( + + + , + ); + + const textarea = document.getElementById("token_validation_json") as HTMLTextAreaElement; + await act(async () => { + fireEvent.change(textarea, { target: { value: "not-valid-json{" } }); + }); + + const submitBtn = screen.getByRole("button", { name: "Submit" }); + await act(async () => { + fireEvent.click(submitBtn); + }); + + await waitFor(() => { + expect(screen.getByText("Must be valid JSON")).toBeInTheDocument(); + }); + }); + + it("shows error for a plain string value (not a JSON object)", async () => { + render( + + + , + ); + + const textarea = document.getElementById("token_validation_json") as HTMLTextAreaElement; + await act(async () => { + // A bare string is valid JSON but we still want to accept it; only truly + // unparseable text should fail. Bare "hello" is actually invalid JSON + // (no quotes), so it should fail. + fireEvent.change(textarea, { target: { value: "hello" } }); + }); + + const submitBtn = screen.getByRole("button", { name: "Submit" }); + await act(async () => { + fireEvent.click(submitBtn); + }); + + await waitFor(() => { + expect(screen.getByText("Must be valid JSON")).toBeInTheDocument(); + }); + }); + + it("whitespace-only value is treated as empty and passes validation", async () => { + const onFinish = vi.fn(); + render( + + + , + ); + + const textarea = document.getElementById("token_validation_json") as HTMLTextAreaElement; + await act(async () => { + fireEvent.change(textarea, { target: { value: " " } }); + }); + + const submitBtn = screen.getByRole("button", { name: "Submit" }); + await act(async () => { + fireEvent.click(submitBtn); + }); + + await waitFor(() => { + expect(screen.queryByText("Must be valid JSON")).not.toBeInTheDocument(); + }); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.tsx b/ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.tsx index 85487a8a479..4a808ca489d 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Form, Select, Tooltip } from "antd"; +import { Form, Input, InputNumber, Select, Tooltip } from "antd"; import { InfoCircleOutlined } from "@ant-design/icons"; import { Button, TextInput } from "@tremor/react"; import { OAUTH_FLOW } from "./types"; @@ -151,6 +151,50 @@ const OAuthFormFields: React.FC = ({ > + + } + name="token_validation_json" + rules={[ + { + validator: (_: any, value: string) => { + if (!value || value.trim() === "") return Promise.resolve(); + try { + JSON.parse(value); + return Promise.resolve(); + } catch { + return Promise.reject(new Error("Must be valid JSON")); + } + }, + }, + ]} + > + + + + } + name="token_storage_ttl_seconds" + > + + {oauthFlow && (

diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx index d49c49446bb..c92956b430f 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx @@ -353,6 +353,147 @@ describe("CreateMCPServer", () => { ); }); + describe("when OAuth interactive auth is selected", () => { + /** Select HTTP transport + OAuth auth, then wait for the OAuth form to appear. */ + async function setupOAuthInteractive() { + render(); + await selectAntOption("Transport Type", "Streamable HTTP"); + + await waitFor(() => { + expect(screen.getByPlaceholderText("https://your-mcp-server.com")).toBeInTheDocument(); + }); + + await selectAntOption("Authentication", "OAuth"); + + // Wait for OAuthFormFields to render (OAuth Flow Type selector is the sentinel) + await waitFor(() => { + expect(screen.getByText("OAuth Flow Type")).toBeInTheDocument(); + }); + + // OAuthFormFields defaults to INTERACTIVE, so the new fields should appear + await waitFor(() => { + expect(screen.getByText("Token Validation Rules (optional)")).toBeInTheDocument(); + expect(screen.getByText("Token Storage TTL (seconds, optional)")).toBeInTheDocument(); + }); + } + + it("shows Token Validation Rules and Token Storage TTL fields", async () => { + await setupOAuthInteractive(); + // Asserted in setupOAuthInteractive + }); + + it("includes token_validation in payload when token_validation_json is filled with valid JSON", async () => { + vi.mocked(networking.createMCPServer).mockResolvedValue({ + server_id: "new-server-oauth", + server_name: "OAuth_Server", + alias: "OAuth_Server", + url: "https://example.com/mcp", + transport: "http", + auth_type: "oauth2", + created_at: "2024-01-01T00:00:00Z", + created_by: "user-1", + updated_at: "2024-01-01T00:00:00Z", + updated_by: "user-1", + }); + + await setupOAuthInteractive(); + + // Fill required form fields + const nameInput = document.getElementById("server_name") as HTMLInputElement; + await act(async () => { + fireEvent.change(nameInput, { target: { value: "OAuth_Server" } }); + }); + const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com"); + await act(async () => { + fireEvent.change(urlInput, { target: { value: "https://example.com/mcp" } }); + }); + + // Fill in the token_validation_json textarea + const textarea = document.getElementById("token_validation_json") as HTMLTextAreaElement; + await act(async () => { + fireEvent.change(textarea, { target: { value: '{"organization": "my-org", "team.id": "42"}' } }); + }); + + const submitButton = screen.getByRole("button", { name: "Add MCP Server" }); + await act(async () => { + fireEvent.click(submitButton); + }); + + await waitFor(() => { + expect(networking.createMCPServer).toHaveBeenCalledTimes(1); + }); + + const [, payload] = vi.mocked(networking.createMCPServer).mock.calls[0]; + expect(payload.token_validation).toEqual({ organization: "my-org", "team.id": "42" }); + }); + + it("omits token_validation from payload when token_validation_json is empty", async () => { + vi.mocked(networking.createMCPServer).mockResolvedValue({ + server_id: "new-server-oauth", + server_name: "OAuth_Server", + alias: "OAuth_Server", + url: "https://example.com/mcp", + transport: "http", + auth_type: "oauth2", + created_at: "2024-01-01T00:00:00Z", + created_by: "user-1", + updated_at: "2024-01-01T00:00:00Z", + updated_by: "user-1", + }); + + await setupOAuthInteractive(); + + const nameInput = document.getElementById("server_name") as HTMLInputElement; + await act(async () => { + fireEvent.change(nameInput, { target: { value: "OAuth_Server" } }); + }); + const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com"); + await act(async () => { + fireEvent.change(urlInput, { target: { value: "https://example.com/mcp" } }); + }); + + // Leave token_validation_json empty + const submitButton = screen.getByRole("button", { name: "Add MCP Server" }); + await act(async () => { + fireEvent.click(submitButton); + }); + + await waitFor(() => { + expect(networking.createMCPServer).toHaveBeenCalledTimes(1); + }); + + const [, payload] = vi.mocked(networking.createMCPServer).mock.calls[0]; + expect(payload.token_validation).toBeUndefined(); + }); + + it("does not submit and shows validation error for invalid JSON in token_validation_json", async () => { + await setupOAuthInteractive(); + + const textarea = document.getElementById("token_validation_json") as HTMLTextAreaElement; + await act(async () => { + fireEvent.change(textarea, { target: { value: "not-valid-json{" } }); + }); + + const nameInput = document.getElementById("server_name") as HTMLInputElement; + await act(async () => { + fireEvent.change(nameInput, { target: { value: "OAuth_Server" } }); + }); + + const submitButton = screen.getByRole("button", { name: "Add MCP Server" }); + await act(async () => { + fireEvent.click(submitButton); + }); + + // Either the inline form validation message or the notification fires — + // both indicate the submit was blocked. + await waitFor(() => { + const inlineError = screen.queryByText("Must be valid JSON"); + const notCalled = !vi.mocked(networking.createMCPServer).mock.calls.length; + expect(inlineError !== null || notCalled).toBe(true); + }); + }); + }); + describe("when modal is cancelled", () => { it("should call setModalVisible(false) when cancel is clicked", async () => { render(); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx index 4c824fcee0b..45556bc18b1 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx @@ -284,6 +284,7 @@ const CreateMCPServer: React.FC = ({ credentials: credentialValues, allow_all_keys: allowAllKeysRaw, available_on_public_internet: availableOnPublicInternetRaw, + token_validation_json: rawTokenValidationJson, ...restValues } = values; @@ -356,6 +357,18 @@ const CreateMCPServer: React.FC = ({ restValues.transport = "http"; } + // Parse token_validation JSON if provided + let tokenValidation: Record | null = null; + if (rawTokenValidationJson && rawTokenValidationJson.trim() !== "") { + try { + tokenValidation = JSON.parse(rawTokenValidationJson); + } catch { + NotificationsManager.fromBackend("Invalid JSON in Token Validation Rules"); + setIsLoading(false); + return; + } + } + // Prepare the payload with cost configuration and allowed tools const payload: Record = { ...restValues, @@ -376,6 +389,7 @@ const CreateMCPServer: React.FC = ({ allow_all_keys: Boolean(allowAllKeysRaw), available_on_public_internet: Boolean(availableOnPublicInternetRaw), static_headers: staticHeaders, + ...(tokenValidation !== null && { token_validation: tokenValidation }), }; payload.static_headers = staticHeaders; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx index e33e2fff491..aba2a3d9222 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx @@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor, fireEvent, act } from "@testing-library/react"; import MCPServerEdit from "./mcp_server_edit"; import * as networking from "../networking"; +import NotificationsManager from "../molecules/notifications_manager"; vi.mock("../networking", () => ({ updateMCPServer: vi.fn(), @@ -37,6 +38,29 @@ vi.mock("./mcp_tool_configuration", () => ({ default: () =>

, })); +// ── fixtures ────────────────────────────────────────────────────────────────── + +const interactiveOAuthServer = { + server_id: "oauth_server_1", + server_name: "OAuthServer", + alias: "oauth_server", // underscores: hyphens fail validateMCPServerName + description: "Interactive OAuth MCP server", + transport: "http", + url: "https://example.com/mcp", + auth_type: "oauth2", + // No token_url → edit form defaults to INTERACTIVE flow + token_url: null, + authorization_url: null, + registration_url: null, + created_at: "2024-01-01T00:00:00Z", + created_by: "user-1", + updated_at: "2024-01-01T00:00:00Z", + updated_by: "user-1", + mcp_access_groups: [], +}; + +// ── test suites ─────────────────────────────────────────────────────────────── + describe("MCPServerEdit (stdio)", () => { beforeEach(() => { vi.clearAllMocks(); @@ -152,3 +176,228 @@ describe("MCPServerEdit (stdio)", () => { expect(payload.env).toEqual({ CIRCLECI_TOKEN: "new-token", CIRCLECI_BASE_URL: "https://circleci.com" }); }); }); + +describe("MCPServerEdit (interactive OAuth)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders Token Validation Rules and Token Storage TTL fields for interactive OAuth server", async () => { + render( + , + ); + + await waitFor(() => { + expect(screen.getByText("Token Validation Rules (optional)")).toBeInTheDocument(); + expect(screen.getByText("Token Storage TTL (seconds, optional)")).toBeInTheDocument(); + }); + }); + + // Note: The M2M flow hiding logic is tested via OAuthFormFields.test.tsx (isM2M prop directly), + // since Form.useWatch doesn't synchronously reflect initialValues in jsdom. + + it("pre-populates token_validation_json from existing server token_validation", async () => { + const tokenValidation = { organization: "my-org", "team.id": "123" }; + + render( + , + ); + + await waitFor(() => { + const textarea = document.getElementById("token_validation_json") as HTMLTextAreaElement; + expect(textarea).not.toBeNull(); + const parsed = JSON.parse(textarea.value); + expect(parsed).toEqual(tokenValidation); + }); + }); + + it("includes token_validation in update payload when token_validation_json is filled", async () => { + const onSuccess = vi.fn(); + vi.mocked(networking.updateMCPServer).mockResolvedValue({ + ...interactiveOAuthServer, + token_validation: { organization: "my-org" }, + }); + + render( + , + ); + + // Wait for the form to mount and the token_validation_json field to appear + await waitFor(() => { + expect(screen.getByText("Token Validation Rules (optional)")).toBeInTheDocument(); + }); + + const textarea = document.getElementById("token_validation_json") as HTMLTextAreaElement; + await act(async () => { + fireEvent.change(textarea, { target: { value: '{"organization": "my-org"}' } }); + }); + + const saveButtons = screen.getAllByRole("button", { name: "Save Changes" }); + await act(async () => { + fireEvent.click(saveButtons[0]); + }); + + await waitFor(() => { + expect(networking.updateMCPServer).toHaveBeenCalledTimes(1); + }); + + const [, payload] = vi.mocked(networking.updateMCPServer).mock.calls[0]; + expect(payload.token_validation).toEqual({ organization: "my-org" }); + }); + + it("does not include token_validation in payload when field is empty and server had none", async () => { + vi.mocked(networking.updateMCPServer).mockResolvedValue(interactiveOAuthServer); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText("Token Validation Rules (optional)")).toBeInTheDocument(); + }); + + // Leave token_validation_json empty + const saveButtons = screen.getAllByRole("button", { name: "Save Changes" }); + await act(async () => { + fireEvent.click(saveButtons[0]); + }); + + await waitFor(() => { + expect(networking.updateMCPServer).toHaveBeenCalledTimes(1); + }); + + const [, payload] = vi.mocked(networking.updateMCPServer).mock.calls[0]; + expect(payload.token_validation).toBeUndefined(); + }); + + it("sends token_validation: null to clear an existing value when textarea is cleared", async () => { + vi.mocked(networking.updateMCPServer).mockResolvedValue({ + ...interactiveOAuthServer, + token_validation: null, + }); + + render( + , + ); + + await waitFor(() => { + const textarea = document.getElementById("token_validation_json") as HTMLTextAreaElement; + expect(textarea?.value).toContain("old-org"); + }); + + // Clear the textarea + const textarea = document.getElementById("token_validation_json") as HTMLTextAreaElement; + await act(async () => { + fireEvent.change(textarea, { target: { value: "" } }); + }); + + const saveButtons = screen.getAllByRole("button", { name: "Save Changes" }); + await act(async () => { + fireEvent.click(saveButtons[0]); + }); + + await waitFor(() => { + expect(networking.updateMCPServer).toHaveBeenCalledTimes(1); + }); + + const [, payload] = vi.mocked(networking.updateMCPServer).mock.calls[0]; + // null signals the backend to clear the existing validation rules + expect(payload.token_validation).toBeNull(); + }); + + it("shows inline validation error and does not submit on invalid JSON in token_validation_json", async () => { + render( + , + ); + + await waitFor(() => { + expect(screen.getByText("Token Validation Rules (optional)")).toBeInTheDocument(); + }); + + const textarea = document.getElementById("token_validation_json") as HTMLTextAreaElement; + await act(async () => { + fireEvent.change(textarea, { target: { value: "{ bad json" } }); + }); + + const saveButtons = screen.getAllByRole("button", { name: "Save Changes" }); + await act(async () => { + fireEvent.click(saveButtons[0]); + }); + + // The Form.Item inline validator intercepts invalid JSON before handleSave runs, + // so the inline error message appears and updateMCPServer is never called. + await waitFor(() => { + expect(screen.getByText("Must be valid JSON")).toBeInTheDocument(); + }); + expect(networking.updateMCPServer).not.toHaveBeenCalled(); + }); + + it("includes token_storage_ttl_seconds in payload when set", async () => { + vi.mocked(networking.updateMCPServer).mockResolvedValue({ + ...interactiveOAuthServer, + token_storage_ttl_seconds: 7200, + }); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText("Token Storage TTL (seconds, optional)")).toBeInTheDocument(); + }); + + const saveButtons = screen.getAllByRole("button", { name: "Save Changes" }); + await act(async () => { + fireEvent.click(saveButtons[0]); + }); + + await waitFor(() => { + expect(networking.updateMCPServer).toHaveBeenCalledTimes(1); + }); + + const [, payload] = vi.mocked(networking.updateMCPServer).mock.calls[0]; + expect(payload.token_storage_ttl_seconds).toBe(7200); + }); +}); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx index e81d2f3960e..1a3e30cb15d 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from "react"; -import { Form, Select, Button as AntdButton, Tooltip, Input } from "antd"; +import { Form, Select, Button as AntdButton, Tooltip, Input, InputNumber } from "antd"; import { InfoCircleOutlined } from "@ant-design/icons"; import { Button, TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react"; import { AUTH_TYPE, OAUTH_FLOW, MCPServer, MCPServerCostInfo, TRANSPORT } from "./types"; @@ -190,6 +190,9 @@ const MCPServerEdit: React.FC = ({ transport: effectiveTransport, static_headers: initialStaticHeaders, oauth_flow_type: mcpServer.token_url ? OAUTH_FLOW.M2M : OAUTH_FLOW.INTERACTIVE, + token_validation_json: mcpServer.token_validation + ? JSON.stringify(mcpServer.token_validation, null, 2) + : undefined, }), [mcpServer, effectiveTransport, initialStaticHeaders, initialEnvJson], ); @@ -400,6 +403,7 @@ const MCPServerEdit: React.FC = ({ args: rawArgs, allow_all_keys: allowAllKeysRaw, available_on_public_internet: availableOnPublicInternetRaw, + token_validation_json: rawTokenValidationJson, ...restValues } = values; @@ -522,6 +526,17 @@ const MCPServerEdit: React.FC = ({ restValues.transport = "http"; } + // Parse token_validation JSON if provided + let tokenValidation: Record | null = null; + if (rawTokenValidationJson && rawTokenValidationJson.trim() !== "") { + try { + tokenValidation = JSON.parse(rawTokenValidationJson); + } catch { + NotificationsManager.fromBackend("Invalid JSON in Token Validation Rules"); + return; + } + } + // Prepare the payload with cost configuration and permission fields const mcpInfoServerName = restValues.server_name || @@ -556,6 +571,10 @@ const MCPServerEdit: React.FC = ({ static_headers: staticHeaders, allow_all_keys: Boolean(allowAllKeysRaw ?? mcpServer.allow_all_keys), available_on_public_internet: Boolean(availableOnPublicInternetRaw ?? mcpServer.available_on_public_internet), + // Include token_validation when it is set (non-null) or when clearing an existing value + ...(tokenValidation !== null || mcpServer.token_validation + ? { token_validation: tokenValidation } + : {}), }; const includeCredentials = restValues.auth_type && AUTH_TYPES_REQUIRING_CREDENTIALS.includes(restValues.auth_type); @@ -863,6 +882,58 @@ const MCPServerEdit: React.FC = ({ className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500" /> + {!isM2MFlow && ( + <> + + Token Validation Rules (optional) + + + + + } + name="token_validation_json" + rules={[ + { + validator: (_: any, value: string) => { + if (!value || value.trim() === "") return Promise.resolve(); + try { + JSON.parse(value); + return Promise.resolve(); + } catch { + return Promise.reject(new Error("Must be valid JSON")); + } + }, + }, + ]} + > + + + + Token Storage TTL (seconds, optional) + + + + + } + name="token_storage_ttl_seconds" + > + + + + )}

Use OAuth to fetch a fresh access token and temporarily save it in the session as the authentication value.

- -
-
+ {regeneratedKey} + + ) : (
+ - Current expiry: {selectedToken?.expires ? new Date(selectedToken.expires).toLocaleString() : "Never"} + Current expiry:{" "} + {selectedToken?.expires ? new Date(selectedToken.expires).toLocaleString() : "Never"} {newExpiryTime && ( -
- New expiry: {newExpiryTime} -
+ + New expiry: {newExpiryTime} + )} - +
} > From 5c4915ad0d02b57a184d46e27960ba4c9dd978e6 Mon Sep 17 00:00:00 2001 From: shivam Date: Thu, 9 Apr 2026 19:43:57 -0700 Subject: [PATCH 029/130] fix(proxy): pass-through multipart uploads and Bedrock custom body - Route multipart forwarding on forward_multipart instead of empty _parsed_body so litellm_logging_obj no longer forces json= for file uploads. - Remove custom_body from pass-through endpoint signatures; FastAPI treated it as a JSON body and rejected multipart before the handler ran. Bedrock passes JSON via request.state (LITELLM_PASS_THROUGH_CUSTOM_BODY_STATE_KEY). - Use build_request + send(stream=True) for streaming multipart; httpx 0.28 AsyncClient.request does not accept stream=. - Add regression test for non-empty _parsed_body multipart path; update Bedrock custom-body test and query-params test for forward_multipart. Made-with: Cursor --- .../llm_passthrough_endpoints.py | 3 +- .../pass_through_endpoints.py | 5717 +++++++++-------- .../test_pass_through_endpoints.py | 147 +- 3 files changed, 2997 insertions(+), 2870 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index 534022cc133..6e354290fe4 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -37,6 +37,7 @@ from litellm.proxy.common_utils.http_parsing_utils import ( from litellm.proxy.pass_through_endpoints.common_utils import get_litellm_virtual_key from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( HttpPassThroughEndpointHelpers, + LITELLM_PASS_THROUGH_CUSTOM_BODY_STATE_KEY, create_pass_through_route, create_websocket_passthrough_route, websocket_passthrough_request, @@ -1086,11 +1087,11 @@ async def bedrock_proxy_route( is_streaming_request=is_streaming_request, _forward_headers=True, ) # dynamically construct pass-through endpoint based on incoming path + setattr(request.state, LITELLM_PASS_THROUGH_CUSTOM_BODY_STATE_KEY, data) received_value = await endpoint_func( request, fastapi_response, user_api_key_dict, - custom_body=data, # type: ignore ) return received_value diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index 4f68c92b9d9..6f27dd4c199 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -1,2839 +1,2878 @@ -import ast -import asyncio -import copy -import json -import traceback -from base64 import b64encode -from datetime import datetime -from typing import Any, Dict, List, Optional, Tuple, Union, cast -from urllib.parse import urlencode, urlparse - -import httpx -from fastapi import ( - APIRouter, - Depends, - FastAPI, - HTTPException, - Request, - Response, - UploadFile, - WebSocket, - status, -) -from fastapi.responses import StreamingResponse -from starlette.datastructures import UploadFile as StarletteUploadFile -from starlette.websockets import WebSocketState -from websockets.asyncio.client import connect -from websockets.exceptions import ( - ConnectionClosedError, - ConnectionClosedOK, - InvalidStatus, -) - -import litellm -from litellm._logging import verbose_proxy_logger -from litellm._uuid import uuid -from litellm.constants import MAXIMUM_TRACEBACK_LINES_TO_LOG -from litellm.integrations.custom_logger import CustomLogger -from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj -from litellm.litellm_core_utils.safe_json_dumps import safe_dumps -from litellm.llms.custom_httpx.http_handler import get_async_httpx_client -from litellm.passthrough import BasePassthroughUtils -from litellm.proxy._types import ( - CommonProxyErrors, - ConfigFieldInfo, - ConfigFieldUpdate, - LiteLLMRoutes, - PassThroughEndpointResponse, - PassThroughGenericEndpoint, - ProxyException, - UserAPIKeyAuth, -) -from litellm.proxy.auth.user_api_key_auth import user_api_key_auth -from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing -from litellm.proxy.common_utils.http_parsing_utils import ( - _read_request_body, - _safe_get_request_headers, -) -from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup -from litellm.proxy.utils import get_server_root_path, normalize_route_for_root_path -from litellm.secret_managers.main import get_secret_str -from litellm.types.llms.custom_http import httpxSpecialProvider -from litellm.types.passthrough_endpoints.pass_through_endpoints import ( - EndpointType, - PassthroughStandardLoggingPayload, -) - -from .streaming_handler import PassThroughStreamingHandler -from .success_handler import PassThroughEndpointLogging - -router = APIRouter() - -pass_through_endpoint_logging = PassThroughEndpointLogging() - -# Global registry to track registered pass-through routes and prevent memory leaks -_registered_pass_through_routes: Dict[ - str, Dict[str, Union[str, List[str], Dict[str, Any]]] -] = {} - - -def get_response_body(response: httpx.Response) -> Optional[dict]: - try: - return response.json() - except Exception: - return None - - -async def set_env_variables_in_header(custom_headers: Optional[dict]) -> Optional[dict]: - """ - checks if any headers on config.yaml are defined as os.environ/COHERE_API_KEY etc - - only runs for headers defined on config.yaml - - example header can be - - {"Authorization": "Bearer os.environ/COHERE_API_KEY"} - """ - if custom_headers is None: - return None - headers = {} - for key, value in custom_headers.items(): - # langfuse Api requires base64 encoded headers - it's simpleer to just ask litellm users to set their langfuse public and secret keys - # we can then get the b64 encoded keys here - if key == "LANGFUSE_PUBLIC_KEY" or key == "LANGFUSE_SECRET_KEY": - # langfuse requires b64 encoded headers - we construct that here - _langfuse_public_key = custom_headers["LANGFUSE_PUBLIC_KEY"] - _langfuse_secret_key = custom_headers["LANGFUSE_SECRET_KEY"] - if isinstance( - _langfuse_public_key, str - ) and _langfuse_public_key.startswith("os.environ/"): - _langfuse_public_key = get_secret_str(_langfuse_public_key) - if isinstance( - _langfuse_secret_key, str - ) and _langfuse_secret_key.startswith("os.environ/"): - _langfuse_secret_key = get_secret_str(_langfuse_secret_key) - headers["Authorization"] = "Basic " + b64encode( - f"{_langfuse_public_key}:{_langfuse_secret_key}".encode("utf-8") - ).decode("ascii") - else: - # for all other headers - headers[key] = value - if isinstance(value, str) and "os.environ/" in value: - verbose_proxy_logger.debug( - "pass through endpoint - looking up 'os.environ/' variable" - ) - # get string section that is os.environ/ - start_index = value.find("os.environ/") - _variable_name = value[start_index:] - - verbose_proxy_logger.debug( - "pass through endpoint - getting secret for variable name: %s", - _variable_name, - ) - _secret_value = get_secret_str(_variable_name) - if _secret_value is not None: - new_value = value.replace(_variable_name, _secret_value) - headers[key] = new_value - return headers - - -async def chat_completion_pass_through_endpoint( # noqa: PLR0915 - fastapi_response: Response, - request: Request, - adapter_id: str, - user_api_key_dict: UserAPIKeyAuth, -): - from litellm.proxy.proxy_server import ( - add_litellm_data_to_request, - general_settings, - llm_router, - proxy_config, - proxy_logging_obj, - user_api_base, - user_max_tokens, - user_model, - user_request_timeout, - user_temperature, - version, - ) - - data = {} - try: - body = await request.body() - body_str = body.decode() - try: - data = ast.literal_eval(body_str) - except Exception: - data = json.loads(body_str) - - data["adapter_id"] = adapter_id - - verbose_proxy_logger.debug( - "Request received by LiteLLM:\n{}".format(json.dumps(data, indent=4)), - ) - data["model"] = ( - general_settings.get("completion_model", None) # server default - or user_model # model name passed via cli args - or data.get("model", None) # default passed in http request - ) - if user_model: - data["model"] = user_model - - data = await add_litellm_data_to_request( - data=data, # type: ignore - request=request, - general_settings=general_settings, - user_api_key_dict=user_api_key_dict, - version=version, - proxy_config=proxy_config, - ) - - # override with user settings, these are params passed via cli - if user_temperature: - data["temperature"] = user_temperature - if user_request_timeout: - data["request_timeout"] = user_request_timeout - if user_max_tokens: - data["max_tokens"] = user_max_tokens - if user_api_base: - data["api_base"] = user_api_base - - ### MODEL ALIAS MAPPING ### - # check if model name in model alias map - # get the actual model name - if data["model"] in litellm.model_alias_map: - data["model"] = litellm.model_alias_map[data["model"]] - - # Check key-specific aliases - if ( - isinstance(data["model"], str) - and user_api_key_dict.aliases - and isinstance(user_api_key_dict.aliases, dict) - and data["model"] in user_api_key_dict.aliases - ): - data["model"] = user_api_key_dict.aliases[data["model"]] - - ### CALL HOOKS ### - modify incoming data before calling the model - data = await proxy_logging_obj.pre_call_hook( # type: ignore - user_api_key_dict=user_api_key_dict, data=data, call_type="text_completion" - ) - - ### ROUTE THE REQUESTs ### - router_model_names = llm_router.model_names if llm_router is not None else [] - # skip router if user passed their key - if "api_key" in data: - llm_response = asyncio.create_task(litellm.aadapter_completion(**data)) - elif ( - llm_router is not None and data["model"] in router_model_names - ): # model in router model list - llm_response = asyncio.create_task(llm_router.aadapter_completion(**data)) - elif ( - llm_router is not None - and llm_router.model_group_alias is not None - and data["model"] in llm_router.model_group_alias - ): # model set in model_group_alias - llm_response = asyncio.create_task(llm_router.aadapter_completion(**data)) - elif llm_router is not None and llm_router.has_model_id( - data["model"] - ): # model in router model list - llm_response = asyncio.create_task(llm_router.aadapter_completion(**data)) - elif ( - llm_router is not None - and data["model"] not in router_model_names - and ( - llm_router.default_deployment is not None - or len(llm_router.pattern_router.patterns) > 0 - ) - ): # check for wildcard routes or default deployment before checking deployment_names - llm_response = asyncio.create_task(llm_router.aadapter_completion(**data)) - elif ( - llm_router is not None and data["model"] in llm_router.deployment_names - ): # model in router deployments, calling a specific deployment on the router (lowest priority) - llm_response = asyncio.create_task( - llm_router.aadapter_completion(**data, specific_deployment=True) - ) - elif user_model is not None: # `litellm --model ` - llm_response = asyncio.create_task(litellm.aadapter_completion(**data)) - else: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail={ - "error": "completion: Invalid model name passed in model=" - + data.get("model", "") - }, - ) - - # Await the llm_response task - response = await llm_response - - hidden_params = getattr(response, "_hidden_params", {}) or {} - model_id = hidden_params.get("model_id", None) or "" - cache_key = hidden_params.get("cache_key", None) or "" - api_base = hidden_params.get("api_base", None) or "" - response_cost = hidden_params.get("response_cost", None) or "" - - ### ALERTING ### - asyncio.create_task( - proxy_logging_obj.update_request_status( - litellm_call_id=data.get("litellm_call_id", ""), status="success" - ) - ) - - verbose_proxy_logger.debug("final response: %s", response) - - fastapi_response.headers.update( - ProxyBaseLLMRequestProcessing.get_custom_headers( - user_api_key_dict=user_api_key_dict, - model_id=model_id, - cache_key=cache_key, - api_base=api_base, - version=version, - response_cost=response_cost, - ) - ) - - verbose_proxy_logger.debug("\nResponse from Litellm:\n{}".format(response)) - return response - except Exception as e: - await proxy_logging_obj.post_call_failure_hook( - user_api_key_dict=user_api_key_dict, original_exception=e, request_data=data - ) - verbose_proxy_logger.exception( - "litellm.proxy.proxy_server.completion(): Exception occured - {}".format( - str(e) - ) - ) - error_msg = f"{str(e)}" - raise ProxyException( - message=getattr(e, "message", error_msg), - type=getattr(e, "type", "None"), - param=getattr(e, "param", "None"), - code=getattr(e, "status_code", 500), - ) - - -class HttpPassThroughEndpointHelpers(BasePassthroughUtils): - @staticmethod - def get_response_headers( - headers: httpx.Headers, - litellm_call_id: Optional[str] = None, - custom_headers: Optional[dict] = None, - ) -> dict: - excluded_headers = {"transfer-encoding", "content-encoding"} - - return_headers = { - key: value - for key, value in headers.items() - if key.lower() not in excluded_headers - } - if litellm_call_id: - return_headers["x-litellm-call-id"] = litellm_call_id - if custom_headers: - return_headers.update(custom_headers) - - return return_headers - - @staticmethod - def get_endpoint_type(url: str) -> EndpointType: - parsed_url = urlparse(url) - if ( - ("generateContent") in url - or ("streamGenerateContent") in url - or ("rawPredict") in url - or ("streamRawPredict") in url - ): - return EndpointType.VERTEX_AI - elif parsed_url.hostname == "api.anthropic.com": - return EndpointType.ANTHROPIC - elif ( - parsed_url.hostname == "api.openai.com" - or parsed_url.hostname == "openai.azure.com" - or (parsed_url.hostname and "openai.com" in parsed_url.hostname) - ): - return EndpointType.OPENAI - return EndpointType.GENERIC - - @staticmethod - async def _make_non_streaming_http_request( - request: Request, - async_client: httpx.AsyncClient, - url: str, - headers: dict, - requested_query_params: Optional[dict] = None, - custom_body: Optional[dict] = None, - ) -> httpx.Response: - """ - Make a non-streaming HTTP request - - If request is GET, don't include a JSON body - """ - if request.method == "GET": - response = await async_client.request( - method=request.method, - url=url, - headers=headers, - params=requested_query_params, - ) - else: - response = await async_client.request( - method=request.method, - url=url, - headers=headers, - params=requested_query_params, - json=custom_body, - ) - return response - - @staticmethod - async def non_streaming_http_request_handler( - request: Request, - async_client: httpx.AsyncClient, - url: httpx.URL, - headers: dict, - requested_query_params: Optional[dict] = None, - _parsed_body: Optional[dict] = None, - ) -> httpx.Response: - """ - Handle non-streaming HTTP requests - - Handles special cases when GET requests, multipart/form-data requests, and generic httpx requests - """ - if request.method == "GET": - response = await async_client.request( - method=request.method, - url=url, - headers=headers, - params=requested_query_params, - ) - elif ( - HttpPassThroughEndpointHelpers.is_multipart(request) is True - and not _parsed_body - ): - # Only use multipart handler if we don't have a parsed body - # (parsed body means it was JSON despite multipart content-type header) - return await HttpPassThroughEndpointHelpers.make_multipart_http_request( - request=request, - async_client=async_client, - url=url, - headers=headers, - requested_query_params=requested_query_params, - ) - else: - # Generic httpx method - response = await async_client.request( - method=request.method, - url=url, - headers=headers, - params=requested_query_params, - json=_parsed_body, - ) - return response - - @staticmethod - def is_multipart(request: Request) -> bool: - """Check if the request is a multipart/form-data request""" - return "multipart/form-data" in request.headers.get("content-type", "") - - @staticmethod - async def _build_request_files_from_upload_file( - upload_file: Union[UploadFile, StarletteUploadFile], - ) -> Tuple[Optional[str], bytes, Optional[str]]: - """Build a request files dict from an UploadFile object""" - file_content = await upload_file.read() - return (upload_file.filename, file_content, upload_file.content_type) - - @staticmethod - async def make_multipart_http_request( - request: Request, - async_client: httpx.AsyncClient, - url: httpx.URL, - headers: dict, - requested_query_params: Optional[dict] = None, - ) -> httpx.Response: - """Process multipart/form-data requests, handling both files and form fields""" - form_data = await request.form() - files = {} - form_data_dict = {} - - for field_name, field_value in form_data.items(): - if isinstance(field_value, (StarletteUploadFile, UploadFile)): - files[ - field_name - ] = await HttpPassThroughEndpointHelpers._build_request_files_from_upload_file( - upload_file=field_value - ) - else: - form_data_dict[field_name] = field_value - - # Remove content-type header - httpx will set it correctly with the new boundary - # when it creates the multipart body from files/data parameters - headers_copy = headers.copy() - headers_copy.pop("content-type", None) - - response = await async_client.request( - method=request.method, - url=url, - headers=headers_copy, - params=requested_query_params, - files=files, - data=form_data_dict, - ) - return response - - @staticmethod - def _init_kwargs_for_pass_through_endpoint( - request: Request, - user_api_key_dict: UserAPIKeyAuth, - passthrough_logging_payload: PassthroughStandardLoggingPayload, - logging_obj: LiteLLMLoggingObj, - _parsed_body: Optional[dict] = None, - litellm_call_id: Optional[str] = None, - ) -> dict: - """ - Filter out litellm params from the request body - """ - from litellm.types.utils import all_litellm_params - - _parsed_body = _parsed_body or {} - - litellm_params_in_body = {} - for k in all_litellm_params: - if k in _parsed_body: - litellm_params_in_body[k] = _parsed_body.pop(k, None) - - _metadata = dict( - LiteLLMProxyRequestSetup.get_sanitized_user_information_from_key( - user_api_key_dict=user_api_key_dict - ) - ) - - _metadata["user_api_key"] = user_api_key_dict.api_key - - litellm_metadata = litellm_params_in_body.pop("litellm_metadata", None) - metadata = litellm_params_in_body.pop("metadata", None) - if litellm_metadata: - _metadata.update(litellm_metadata) - if metadata: - _metadata.update(metadata) - - _metadata = _update_metadata_with_tags_in_header( - request=request, - metadata=_metadata, - ) - - kwargs = { - "litellm_params": { - **litellm_params_in_body, # type: ignore - "metadata": _metadata, - "proxy_server_request": { - "url": str(request.url), - "method": request.method, - "body": copy.copy(_parsed_body), # use copy instead of deepcopy - "headers": request.headers, - }, - }, - "call_type": "pass_through_endpoint", - "litellm_call_id": litellm_call_id, - "passthrough_logging_payload": passthrough_logging_payload, - } - - logging_obj.model_call_details[ - "passthrough_logging_payload" - ] = passthrough_logging_payload - - return kwargs - - @staticmethod - def construct_target_url_with_subpath( - base_target: str, subpath: str, include_subpath: Optional[bool] - ) -> str: - """ - Helper function to construct the full target URL with subpath handling. - - Args: - base_target: The base target URL - subpath: The captured subpath from the request - include_subpath: Whether to include the subpath in the target URL - - Returns: - The constructed full target URL - """ - if not include_subpath: - return base_target - - if not subpath: - return base_target - - # Ensure base_target ends with / and subpath doesn't start with / - if not base_target.endswith("/"): - base_target = base_target + "/" - if subpath.startswith("/"): - subpath = subpath[1:] - - return base_target + subpath - - @staticmethod - def _update_stream_param_based_on_request_body( - parsed_body: dict, - stream: Optional[bool] = None, - ) -> Optional[bool]: - """ - If stream is provided in the request body, use it. - Otherwise, use the stream parameter passed to the `pass_through_request` function - """ - if "stream" in parsed_body: - return parsed_body.get("stream", stream) - return stream - - -async def pass_through_request( # noqa: PLR0915 - request: Request, - target: str, - custom_headers: dict, - user_api_key_dict: UserAPIKeyAuth, - custom_body: Optional[dict] = None, - forward_headers: Optional[bool] = False, - merge_query_params: Optional[bool] = False, - query_params: Optional[dict] = None, - default_query_params: Optional[dict] = None, - stream: Optional[bool] = None, - cost_per_request: Optional[float] = None, - custom_llm_provider: Optional[str] = None, - guardrails_config: Optional[dict] = None, -): - """ - Pass through endpoint handler, makes the httpx request for pass-through endpoints and ensures logging hooks are called - - Args: - request: The incoming request - target: The target URL - custom_headers: The custom headers - user_api_key_dict: The user API key dictionary - custom_body: The custom body - forward_headers: Whether to forward headers - merge_query_params: Whether to merge query params - query_params: The query params - default_query_params: The default query params to be applied if not overridden by client - stream: Whether to stream the response - cost_per_request: Optional field - cost per request to the target endpoint - custom_llm_provider: Optional field - custom LLM provider for the endpoint - guardrails_config: Optional field - guardrails configuration for passthrough endpoint - """ - from litellm.litellm_core_utils.litellm_logging import Logging - from litellm.proxy.pass_through_endpoints.passthrough_guardrails import ( - PassthroughGuardrailHandler, - ) - from litellm.proxy.proxy_server import proxy_logging_obj - - ######################################################### - # Initialize variables - ######################################################### - litellm_call_id = str(uuid.uuid4()) - url: Optional[httpx.URL] = None - - # parsed request body - _parsed_body: Optional[dict] = None - # kwargs for pass through endpoint, contains metadata, litellm_params, call_type, litellm_call_id, passthrough_logging_payload - kwargs: Optional[dict] = None - logging_obj: Optional[Logging] = None - - ######################################################### - try: - url = httpx.URL(target) - headers = custom_headers - headers = HttpPassThroughEndpointHelpers.forward_headers_from_request( - request_headers=_safe_get_request_headers(request).copy(), - headers=headers, - forward_headers=forward_headers, - ) - - # Apply default query parameters if provided, regardless of merge_query_params setting - if default_query_params or merge_query_params: - # Determine what to merge based on settings - request_params = dict(request.query_params) if merge_query_params else {} - - # Create a new URL with the merged query params - url = url.copy_with( - query=urlencode( - HttpPassThroughEndpointHelpers.get_merged_query_parameters( - existing_url=url, - request_query_params=request_params, - default_query_params=default_query_params, - ) - ).encode("ascii") - ) - - endpoint_type: EndpointType = HttpPassThroughEndpointHelpers.get_endpoint_type( - str(url) - ) - - # Skip body parsing for multipart requests - make_multipart_http_request will handle it - # But if custom_body is provided (e.g., JSON parsed despite multipart content-type), use it - is_multipart = ( - HttpPassThroughEndpointHelpers.is_multipart(request) and not custom_body - ) - - if custom_body: - _parsed_body = custom_body - elif is_multipart: - # Don't parse multipart body here - it will be handled by make_multipart_http_request - _parsed_body = {} - else: - _parsed_body = await _read_request_body(request) - verbose_proxy_logger.debug( - "Pass through endpoint sending request to \nURL {}\nheaders: {}\nbody: {}\n".format( - url, headers, _parsed_body - ) - ) - - ### COLLECT GUARDRAILS FOR PASSTHROUGH ENDPOINT ### - # Passthrough endpoints are opt-in only for guardrails - # When enabled, collect guardrails from org/team/key levels + passthrough-specific - guardrails_to_run = PassthroughGuardrailHandler.collect_guardrails( - user_api_key_dict=user_api_key_dict, - passthrough_guardrails_config=guardrails_config, - ) - - # Add guardrails to metadata if any should run - if guardrails_to_run and len(guardrails_to_run) > 0: - if _parsed_body is None: - _parsed_body = {} - if "metadata" not in _parsed_body: - _parsed_body["metadata"] = {} - _parsed_body["metadata"]["guardrails"] = guardrails_to_run - verbose_proxy_logger.debug( - f"Added guardrails to passthrough request metadata: {guardrails_to_run}" - ) - - ## LOGGING OBJECT ## - initialize before pre_call_hook so guardrails can access it - start_time = datetime.now() - logging_obj = Logging( - model="unknown", - messages=[{"role": "user", "content": safe_dumps(_parsed_body)}], - stream=False, - call_type="pass_through_endpoint", - start_time=start_time, - litellm_call_id=litellm_call_id, - function_id="1245", - ) - - # Store passthrough guardrails config on logging_obj for field targeting - logging_obj.passthrough_guardrails_config = guardrails_config - - # Store logging_obj in data so guardrails can access it - if _parsed_body is None: - _parsed_body = {} - _parsed_body["litellm_logging_obj"] = logging_obj - - ### CALL HOOKS ### - modify incoming data / reject request before calling the model - _parsed_body = await proxy_logging_obj.pre_call_hook( - user_api_key_dict=user_api_key_dict, - data=_parsed_body, - call_type="pass_through_endpoint", - ) - async_client_obj = get_async_httpx_client( - llm_provider=httpxSpecialProvider.PassThroughEndpoint, - params={"timeout": 600}, - ) - async_client = async_client_obj.client - passthrough_logging_payload = PassthroughStandardLoggingPayload( - url=str(url), - request_body=_parsed_body, - request_method=getattr(request, "method", None), - cost_per_request=cost_per_request, - ) - kwargs = HttpPassThroughEndpointHelpers._init_kwargs_for_pass_through_endpoint( - user_api_key_dict=user_api_key_dict, - _parsed_body=_parsed_body, - passthrough_logging_payload=passthrough_logging_payload, - litellm_call_id=litellm_call_id, - request=request, - logging_obj=logging_obj, - ) - - # Store custom_llm_provider in kwargs and logging object if provided - if custom_llm_provider: - logging_obj.model_call_details["custom_llm_provider"] = custom_llm_provider - logging_obj.model_call_details["litellm_params"] = kwargs.get( - "litellm_params", {} - ) - - # done for supporting 'parallel_request_limiter.py' with pass-through endpoints - logging_obj.update_environment_variables( - model="unknown", - user="unknown", - optional_params={}, - litellm_params=kwargs["litellm_params"], - call_type="pass_through_endpoint", - ) - logging_obj.model_call_details["litellm_call_id"] = litellm_call_id - - # combine url with query params for logging - requested_query_params: Optional[dict] = query_params or dict( - request.query_params - ) - - requested_query_params_str = None - if requested_query_params: - requested_query_params_str = "&".join( - f"{k}={v}" for k, v in requested_query_params.items() - ) - - logging_url = str(url) - if requested_query_params_str: - if "?" in str(url): - logging_url = str(url) + "&" + requested_query_params_str - else: - logging_url = str(url) + "?" + requested_query_params_str - - logging_obj.pre_call( - input=[{"role": "user", "content": safe_dumps(_parsed_body)}], - api_key="", - additional_args={ - "complete_input_dict": _parsed_body, - "api_base": str(logging_url), - "headers": headers, - }, - ) - stream = ( - HttpPassThroughEndpointHelpers._update_stream_param_based_on_request_body( - parsed_body=_parsed_body, - stream=stream, - ) - ) - - if stream: - req = async_client.build_request( - "POST", - url, - json=_parsed_body, - params=requested_query_params, - headers=headers, - ) - - response = await async_client.send(req, stream=stream) - - try: - response.raise_for_status() - except httpx.HTTPStatusError as e: - raise HTTPException( - status_code=e.response.status_code, detail=await e.response.aread() - ) - - return StreamingResponse( - PassThroughStreamingHandler.chunk_processor( - response=response, - request_body=_parsed_body, - litellm_logging_obj=logging_obj, - endpoint_type=endpoint_type, - start_time=start_time, - passthrough_success_handler_obj=pass_through_endpoint_logging, - url_route=str(url), - ), - headers=HttpPassThroughEndpointHelpers.get_response_headers( - headers=response.headers, - litellm_call_id=litellm_call_id, - ), - status_code=response.status_code, - ) - - response = ( - await HttpPassThroughEndpointHelpers.non_streaming_http_request_handler( - request=request, - async_client=async_client, - url=url, - headers=headers, - requested_query_params=requested_query_params, - _parsed_body=_parsed_body, - ) - ) - verbose_proxy_logger.debug("response.headers= %s", response.headers) - - if _is_streaming_response(response) is True: - try: - response.raise_for_status() - except httpx.HTTPStatusError as e: - raise HTTPException( - status_code=e.response.status_code, detail=await e.response.aread() - ) - - return StreamingResponse( - PassThroughStreamingHandler.chunk_processor( - response=response, - request_body=_parsed_body, - litellm_logging_obj=logging_obj, - endpoint_type=endpoint_type, - start_time=start_time, - passthrough_success_handler_obj=pass_through_endpoint_logging, - url_route=str(url), - ), - headers=HttpPassThroughEndpointHelpers.get_response_headers( - headers=response.headers, - litellm_call_id=litellm_call_id, - ), - status_code=response.status_code, - ) - - try: - response.raise_for_status() - except httpx.HTTPStatusError as e: - raise HTTPException( - status_code=e.response.status_code, detail=e.response.text - ) - - if response.status_code >= 300: - raise HTTPException(status_code=response.status_code, detail=response.text) - - content = await response.aread() - - ## LOG SUCCESS - response_body: Optional[dict] = get_response_body(response) - passthrough_logging_payload["response_body"] = response_body - end_time = datetime.now() - asyncio.create_task( - pass_through_endpoint_logging.pass_through_async_success_handler( - httpx_response=response, - response_body=response_body, - url_route=str(url), - result="", - start_time=start_time, - end_time=end_time, - logging_obj=logging_obj, - cache_hit=False, - request_body=_parsed_body, - custom_llm_provider=custom_llm_provider, - **kwargs, - ) - ) - - ## CUSTOM HEADERS - `x-litellm-*` - custom_headers = ProxyBaseLLMRequestProcessing.get_custom_headers( - user_api_key_dict=user_api_key_dict, - call_id=litellm_call_id, - model_id=None, - cache_key=None, - api_base=str(url._uri_reference), - ) - - return Response( - content=content, - status_code=response.status_code, - headers=HttpPassThroughEndpointHelpers.get_response_headers( - headers=response.headers, - custom_headers=custom_headers, - ), - ) - except Exception as e: - custom_headers = ProxyBaseLLMRequestProcessing.get_custom_headers( - user_api_key_dict=user_api_key_dict, - call_id=litellm_call_id, - model_id=None, - cache_key=None, - api_base=str(url._uri_reference) if url else None, - ) - verbose_proxy_logger.exception( - "litellm.proxy.proxy_server.pass_through_endpoint(): Exception occured - {}".format( - str(e) - ) - ) - - ######################################################### - # Monitoring: Trigger post_call_failure_hook - # for pass through endpoint failure - ######################################################### - request_payload: dict = _parsed_body or {} - # add user_api_key_dict, litellm_call_id, passthrough_logging_payloa for logging - if kwargs: - for key, value in kwargs.items(): - request_payload[key] = value - if logging_obj is not None: - request_payload["litellm_logging_obj"] = logging_obj - - if ( - "model" not in request_payload - and _parsed_body - and isinstance(_parsed_body, dict) - ): - request_payload["model"] = _parsed_body.get("model", "") - if "custom_llm_provider" not in request_payload and custom_llm_provider: - request_payload["custom_llm_provider"] = custom_llm_provider - - await proxy_logging_obj.post_call_failure_hook( - user_api_key_dict=user_api_key_dict, - original_exception=e, - request_data=request_payload, - traceback_str=traceback.format_exc( - limit=MAXIMUM_TRACEBACK_LINES_TO_LOG, - ), - ) - - ######################################################### - - if isinstance(e, HTTPException): - raise ProxyException( - message=getattr(e, "message", str(getattr(e, "detail", str(e)))), - type=getattr(e, "type", "None"), - param=getattr(e, "param", "None"), - code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST), - headers=custom_headers, - ) - else: - error_msg = f"{str(e)}" - raise ProxyException( - message=getattr(e, "message", error_msg), - type=getattr(e, "type", "None"), - param=getattr(e, "param", "None"), - code=getattr(e, "status_code", 500), - headers=custom_headers, - ) - - -def _update_metadata_with_tags_in_header(request: Request, metadata: dict) -> dict: - """ - If tags are in the request headers, add them to the metadata - - Used for google and vertex JS SDKs, and Azure passthrough - Checks both 'tags' and 'x-litellm-tags' headers - """ - tags_to_add = [] - - # Check for 'tags' header first - _tags = request.headers.get("tags") - if _tags: - tags_to_add.extend([tag.strip() for tag in _tags.split(",")]) - - _tags = request.headers.get("x-litellm-tags") - if _tags: - tags_to_add.extend([tag.strip() for tag in _tags.split(",")]) - - # Only add tags key if there are tags to add - if tags_to_add: - if "tags" not in metadata: - metadata["tags"] = [] - metadata["tags"].extend(tags_to_add) - - return metadata - - -async def _parse_request_data_by_content_type( - request: Request, -) -> Tuple[Optional[Any], Optional[Any], Optional[Any], Optional[Any]]: - """ - Parse request data based on content type. - - Handles JSON, multipart/form-data, and URL-encoded form data. - - Returns: - Tuple of (query_params_data, custom_body_data, file_data, stream) - """ - content_type = request.headers.get("content-type", "") - - query_params_data = None - custom_body_data = None - file_data = None - stream = None - - if "application/json" in content_type: - # ✅ Handle JSON - try: - body = await request.json() - query_params_data = body.get("query_params") - custom_body_data = body.get("custom_body") - stream = body.get("stream") - except json.JSONDecodeError: - # Handle requests with no body (e.g., DELETE requests) - pass - elif "multipart/form-data" in content_type: - # ✅ Try to parse as JSON first (handles misconfigured clients sending JSON with multipart content-type) - # If that fails, skip parsing - pass_through_request will handle actual multipart - try: - body = await request.json() - # Successfully parsed as JSON - treat as JSON body - query_params_data = body.get("query_params") - custom_body_data = body.get("custom_body") - stream = body.get("stream") - # If custom_body is not set, use the entire body - if custom_body_data is None and body: - custom_body_data = body - except (json.JSONDecodeError, Exception): - # Not JSON - this is actual multipart data - # Skip parsing here to avoid consuming the request body stream - # make_multipart_http_request will handle it - pass - - elif "application/x-www-form-urlencoded" in content_type: - # ✅ Handle URL-encoded form data - form = await request.form() - query_params_data = form.get("query_params") - custom_body_data = form.get("custom_body") - - else: - # ✅ Fallback: maybe no body, just query params - query_params_data = dict(request.query_params) or None - - return query_params_data, custom_body_data, file_data, stream - - -def create_pass_through_route( - endpoint, - target: str, - custom_headers: Optional[dict] = None, - _forward_headers: Optional[bool] = False, - _merge_query_params: Optional[bool] = False, - dependencies: Optional[List] = None, - include_subpath: Optional[bool] = False, - cost_per_request: Optional[float] = None, - custom_llm_provider: Optional[str] = None, - is_streaming_request: Optional[bool] = False, - query_params: Optional[dict] = None, - default_query_params: Optional[dict] = None, - guardrails: Optional[Dict[str, Any]] = None, -): - # check if target is an adapter.py or a url - from litellm._uuid import uuid - from litellm.proxy.types_utils.utils import get_instance_fn - - try: - if isinstance(target, CustomLogger): - adapter = target - else: - adapter = get_instance_fn(value=target) - adapter_id = str(uuid.uuid4()) - litellm.adapters = [{"id": adapter_id, "adapter": adapter}] - - async def endpoint_func( # type: ignore - request: Request, - fastapi_response: Response, - user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), - subpath: str = "", # captures sub-paths when include_subpath=True - custom_body: Optional[ - dict - ] = None, # accepted for signature compatibility with URL-based path; not forwarded because chat_completion_pass_through_endpoint does not support it - ): - return await chat_completion_pass_through_endpoint( - fastapi_response=fastapi_response, - request=request, - adapter_id=adapter_id, - user_api_key_dict=user_api_key_dict, - ) - - except Exception: - verbose_proxy_logger.debug("Defaulting to target being a url.") - - async def endpoint_func( # type: ignore - request: Request, - fastapi_response: Response, - user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), - subpath: str = "", # captures sub-paths when include_subpath=True - custom_body: Optional[ - dict - ] = None, # caller-supplied body takes precedence over request-parsed body - ): - from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( - InitPassThroughEndpointHelpers, - ) - - path = request.url.path - - # Parse request data based on content type - ( - query_params_data, - custom_body_data, - file_data, - stream, - ) = await _parse_request_data_by_content_type(request) - - if not InitPassThroughEndpointHelpers.is_registered_pass_through_route( - route=path - ): - raise HTTPException( - status_code=404, - detail=f"Pass-through endpoint {endpoint} not found. This could have been deleted or not yet added to the proxy.", - ) - - passthrough_params = ( - InitPassThroughEndpointHelpers.get_registered_pass_through_route( - route=path, method=request.method - ) - ) - target_params = { - "target": target, - "custom_headers": custom_headers, - "forward_headers": _forward_headers, - "merge_query_params": _merge_query_params, - "cost_per_request": cost_per_request, - "guardrails": None, - } - - if passthrough_params is not None: - target_params.update(passthrough_params.get("passthrough_params", {})) - - # Extract and cast parameters with proper types - param_target = target_params.get("target") or target - param_custom_headers = target_params.get("custom_headers", custom_headers) - param_forward_headers = target_params.get( - "forward_headers", _forward_headers - ) - param_merge_query_params = target_params.get( - "merge_query_params", _merge_query_params - ) - param_cost_per_request = target_params.get( - "cost_per_request", cost_per_request - ) - param_guardrails = target_params.get("guardrails", None) - param_default_query_params = target_params.get("default_query_params", None) - - # Construct the full target URL with subpath if needed - full_target = ( - HttpPassThroughEndpointHelpers.construct_target_url_with_subpath( - base_target=cast(str, param_target), - subpath=subpath, - include_subpath=include_subpath, - ) - ) - - # Ensure custom_headers is a dict - headers_dict = ( - param_custom_headers if isinstance(param_custom_headers, dict) else {} - ) - - # Ensure query_params and custom_body are dicts or None - final_query_params = ( - query_params_data if isinstance(query_params_data, dict) else {} - ) - if query_params: - final_query_params.update(query_params) - # Caller-supplied custom_body takes precedence over the request-parsed body - final_custom_body: Optional[dict] = None - if custom_body is not None: - final_custom_body = custom_body - elif isinstance(custom_body_data, dict): - final_custom_body = custom_body_data - - return await pass_through_request( # type: ignore - request=request, - target=full_target, - custom_headers=headers_dict, - user_api_key_dict=user_api_key_dict, - forward_headers=cast(Optional[bool], param_forward_headers), - merge_query_params=cast(Optional[bool], param_merge_query_params), - query_params=final_query_params, - default_query_params=cast(Optional[dict], param_default_query_params), - stream=is_streaming_request or stream, - custom_body=final_custom_body, - cost_per_request=cast(Optional[float], param_cost_per_request), - custom_llm_provider=custom_llm_provider, - guardrails_config=cast(Optional[dict], param_guardrails), - ) - - return endpoint_func - - -def create_websocket_passthrough_route( - endpoint: str, - target: str, - custom_headers: Optional[dict] = None, - _forward_headers: Optional[bool] = False, - dependencies: Optional[List] = None, - cost_per_request: Optional[float] = None, -): - """ - Create a WebSocket passthrough route function. - - Args: - endpoint: The endpoint path (for logging purposes) - target: The target WebSocket URL (e.g., "wss://api.example.com/ws") - custom_headers: Custom headers to include in the WebSocket connection - _forward_headers: Whether to forward incoming headers - dependencies: FastAPI dependencies to inject - - Returns: - A WebSocket passthrough function that can be registered with app.websocket() - """ - from litellm.proxy.auth.user_api_key_auth import user_api_key_auth_websocket - - async def websocket_endpoint_func( - websocket: WebSocket, - user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth_websocket), - **kwargs, # For additional query parameters - ): - """ - WebSocket passthrough endpoint function. - - This function handles the WebSocket connection by: - 1. Accepting the incoming WebSocket connection - 2. Establishing a connection to the target WebSocket - 3. Forwarding messages bidirectionally - 4. Handling connection cleanup - """ - return await websocket_passthrough_request( - websocket=websocket, - target=target, - custom_headers=custom_headers or {}, - user_api_key_dict=user_api_key_dict, - forward_headers=_forward_headers, - endpoint=endpoint, - cost_per_request=cost_per_request, - accept_websocket=True, # Generic usage should accept the WebSocket - ) - - return websocket_endpoint_func - - -async def websocket_passthrough_request( # noqa: PLR0915 - websocket: WebSocket, - target: str, - custom_headers: dict, - user_api_key_dict: UserAPIKeyAuth, - forward_headers: Optional[bool] = False, - endpoint: Optional[str] = None, - cost_per_request: Optional[float] = None, - accept_websocket: bool = True, -): - """ - WebSocket passthrough request handler. - - Args: - websocket: The incoming WebSocket connection - target: The target WebSocket URL - custom_headers: Custom headers to include in the connection - user_api_key_dict: The user API key dictionary - forward_headers: Whether to forward incoming headers - endpoint: The endpoint path (for logging purposes) - cost_per_request: Optional field - cost per request to the target endpoint - """ - from litellm.litellm_core_utils.litellm_logging import Logging - from litellm.proxy.proxy_server import proxy_logging_obj - from litellm.types.passthrough_endpoints.pass_through_endpoints import ( - PassthroughStandardLoggingPayload, - ) - - # Initialize tracking variables - start_time = datetime.now() - websocket_messages: list[dict[str, Any]] = [] - litellm_call_id = str(uuid.uuid4()) - - verbose_proxy_logger.info( - f"WebSocket passthrough ({endpoint}): Starting WebSocket connection to {target}" - ) - - # Only accept the WebSocket if requested (for generic usage) - if accept_websocket: - await websocket.accept() - verbose_proxy_logger.debug( - f"WebSocket passthrough ({endpoint}): WebSocket connection accepted" - ) - - # Prepare headers for the upstream connection - upstream_headers = custom_headers.copy() - - if forward_headers: - # Forward relevant headers from the incoming request - incoming_headers = dict(websocket.headers) - for header_name, header_value in incoming_headers.items(): - # Only forward certain headers to avoid conflicts - if header_name.lower() in [ - "authorization", - "x-api-key", - "x-goog-user-project", - ]: - upstream_headers[header_name] = header_value - - # Initialize logging object similar to HTTP passthrough - logging_obj = Logging( - model="unknown", - messages=[{"role": "user", "content": "WebSocket connection"}], - stream=True, # WebSockets are inherently streaming - call_type="pass_through_endpoint", - start_time=start_time, - litellm_call_id=litellm_call_id, - function_id="websocket_passthrough", - ) - - # Create passthrough logging payload - passthrough_logging_payload = PassthroughStandardLoggingPayload( - url=target, - request_body={}, # WebSocket doesn't have a traditional request body - request_method="WEBSOCKET", - cost_per_request=cost_per_request, - ) - - # Create a dummy request object for WebSocket connections to maintain compatibility - # with the existing _init_kwargs_for_pass_through_endpoint function - class DummyRequest: - def __init__( - self, url: str, method: str = "WEBSOCKET", headers: Optional[dict] = None - ): - self.url = url - self.method = method - self.headers = headers or {} - - def __str__(self): - return f"DummyRequest(url={self.url}, method={self.method})" - - dummy_request = DummyRequest( - url=target, - method="WEBSOCKET", - headers=dict(websocket.headers) if hasattr(websocket, "headers") else {}, - ) - - # Initialize kwargs for logging using the same pattern as HTTP passthrough - kwargs = HttpPassThroughEndpointHelpers._init_kwargs_for_pass_through_endpoint( - user_api_key_dict=user_api_key_dict, - _parsed_body={}, # WebSocket doesn't have a traditional request body - passthrough_logging_payload=passthrough_logging_payload, - litellm_call_id=litellm_call_id, - request=dummy_request, # type: ignore - logging_obj=logging_obj, - ) - - # Update logging environment variables - logging_obj.update_environment_variables( - model="unknown", - user="unknown", - optional_params={}, - litellm_params=dict(kwargs.get("litellm_params", {})), - call_type="pass_through_endpoint", - ) - logging_obj.model_call_details["litellm_call_id"] = litellm_call_id - - # Pre-call logging - logging_obj.pre_call( - input=[{"role": "user", "content": "WebSocket connection"}], - api_key="", - additional_args={ - "complete_input_dict": {}, - "api_base": target, - "headers": upstream_headers, - }, - ) - - ### CALL HOOKS ### - modify incoming data / reject request before calling the model - websocket_data: dict[str, Any] = {} - websocket_data = await proxy_logging_obj.pre_call_hook( - user_api_key_dict=user_api_key_dict, - data=websocket_data, - call_type="pass_through_endpoint", - ) - - try: - verbose_proxy_logger.debug( - f"WebSocket passthrough ({endpoint}): Establishing upstream connection to {target}" - ) - async with connect( - target, - additional_headers=upstream_headers, - ) as upstream_ws: - verbose_proxy_logger.info( - f"WebSocket passthrough ({endpoint}): Upstream connection established successfully" - ) - - async def forward_client_to_upstream() -> None: - """Forward messages from client to upstream WebSocket""" - try: - while True: - message = await websocket.receive() - message_type = message.get("type") - if message_type == "websocket.disconnect": - await upstream_ws.close() - break - - text_data = message.get("text") - bytes_data = message.get("bytes") - - if text_data is not None: - # Try to extract model from client setup message for Vertex AI Live - if endpoint and "/vertex_ai/live" in endpoint: - verbose_proxy_logger.debug( - f"WebSocket passthrough ({endpoint}): Processing client message for model extraction" - ) - try: - client_message = json.loads(text_data) - if ( - isinstance(client_message, dict) - and "setup" in client_message - ): - setup_data = client_message["setup"] - verbose_proxy_logger.debug( - f"WebSocket passthrough ({endpoint}): Found setup data in client message: {setup_data}" - ) - if ( - isinstance(setup_data, dict) - and "model" in setup_data - ): - extracted_model = ( - _extract_model_from_vertex_ai_setup( - setup_data - ) - ) - if extracted_model: - kwargs["model"] = extracted_model - kwargs[ - "custom_llm_provider" - ] = "vertex_ai-language-models" - # Update logging object with correct model - logging_obj.model = extracted_model - logging_obj.model_call_details[ - "model" - ] = extracted_model - logging_obj.model_call_details[ - "custom_llm_provider" - ] = "vertex_ai" - verbose_proxy_logger.info( - f"WebSocket passthrough ({endpoint}): Successfully extracted model '{extracted_model}' and set provider to 'vertex_ai' from client setup message" - ) - else: - verbose_proxy_logger.warning( - f"WebSocket passthrough ({endpoint}): Failed to extract model from client setup data: {setup_data}" - ) - else: - verbose_proxy_logger.debug( - f"WebSocket passthrough ({endpoint}): Setup data does not contain model field: {setup_data}" - ) - else: - verbose_proxy_logger.debug( - f"WebSocket passthrough ({endpoint}): Client message does not contain setup data" - ) - except (json.JSONDecodeError, KeyError, TypeError) as e: - verbose_proxy_logger.debug( - f"WebSocket passthrough ({endpoint}): Client message is not a valid setup message: {e}" - ) - pass # Not a JSON message or doesn't contain setup data - - await upstream_ws.send(text_data) - elif bytes_data is not None: - await upstream_ws.send(bytes_data) - except asyncio.CancelledError: - raise - except Exception: - verbose_proxy_logger.exception( - f"WebSocket passthrough ({endpoint}): error forwarding client message" - ) - await upstream_ws.close() - - async def forward_upstream_to_client() -> None: - """Forward messages from upstream to client WebSocket""" - try: - # Wait for the first response from upstream - raw_response = await upstream_ws.recv(decode=False) - # Ensure raw_response is bytes before decoding - if isinstance(raw_response, str): - raw_response = raw_response.encode("ascii") - setup_response = json.loads(raw_response.decode("ascii")) - verbose_proxy_logger.debug(f"Setup response: {setup_response}") - - # Extract model and provider from setup response for Vertex AI Live - if endpoint and "/vertex_ai/live" in endpoint: - verbose_proxy_logger.debug( - f"WebSocket passthrough ({endpoint}): Processing server setup response for model extraction" - ) - extracted_model = _extract_model_from_vertex_ai_setup( - setup_response - ) - if extracted_model: - kwargs["model"] = extracted_model - kwargs["custom_llm_provider"] = "vertex_ai_language_models" - # Update logging object with correct model - logging_obj.model = extracted_model - logging_obj.model_call_details["model"] = extracted_model - logging_obj.model_call_details[ - "custom_llm_provider" - ] = "vertex_ai_language_models" - verbose_proxy_logger.debug( - f"WebSocket passthrough ({endpoint}): Successfully extracted model '{extracted_model}' and set provider to 'vertex_ai' from server setup response" - ) - else: - verbose_proxy_logger.warning( - f"WebSocket passthrough ({endpoint}): Failed to extract model from server setup response: {setup_response}" - ) - else: - verbose_proxy_logger.debug( - f"WebSocket passthrough ({endpoint}): Not a Vertex AI Live endpoint, skipping model extraction" - ) - - # Send the setup response to the client - await websocket.send_text(json.dumps(setup_response)) - - # Now continuously forward messages from upstream to client - async for upstream_message in upstream_ws: - if isinstance(upstream_message, bytes): - await websocket.send_bytes(upstream_message) - # Parse and collect for cost tracking - try: - message_data = json.loads(upstream_message.decode()) - websocket_messages.append(message_data) - except (json.JSONDecodeError, UnicodeDecodeError): - pass - else: - await websocket.send_text(upstream_message) - # Parse and collect for cost tracking - try: - message_data = json.loads(upstream_message) - websocket_messages.append(message_data) - except json.JSONDecodeError: - pass - - except (ConnectionClosedOK, ConnectionClosedError) as e: - verbose_proxy_logger.debug( - f"Upstream WebSocket connection closed: {e}" - ) - pass - except asyncio.CancelledError: - verbose_proxy_logger.debug( - "asyncio.CancelledError in forward_upstream_to_client" - ) - raise - except Exception as e: - verbose_proxy_logger.debug( - f"Exception in forward_upstream_to_client: {e}" - ) - verbose_proxy_logger.exception( - f"WebSocket passthrough ({endpoint}): error forwarding upstream message" - ) - raise - - # Create tasks for bidirectional message forwarding - tasks = [ - asyncio.create_task(forward_client_to_upstream()), - asyncio.create_task(forward_upstream_to_client()), - ] - - done, pending = await asyncio.wait( - tasks, return_when=asyncio.FIRST_COMPLETED - ) - - # Cancel remaining tasks - for task in pending: - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - - # Check for exceptions in completed tasks - for task in done: - exception = task.exception() - if exception is not None: - raise exception - - end_time = datetime.now() - - # Update passthrough logging payload with response data - passthrough_logging_payload["response_body"] = websocket_messages # type: ignore - passthrough_logging_payload["end_time"] = end_time # type: ignore - - # Remove logging_obj from kwargs to avoid duplicate keyword argument - success_kwargs = kwargs.copy() - success_kwargs.pop("logging_obj", None) - - # # Add user authentication context for database logging - # if user_api_key_dict: - # success_kwargs.setdefault('litellm_params', {}) - # success_kwargs['litellm_params'].update({ - # 'proxy_server_request': { - # 'body': { - # 'user': user_api_key_dict.user_id, - # 'team_id': user_api_key_dict.team_id, - # 'end_user_id': user_api_key_dict.end_user_id, - # } - # } - # }) - # # Also add the user_api_key for direct access - # success_kwargs['user_api_key'] = user_api_key_dict.api_key - - # Create a dummy httpx.Response for WebSocket connections - class MockWebSocketResponse: - def __init__(self, target_url: str): - self.status_code = 200 - self.text = "WebSocket connection successful" - self.headers: dict[str, str] = {} - self.request = MockWebSocketRequest(target_url) - - class MockWebSocketRequest: - def __init__(self, target_url: str): - self.method = "WEBSOCKET" - self.url = target_url - - mock_response = MockWebSocketResponse(target) - - # Use the same success handler as HTTP passthrough endpoints - asyncio.create_task( - pass_through_endpoint_logging.pass_through_async_success_handler( - httpx_response=mock_response, # type: ignore - response_body=websocket_messages, # type: ignore - url_route=endpoint or "", - result="websocket_connection_successful", - start_time=start_time, - end_time=end_time, - logging_obj=logging_obj, - cache_hit=False, - request_body={}, - **success_kwargs, - ) - ) - - # Call the proxy logging success hook - if proxy_logging_obj: - await proxy_logging_obj.post_call_success_hook( - data={}, - user_api_key_dict=user_api_key_dict, - response={"status": "websocket_connection_successful"}, # type: ignore - ) - - except InvalidStatus as exc: - verbose_proxy_logger.exception( - f"WebSocket passthrough ({endpoint}): upstream rejected WebSocket connection" - ) - - # Prepare request payload for logging - request_payload = {} - if kwargs: - for key, value in kwargs.items(): - request_payload[key] = value - if logging_obj is not None: - request_payload["litellm_logging_obj"] = logging_obj - - # Log the connection failure using the same pattern as HTTP - await proxy_logging_obj.post_call_failure_hook( - user_api_key_dict=user_api_key_dict, - original_exception=exc, - request_data=request_payload, - traceback_str=traceback.format_exc( - limit=MAXIMUM_TRACEBACK_LINES_TO_LOG, - ), - ) - - if websocket.client_state != WebSocketState.DISCONNECTED: - await websocket.close( - code=getattr(exc, "status_code", 1011), - reason="Upstream connection rejected", - ) - except Exception as e: - verbose_proxy_logger.exception( - f"WebSocket passthrough ({endpoint}): unexpected error while proxying WebSocket" - ) - - # Prepare request payload for logging - request_payload = {} - if kwargs: - for key, value in kwargs.items(): - request_payload[key] = value - if logging_obj is not None: - request_payload["litellm_logging_obj"] = logging_obj - - # Log the unexpected error using the same pattern as HTTP - await proxy_logging_obj.post_call_failure_hook( - user_api_key_dict=user_api_key_dict, - original_exception=e, - request_data=request_payload, - traceback_str=traceback.format_exc( - limit=MAXIMUM_TRACEBACK_LINES_TO_LOG, - ), - ) - - if websocket.client_state != WebSocketState.DISCONNECTED: - await websocket.close(code=1011, reason="WebSocket passthrough error") - finally: - if websocket.client_state != WebSocketState.DISCONNECTED: - await websocket.close() - - -def _is_streaming_response(response: httpx.Response) -> bool: - _content_type = response.headers.get("content-type") - if _content_type is not None and "text/event-stream" in _content_type: - return True - return False - - -def _extract_model_from_vertex_ai_setup(setup_response: dict) -> Optional[str]: - """ - Extract the model name from Vertex AI Live setup response. - - The setup response can contain a model field in two formats: - 1. Direct: {"model": "projects/.../models/gemini-2.0-flash-live-preview-04-09"} - 2. Nested: {"setup": {"model": "projects/.../models/gemini-2.0-flash-live-preview-04-09"}} - - We extract just the model name: "gemini-2.0-flash-live-preview-04-09" - """ - try: - # Handle both direct model field and nested setup.model field - model_path = None - if isinstance(setup_response, dict): - if "model" in setup_response: - model_path = setup_response["model"] - elif ( - "setup" in setup_response - and isinstance(setup_response["setup"], dict) - and "model" in setup_response["setup"] - ): - model_path = setup_response["setup"]["model"] - - if isinstance(model_path, str) and "/models/" in model_path: - # Extract the model name after the last "/models/" - model_name = model_path.split("/models/")[-1] - return model_name - except Exception as e: - verbose_proxy_logger.debug(f"Error extracting model from setup response: {e}") - return None - - -class SafeRouteAdder: - """ - Wrapper class for adding routes to FastAPI app. - Only adds routes if they don't already exist on the app. - """ - - @staticmethod - def _is_path_registered(app: FastAPI, path: str, methods: List[str]) -> bool: - """ - Check if a path with any of the specified methods is already registered on the app. - - Args: - app: The FastAPI application instance - path: The path to check (e.g., "/v1/chat/completions") - methods: List of HTTP methods to check (e.g., ["GET", "POST"]) - - Returns: - True if the path is already registered with any of the methods, False otherwise - """ - for route in app.routes: - # Use getattr to safely access route attributes - route_path = getattr(route, "path", None) - route_methods = getattr(route, "methods", None) - - if route_path == path and route_methods is not None: - # Check if any of the methods overlap - if any(method in route_methods for method in methods): - return True - return False - - @staticmethod - def add_api_route_if_not_exists( - app: FastAPI, - path: str, - endpoint: Any, - methods: List[str], - dependencies: Optional[List] = None, - ) -> bool: - """ - Add an API route to the app only if it doesn't already exist. - - Args: - app: The FastAPI application instance - path: The path for the route - endpoint: The endpoint function/callable - methods: List of HTTP methods - dependencies: Optional list of dependencies - - Returns: - True if route was added, False if it already existed - """ - if SafeRouteAdder._is_path_registered(app=app, path=path, methods=methods): - verbose_proxy_logger.debug( - "Skipping route registration - path %s with methods %s already registered on app", - path, - methods, - ) - return False - - app.add_api_route( - path=path, - endpoint=endpoint, - methods=methods, - dependencies=dependencies, - ) - verbose_proxy_logger.debug( - "Successfully added route: %s with methods %s", - path, - methods, - ) - return True - - -class InitPassThroughEndpointHelpers: - @staticmethod - def add_exact_path_route( - app: FastAPI, - path: str, - target: str, - custom_headers: Optional[dict], - forward_headers: Optional[bool], - merge_query_params: Optional[bool], - dependencies: Optional[List], - cost_per_request: Optional[float], - endpoint_id: str, - guardrails: Optional[dict] = None, - methods: Optional[List[str]] = None, - default_query_params: Optional[dict] = None, - ): - """Add exact path route for pass-through endpoint""" - # Default to all methods if none specified (backward compatibility) - if methods is None or len(methods) == 0: - methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] - - # Create route key that includes methods for uniqueness - methods_str = ",".join(sorted(methods)) - route_key = f"{endpoint_id}:exact:{path}:{methods_str}" - - # Check if this exact route is already registered - if route_key in _registered_pass_through_routes: - verbose_proxy_logger.debug( - "Updating duplicate exact pass through endpoint: %s with methods %s (already registered)", - path, - methods, - ) - - verbose_proxy_logger.debug( - "adding exact pass through endpoint: %s, methods: %s, dependencies: %s", - path, - methods, - dependencies, - ) - - # Use SafeRouteAdder to only add route if it doesn't exist on the app - SafeRouteAdder.add_api_route_if_not_exists( - app=app, - path=path, - endpoint=create_pass_through_route( # type: ignore - path, - target, - custom_headers, - forward_headers, - merge_query_params, - dependencies, - cost_per_request=cost_per_request, - default_query_params=default_query_params, - guardrails=guardrails, - ), - methods=methods, - dependencies=dependencies, - ) - - # Always register/update the route metadata (headers, target) even if FastAPI route exists - _registered_pass_through_routes[route_key] = { - "endpoint_id": endpoint_id, - "path": path, - "type": "exact", - "methods": methods, - "passthrough_params": { - "target": target, - "custom_headers": custom_headers, - "forward_headers": forward_headers, - "merge_query_params": merge_query_params, - "default_query_params": default_query_params, - "dependencies": dependencies, - "cost_per_request": cost_per_request, - "guardrails": guardrails, - }, - } - - @staticmethod - def add_subpath_route( - app: FastAPI, - path: str, - target: str, - custom_headers: Optional[dict], - forward_headers: Optional[bool], - merge_query_params: Optional[bool], - dependencies: Optional[List], - cost_per_request: Optional[float], - endpoint_id: str, - guardrails: Optional[dict] = None, - methods: Optional[List[str]] = None, - default_query_params: Optional[dict] = None, - ): - """Add wildcard route for sub-paths""" - # Default to all methods if none specified (backward compatibility) - if methods is None or len(methods) == 0: - methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] - - wildcard_path = f"{path}/{{subpath:path}}" - methods_str = ",".join(sorted(methods)) - route_key = f"{endpoint_id}:subpath:{path}:{methods_str}" - - # Check if this subpath route is already registered - if route_key in _registered_pass_through_routes: - verbose_proxy_logger.debug( - "Updating duplicate wildcard pass through endpoint: %s with methods %s (already registered)", - wildcard_path, - methods, - ) - - verbose_proxy_logger.debug( - "adding wildcard pass through endpoint: %s, methods: %s, dependencies: %s", - wildcard_path, - methods, - dependencies, - ) - - # Use SafeRouteAdder to only add route if it doesn't exist on the app - SafeRouteAdder.add_api_route_if_not_exists( - app=app, - path=wildcard_path, - endpoint=create_pass_through_route( # type: ignore - path, - target, - custom_headers, - forward_headers, - merge_query_params, - dependencies, - include_subpath=True, - cost_per_request=cost_per_request, - default_query_params=default_query_params, - guardrails=guardrails, - ), - methods=methods, - dependencies=dependencies, - ) - - # Register the route to prevent duplicates only if it was added - _registered_pass_through_routes[route_key] = { - "endpoint_id": endpoint_id, - "path": path, - "type": "subpath", - "methods": methods, - "passthrough_params": { - "target": target, - "custom_headers": custom_headers, - "forward_headers": forward_headers, - "merge_query_params": merge_query_params, - "default_query_params": default_query_params, - "dependencies": dependencies, - "cost_per_request": cost_per_request, - "guardrails": guardrails, - }, - } - - @staticmethod - def remove_endpoint_routes(endpoint_id: str): - """Remove all routes for a specific endpoint ID from the registry - and clean up corresponding entries from LiteLLMRoutes.openai_routes.""" - keys_to_remove = [ - key - for key, value in _registered_pass_through_routes.items() - if value["endpoint_id"] == endpoint_id - ] - for key in keys_to_remove: - route_info = _registered_pass_through_routes[key] - path = route_info.get("path") - if isinstance(path, str): - openai_routes = LiteLLMRoutes.openai_routes.value - if path in openai_routes: - openai_routes.remove(path) - if route_info.get("type") == "subpath": - wildcard_path = path.rstrip("/") + "/*" - if wildcard_path in openai_routes: - openai_routes.remove(wildcard_path) - del _registered_pass_through_routes[key] - verbose_proxy_logger.debug( - "Removed pass-through route from registry: %s", key - ) - - @staticmethod - def clear_all_pass_through_routes(): - """Clear all pass-through routes from the registry""" - _registered_pass_through_routes.clear() - - @staticmethod - def get_all_registered_pass_through_routes() -> List[str]: - """Get all registered pass-through endpoints from the registry""" - return list(_registered_pass_through_routes.keys()) - - @staticmethod - def _build_full_path_with_root(path: str) -> str: - """ - Build full path by prepending server root path if needed. - - Args: - path: The relative path to build - - Returns: - Full path with server root prepended (if root is not "/") - """ - root_path = get_server_root_path() - if root_path == "/": - return path - return f"{root_path}{path}" - - @staticmethod - def is_registered_pass_through_route(route: str) -> bool: - """ - Check if route is a registered pass-through endpoint from DB - - Uses the in-memory registry to avoid additional DB queries - Optimized for minimal latency - - Args: - route: The route to check - - Returns: - bool: True if route is a registered pass-through endpoint, False otherwise - """ - ## CHECK IF MAPPED PASS THROUGH ENDPOINT - normalized_route = normalize_route_for_root_path(route) - if normalized_route is not None: - for mapped_route in LiteLLMRoutes.mapped_pass_through_routes.value: - if normalized_route.startswith(mapped_route): - return True - - # Fast path: check if any registered route key contains this path - # Keys are in format: "{endpoint_id}:exact:{path}:{methods}" or "{endpoint_id}:subpath:{path}:{methods}" - # For backward compatibility, also support old format: "{endpoint_id}:exact:{path}" or "{endpoint_id}:subpath:{path}" - # Extract unique paths from keys for quick checking - for key in _registered_pass_through_routes.keys(): - parts = key.split(":", 3) # Split into [endpoint_id, type, path, methods?] - if len(parts) >= 3: - route_type = parts[1] - registered_path = ( - InitPassThroughEndpointHelpers._build_full_path_with_root(parts[2]) - ) - if route_type == "exact" and route == registered_path: - return True - elif route_type == "subpath": - if route == registered_path or route.startswith( - registered_path + "/" - ): - return True - - return False - - @staticmethod - def get_registered_pass_through_route( - route: str, method: Optional[str] = None - ) -> Optional[Dict[str, Any]]: - """Get passthrough params for a given route and optionally filter by HTTP method""" - for key in _registered_pass_through_routes.keys(): - parts = key.split(":", 3) # Split into [endpoint_id, type, path, methods?] - if len(parts) >= 3: - route_type = parts[1] - registered_path = ( - InitPassThroughEndpointHelpers._build_full_path_with_root(parts[2]) - ) - - # Get the methods for this route - route_methods = _registered_pass_through_routes[key].get("methods", []) - - # Check if path matches - path_matches = False - if route_type == "exact" and route == registered_path: - path_matches = True - elif route_type == "subpath": - if route == registered_path or route.startswith( - registered_path + "/" - ): - path_matches = True - - # If path matches and method filter is provided, check if method is allowed - if path_matches: - if method is None or not route_methods or method in route_methods: - return _registered_pass_through_routes[key] - - return None - - -def _get_combined_pass_through_endpoints( - pass_through_endpoints: Union[List[Dict], List[PassThroughGenericEndpoint]], - config_pass_through_endpoints: List[Dict], -): - """Get combined pass-through endpoints from db + config""" - return pass_through_endpoints + config_pass_through_endpoints - - -async def _register_pass_through_endpoint( - endpoint: Union[Dict[str, Any], PassThroughGenericEndpoint], - app: FastAPI, - premium_user: bool, - visited_endpoints: set[str], -) -> None: - endpoint_data: Dict[str, Any] - if isinstance(endpoint, PassThroughGenericEndpoint): - endpoint_data = endpoint.model_dump() - else: - endpoint_data = endpoint - - if endpoint_data.get("id") is None: - endpoint_data["id"] = str(uuid.uuid4()) - endpoint_id = cast(str, endpoint_data["id"]) - - target = endpoint_data.get("target") - path = endpoint_data.get("path") - if path is None: - raise ValueError("Path is required for pass-through endpoint") - - custom_headers = await set_env_variables_in_header( - custom_headers=endpoint_data.get("headers") - ) - forward_headers = endpoint_data.get("forward_headers") - merge_query_params = endpoint_data.get("merge_query_params") - default_query_params = endpoint_data.get("default_query_params") - auth = endpoint_data.get("auth") - dependencies = None - - if auth is not None and str(auth).lower() == "true": - if premium_user is not True: - raise ValueError( - "Error Setting Authentication on Pass Through Endpoint: {}".format( - CommonProxyErrors.not_premium_user.value - ) - ) - dependencies = [Depends(user_api_key_auth)] - if path not in LiteLLMRoutes.openai_routes.value: - LiteLLMRoutes.openai_routes.value.append(path) - - if target is None: - return - - guardrails = endpoint_data.get("guardrails") - methods = endpoint_data.get("methods") - cost_per_request = endpoint_data.get("cost_per_request") - - verbose_proxy_logger.debug( - "Initializing pass through endpoint: %s (ID: %s)", path, endpoint_id - ) - InitPassThroughEndpointHelpers.add_exact_path_route( - app=app, - path=path, - target=target, - custom_headers=custom_headers, - forward_headers=forward_headers, - merge_query_params=merge_query_params, - dependencies=dependencies, - cost_per_request=cost_per_request, - endpoint_id=endpoint_id, - guardrails=guardrails, - methods=methods, - default_query_params=default_query_params, - ) - - methods_for_key = methods if methods else ["GET", "POST", "PUT", "DELETE", "PATCH"] - methods_str = ",".join(sorted(methods_for_key)) - visited_endpoints.add(f"{endpoint_id}:exact:{path}:{methods_str}") - - if endpoint_data.get("include_subpath", False) is True: - if auth is not None and str(auth).lower() == "true": - wildcard_path = path.rstrip("/") + "/*" - if wildcard_path not in LiteLLMRoutes.openai_routes.value: - LiteLLMRoutes.openai_routes.value.append(wildcard_path) - InitPassThroughEndpointHelpers.add_subpath_route( - app=app, - path=path, - target=target, - custom_headers=custom_headers, - forward_headers=forward_headers, - merge_query_params=merge_query_params, - dependencies=dependencies, - cost_per_request=cost_per_request, - endpoint_id=endpoint_id, - guardrails=guardrails, - methods=methods, - default_query_params=default_query_params, - ) - visited_endpoints.add(f"{endpoint_id}:subpath:{path}:{methods_str}") - - verbose_proxy_logger.debug( - "Added new pass through endpoint: %s (ID: %s)", path, endpoint_id - ) - - -async def initialize_pass_through_endpoints( - pass_through_endpoints: Union[List[Dict], List[PassThroughGenericEndpoint]], -): - """ - 1. Create a global list of pass-through endpoints (db + config) - 2. Clear all existing pass-through endpoints from the FastAPI app routes - 3. Add new endpoints to the in-memory registry - - Initialize a list of pass-through endpoints by adding them to the FastAPI app routes - - Args: - pass_through_endpoints: List of pass-through endpoints to initialize - - Returns: - None - """ - verbose_proxy_logger.debug("initializing pass through endpoints") - from litellm.proxy.proxy_server import ( - app, - config_passthrough_endpoints, - premium_user, - ) - - ## get combined pass-through endpoints from db + config - combined_pass_through_endpoints: List[Union[Dict, PassThroughGenericEndpoint]] - - if config_passthrough_endpoints is not None: - combined_pass_through_endpoints = _get_combined_pass_through_endpoints( # type: ignore - pass_through_endpoints, config_passthrough_endpoints - ) - else: - combined_pass_through_endpoints = pass_through_endpoints # type: ignore - - ## clear all existing pass-through endpoints from the FastAPI app routes - # InitPassThroughEndpointHelpers.clear_all_pass_through_routes() - - # get a list of all registered pass-through endpoints - # mark the ones that are visited in the list - # remove the ones that are not visited from the list - registered_pass_through_endpoints = ( - InitPassThroughEndpointHelpers.get_all_registered_pass_through_routes() - ) - - visited_endpoints: set[str] = set() - - for endpoint in combined_pass_through_endpoints: - await _register_pass_through_endpoint( - endpoint=endpoint, - app=app, - premium_user=premium_user, - visited_endpoints=visited_endpoints, - ) - - # remove the ones that are not visited from the list - for endpoint_key in registered_pass_through_endpoints: - if endpoint_key not in visited_endpoints: - InitPassThroughEndpointHelpers.remove_endpoint_routes(endpoint_key) - - -def _get_pass_through_endpoints_from_config() -> List[PassThroughGenericEndpoint]: - """ - Get pass-through endpoints defined in the config file. - These are read-only and cannot be edited via the UI. - Malformed endpoints are logged and skipped; they do not crash the function. - """ - from pydantic import ValidationError - - from litellm.proxy.proxy_server import config_passthrough_endpoints - - if config_passthrough_endpoints is None or len(config_passthrough_endpoints) == 0: - return [] - - returned_endpoints: List[PassThroughGenericEndpoint] = [] - for endpoint in config_passthrough_endpoints: - try: - if isinstance(endpoint, dict): - endpoint_dict = dict(endpoint) - endpoint_dict["is_from_config"] = True - returned_endpoints.append(PassThroughGenericEndpoint(**endpoint_dict)) - elif isinstance(endpoint, PassThroughGenericEndpoint): - # Create a copy with is_from_config=True - endpoint_dict = endpoint.model_dump() - endpoint_dict["is_from_config"] = True - returned_endpoints.append(PassThroughGenericEndpoint(**endpoint_dict)) - except ValidationError as e: - verbose_proxy_logger.warning( - "Skipping malformed pass-through endpoint from config: %s", - e, - exc_info=False, - ) - - return returned_endpoints - - -async def _get_pass_through_endpoints_from_db( - endpoint_id: Optional[str] = None, - user_api_key_dict: Optional[UserAPIKeyAuth] = None, -) -> List[PassThroughGenericEndpoint]: - from litellm.proxy._types import LitellmUserRoles - from litellm.proxy.proxy_server import get_config_general_settings - - try: - if user_api_key_dict is None: - user_api_key_dict = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN) - response: ConfigFieldInfo = await get_config_general_settings( - field_name="pass_through_endpoints", user_api_key_dict=user_api_key_dict - ) - except Exception: - return [] - - pass_through_endpoint_data: Optional[List] = response.field_value - if pass_through_endpoint_data is None: - return [] - - returned_endpoints: List[PassThroughGenericEndpoint] = [] - if endpoint_id is None: - # Return all endpoints from DB, mark as not from config - for endpoint in pass_through_endpoint_data: - if isinstance(endpoint, dict): - endpoint_dict = dict(endpoint) - endpoint_dict["is_from_config"] = False - returned_endpoints.append(PassThroughGenericEndpoint(**endpoint_dict)) - elif isinstance(endpoint, PassThroughGenericEndpoint): - endpoint_dict = endpoint.model_dump() - endpoint_dict["is_from_config"] = False - returned_endpoints.append(PassThroughGenericEndpoint(**endpoint_dict)) - else: - # Find specific endpoint by ID - found_endpoint = _find_endpoint_by_id(pass_through_endpoint_data, endpoint_id) - if found_endpoint is not None: - endpoint_dict = ( - found_endpoint.model_dump() - if isinstance(found_endpoint, PassThroughGenericEndpoint) - else dict(found_endpoint) - ) - endpoint_dict["is_from_config"] = False - returned_endpoints.append(PassThroughGenericEndpoint(**endpoint_dict)) - - return returned_endpoints - - -async def _filter_endpoints_by_team_allowed_routes( - team_id: str, - pass_through_endpoints: List[PassThroughGenericEndpoint], - prisma_client, -) -> List[PassThroughGenericEndpoint]: - """ - Filter pass-through endpoints based on team's allowed_passthrough_routes metadata. - - Args: - team_id: The team ID to check permissions for - pass_through_endpoints: List of endpoints to filter - prisma_client: Database client - - Returns: - Filtered list of endpoints based on team permissions - - Raises: - HTTPException: If team is not found - """ - # retrieve team from db - team = await prisma_client.db.litellm_teamtable.find_unique( - where={"team_id": team_id}, - ) - if team is None: - raise HTTPException( - status_code=404, - detail={"error": "Team not found"}, - ) - - # retrieve team metadata - team_metadata = team.metadata - if ( - team_metadata is not None - and team_metadata.get("allowed_passthrough_routes") is not None - ): - ## FILTER pass_through_endpoints by allowed_passthrough_routes - pass_through_endpoints = [ - endpoint - for endpoint in pass_through_endpoints - if endpoint.path in team_metadata.get("allowed_passthrough_routes") - ] - - return pass_through_endpoints - - -@router.get( - "/config/pass_through_endpoint", - dependencies=[Depends(user_api_key_auth)], - response_model=PassThroughEndpointResponse, -) -@router.get( - "/config/pass_through_endpoint/team/{team_id}", - dependencies=[Depends(user_api_key_auth)], - response_model=PassThroughEndpointResponse, -) -async def get_pass_through_endpoints( - endpoint_id: Optional[str] = None, - user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), - team_id: Optional[str] = None, -): - """ - GET configured pass through endpoint. - - If no endpoint_id given, return all configured endpoints. - """ ## Get existing pass-through endpoint field value - from litellm.proxy._types import CommonProxyErrors - from litellm.proxy.proxy_server import prisma_client - - if prisma_client is None: - raise HTTPException( - status_code=500, - detail={"error": CommonProxyErrors.db_not_connected_error.value}, - ) - - # Get endpoints from DB (editable via UI) - db_endpoints = await _get_pass_through_endpoints_from_db( - endpoint_id=endpoint_id, user_api_key_dict=user_api_key_dict - ) - - # Get endpoints from config file (read-only, not editable via UI) - config_endpoints = _get_pass_through_endpoints_from_config() - - # Merge: config endpoints not in DB + all DB endpoints (DB overrides config for same path) - db_paths = {ep.path for ep in db_endpoints} - config_only_endpoints = [ep for ep in config_endpoints if ep.path not in db_paths] - if endpoint_id is not None: - # When filtering by endpoint_id, only return if found in DB (config endpoints use generated IDs) - pass_through_endpoints = db_endpoints - else: - pass_through_endpoints = config_only_endpoints + db_endpoints - - if team_id is not None: - pass_through_endpoints = await _filter_endpoints_by_team_allowed_routes( - team_id=team_id, - pass_through_endpoints=pass_through_endpoints, - prisma_client=prisma_client, - ) - - return PassThroughEndpointResponse(endpoints=pass_through_endpoints) - - -@router.post( - "/config/pass_through_endpoint/{endpoint_id}", - dependencies=[Depends(user_api_key_auth)], -) -async def update_pass_through_endpoints( - endpoint_id: str, - data: PassThroughGenericEndpoint, - request: Request, - user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), -): - """ - Update a pass-through endpoint by ID. - """ - from litellm.proxy.proxy_server import ( - get_config_general_settings, - update_config_general_settings, - ) - - ## Get existing pass-through endpoint field value - try: - response: ConfigFieldInfo = await get_config_general_settings( - field_name="pass_through_endpoints", user_api_key_dict=user_api_key_dict - ) - except Exception: - raise HTTPException( - status_code=404, - detail={"error": "No pass-through endpoints found"}, - ) - - pass_through_endpoint_data: Optional[List] = response.field_value - if pass_through_endpoint_data is None: - raise HTTPException( - status_code=404, - detail={"error": "No pass-through endpoints found"}, - ) - - # Find the endpoint to update - found_endpoint = _find_endpoint_by_id(pass_through_endpoint_data, endpoint_id) - - if found_endpoint is None: - raise HTTPException( - status_code=404, - detail={"error": f"Endpoint with ID '{endpoint_id}' not found"}, - ) - - # Find the index for updating the list - endpoint_index = None - for idx, endpoint in enumerate(pass_through_endpoint_data): - _endpoint = ( - PassThroughGenericEndpoint(**endpoint) - if isinstance(endpoint, dict) - else endpoint - ) - if _endpoint.id == endpoint_id: - endpoint_index = idx - break - - if endpoint_index is None: - raise HTTPException( - status_code=404, - detail={ - "error": f"Could not find index for endpoint with ID '{endpoint_id}'" - }, - ) - - # Get the update data as dict, excluding None values for partial updates - # Exclude is_from_config as it's a response-only field (computed at read time) - update_data = data.model_dump(exclude_none=True, exclude={"is_from_config"}) - - # Start with existing endpoint data - endpoint_dict = found_endpoint.model_dump() - - # Update with new data (only non-None values) - endpoint_dict.update(update_data) - - # Preserve existing ID if not provided in update and endpoint has ID - if "id" not in update_data and found_endpoint.id is not None: - endpoint_dict["id"] = found_endpoint.id - - # Remove is_from_config before saving - it's a response-only field (computed at read time) - endpoint_dict.pop("is_from_config", None) - - # Create updated endpoint object - updated_endpoint = PassThroughGenericEndpoint(**endpoint_dict) - - # Update the list - pass_through_endpoint_data[endpoint_index] = endpoint_dict - - # Remove old routes from registry before they get re-registered - InitPassThroughEndpointHelpers.remove_endpoint_routes(endpoint_id) - - ## Update db - updated_data = ConfigFieldUpdate( - field_name="pass_through_endpoints", - field_value=pass_through_endpoint_data, - config_type="general_settings", - ) - - await update_config_general_settings( - data=updated_data, user_api_key_dict=user_api_key_dict - ) - - # Re-register the route with updated headers - _custom_headers: Optional[dict] = updated_endpoint.headers or {} - _custom_headers = await set_env_variables_in_header(custom_headers=_custom_headers) - - if updated_endpoint.include_subpath: - InitPassThroughEndpointHelpers.add_subpath_route( - app=request.app, - path=updated_endpoint.path, - target=updated_endpoint.target, - custom_headers=_custom_headers, - forward_headers=None, # Defaults not available in model? assuming None logic handles it - merge_query_params=None, - dependencies=None, - cost_per_request=updated_endpoint.cost_per_request, - endpoint_id=updated_endpoint.id or endpoint_id or "", - guardrails=getattr(updated_endpoint, "guardrails", None), - methods=updated_endpoint.methods, - default_query_params=updated_endpoint.default_query_params, - ) - else: - InitPassThroughEndpointHelpers.add_exact_path_route( - app=request.app, - path=updated_endpoint.path, - target=updated_endpoint.target, - custom_headers=_custom_headers, - forward_headers=None, - merge_query_params=None, - dependencies=None, - cost_per_request=updated_endpoint.cost_per_request, - endpoint_id=updated_endpoint.id or endpoint_id or "", - guardrails=getattr(updated_endpoint, "guardrails", None), - methods=updated_endpoint.methods, - default_query_params=updated_endpoint.default_query_params, - ) - - return PassThroughEndpointResponse( - endpoints=[updated_endpoint] if updated_endpoint else [] - ) - - -@router.post( - "/config/pass_through_endpoint", - dependencies=[Depends(user_api_key_auth)], -) -async def create_pass_through_endpoints( - data: PassThroughGenericEndpoint, - request: Request, - user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), -): - """ - Create new pass-through endpoint - """ - from litellm._uuid import uuid - from litellm.proxy.proxy_server import ( - get_config_general_settings, - update_config_general_settings, - ) - - ## Get existing pass-through endpoint field value - - try: - response: ConfigFieldInfo = await get_config_general_settings( - field_name="pass_through_endpoints", user_api_key_dict=user_api_key_dict - ) - except Exception: - response = ConfigFieldInfo( - field_name="pass_through_endpoints", field_value=None - ) - - ## Auto-generate ID if not provided - # Exclude is_from_config as it's a response-only field (computed at read time) - data_dict = data.model_dump(exclude={"is_from_config"}) - if data_dict.get("id") is None: - data_dict["id"] = str(uuid.uuid4()) - - if response.field_value is None: - response.field_value = [data_dict] - elif isinstance(response.field_value, List): - response.field_value.append(data_dict) - - ## Update db - updated_data = ConfigFieldUpdate( - field_name="pass_through_endpoints", - field_value=response.field_value, - config_type="general_settings", - ) - await update_config_general_settings( - data=updated_data, user_api_key_dict=user_api_key_dict - ) - - # Return the created endpoint with the generated ID - created_endpoint = PassThroughGenericEndpoint(**data_dict) - - # Register the new route - _custom_headers: Optional[dict] = created_endpoint.headers or {} - _custom_headers = await set_env_variables_in_header(custom_headers=_custom_headers) - - if created_endpoint.include_subpath: - InitPassThroughEndpointHelpers.add_subpath_route( - app=request.app, - path=created_endpoint.path, - target=created_endpoint.target, - custom_headers=_custom_headers, - forward_headers=None, - merge_query_params=None, - dependencies=None, - cost_per_request=created_endpoint.cost_per_request, - endpoint_id=created_endpoint.id or "", - guardrails=getattr(created_endpoint, "guardrails", None), - methods=created_endpoint.methods, - default_query_params=created_endpoint.default_query_params, - ) - else: - InitPassThroughEndpointHelpers.add_exact_path_route( - app=request.app, - path=created_endpoint.path, - target=created_endpoint.target, - custom_headers=_custom_headers, - forward_headers=None, - merge_query_params=None, - dependencies=None, - cost_per_request=created_endpoint.cost_per_request, - endpoint_id=created_endpoint.id or "", - guardrails=getattr(created_endpoint, "guardrails", None), - methods=created_endpoint.methods, - default_query_params=created_endpoint.default_query_params, - ) - - return PassThroughEndpointResponse(endpoints=[created_endpoint]) - - -@router.delete( - "/config/pass_through_endpoint", - dependencies=[Depends(user_api_key_auth)], - response_model=PassThroughEndpointResponse, -) -async def delete_pass_through_endpoints( - endpoint_id: str, - user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), -): - """ - Delete a pass-through endpoint by ID. - - Returns - the deleted endpoint - """ - from litellm.proxy.proxy_server import ( - get_config_general_settings, - update_config_general_settings, - ) - - ## Get existing pass-through endpoint field value - - try: - response: ConfigFieldInfo = await get_config_general_settings( - field_name="pass_through_endpoints", user_api_key_dict=user_api_key_dict - ) - except Exception: - response = ConfigFieldInfo( - field_name="pass_through_endpoints", field_value=None - ) - - ## Update field by removing endpoint - pass_through_endpoint_data: Optional[List] = response.field_value - if response.field_value is None or pass_through_endpoint_data is None: - raise HTTPException( - status_code=400, - detail={"error": "There are no pass-through endpoints setup."}, - ) - - # Find the endpoint to delete - found_endpoint = _find_endpoint_by_id(pass_through_endpoint_data, endpoint_id) - - if found_endpoint is None: - raise HTTPException( - status_code=400, - detail={ - "error": "Endpoint with ID '{}' was not found in pass-through endpoint list.".format( - endpoint_id - ) - }, - ) - - # Find the index for deleting from the list - endpoint_index = None - for idx, endpoint in enumerate(pass_through_endpoint_data): - _endpoint = ( - PassThroughGenericEndpoint(**endpoint) - if isinstance(endpoint, dict) - else endpoint - ) - if _endpoint.id == endpoint_id: - endpoint_index = idx - break - - if endpoint_index is None: - raise HTTPException( - status_code=400, - detail={ - "error": f"Could not find index for endpoint with ID '{endpoint_id}'" - }, - ) - - # Remove the endpoint - pass_through_endpoint_data.pop(endpoint_index) - response_obj = found_endpoint - - # Remove routes from registry - InitPassThroughEndpointHelpers.remove_endpoint_routes(endpoint_id) - - ## Update db - updated_data = ConfigFieldUpdate( - field_name="pass_through_endpoints", - field_value=pass_through_endpoint_data, - config_type="general_settings", - ) - await update_config_general_settings( - data=updated_data, user_api_key_dict=user_api_key_dict - ) - - return PassThroughEndpointResponse(endpoints=[response_obj]) - - -def _find_endpoint_by_id( - endpoints_data: List, - endpoint_id: str, -) -> Optional[PassThroughGenericEndpoint]: - """ - Find an endpoint by ID. - - Args: - endpoints_data: List of endpoint data (dicts or PassThroughGenericEndpoint objects) - endpoint_id: ID to search for - - Returns: - Found endpoint or None if not found - """ - for endpoint in endpoints_data: - _endpoint: Optional[PassThroughGenericEndpoint] = None - if isinstance(endpoint, dict): - _endpoint = PassThroughGenericEndpoint(**endpoint) - elif isinstance(endpoint, PassThroughGenericEndpoint): - _endpoint = endpoint - - # Only compare IDs to IDs - if _endpoint is not None and _endpoint.id == endpoint_id: - return _endpoint - - return None - - -async def initialize_pass_through_endpoints_in_db(): - """ - Gets all pass-through endpoints from db and initializes them in the proxy server. - """ - pass_through_endpoints = await _get_pass_through_endpoints_from_db() - await initialize_pass_through_endpoints( - pass_through_endpoints=pass_through_endpoints - ) +import ast +import asyncio +import copy +import json +import traceback +from base64 import b64encode +from datetime import datetime +from typing import Any, Dict, List, Optional, Tuple, Union, cast +from urllib.parse import urlencode, urlparse + +import httpx +from fastapi import ( + APIRouter, + Depends, + FastAPI, + HTTPException, + Request, + Response, + UploadFile, + WebSocket, + status, +) +from fastapi.responses import StreamingResponse +from starlette.datastructures import UploadFile as StarletteUploadFile +from starlette.websockets import WebSocketState +from websockets.asyncio.client import connect +from websockets.exceptions import ( + ConnectionClosedError, + ConnectionClosedOK, + InvalidStatus, +) + +import litellm +from litellm._logging import verbose_proxy_logger +from litellm._uuid import uuid +from litellm.constants import MAXIMUM_TRACEBACK_LINES_TO_LOG +from litellm.integrations.custom_logger import CustomLogger +from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj +from litellm.litellm_core_utils.safe_json_dumps import safe_dumps +from litellm.llms.custom_httpx.http_handler import get_async_httpx_client +from litellm.passthrough import BasePassthroughUtils +from litellm.proxy._types import ( + CommonProxyErrors, + ConfigFieldInfo, + ConfigFieldUpdate, + LiteLLMRoutes, + PassThroughEndpointResponse, + PassThroughGenericEndpoint, + ProxyException, + UserAPIKeyAuth, +) +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing +from litellm.proxy.common_utils.http_parsing_utils import ( + _read_request_body, + _safe_get_request_headers, +) +from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup +from litellm.proxy.utils import get_server_root_path, normalize_route_for_root_path +from litellm.secret_managers.main import get_secret_str +from litellm.types.llms.custom_http import httpxSpecialProvider +from litellm.types.passthrough_endpoints.pass_through_endpoints import ( + EndpointType, + PassthroughStandardLoggingPayload, +) + +from .streaming_handler import PassThroughStreamingHandler +from .success_handler import PassThroughEndpointLogging + +router = APIRouter() + +pass_through_endpoint_logging = PassThroughEndpointLogging() + +# Global registry to track registered pass-through routes and prevent memory leaks +_registered_pass_through_routes: Dict[ + str, Dict[str, Union[str, List[str], Dict[str, Any]]] +] = {} + +# Programmatic pass-through callers (e.g. Bedrock proxy) attach JSON here. Must not use a +# `custom_body: dict` route parameter — FastAPI would treat it as the HTTP body and reject +# multipart/form-data before the handler runs. +LITELLM_PASS_THROUGH_CUSTOM_BODY_STATE_KEY = "litellm_pass_through_custom_body" + + +def get_response_body(response: httpx.Response) -> Optional[dict]: + try: + return response.json() + except Exception: + return None + + +async def set_env_variables_in_header(custom_headers: Optional[dict]) -> Optional[dict]: + """ + checks if any headers on config.yaml are defined as os.environ/COHERE_API_KEY etc + + only runs for headers defined on config.yaml + + example header can be + + {"Authorization": "Bearer os.environ/COHERE_API_KEY"} + """ + if custom_headers is None: + return None + headers = {} + for key, value in custom_headers.items(): + # langfuse Api requires base64 encoded headers - it's simpleer to just ask litellm users to set their langfuse public and secret keys + # we can then get the b64 encoded keys here + if key == "LANGFUSE_PUBLIC_KEY" or key == "LANGFUSE_SECRET_KEY": + # langfuse requires b64 encoded headers - we construct that here + _langfuse_public_key = custom_headers["LANGFUSE_PUBLIC_KEY"] + _langfuse_secret_key = custom_headers["LANGFUSE_SECRET_KEY"] + if isinstance( + _langfuse_public_key, str + ) and _langfuse_public_key.startswith("os.environ/"): + _langfuse_public_key = get_secret_str(_langfuse_public_key) + if isinstance( + _langfuse_secret_key, str + ) and _langfuse_secret_key.startswith("os.environ/"): + _langfuse_secret_key = get_secret_str(_langfuse_secret_key) + headers["Authorization"] = "Basic " + b64encode( + f"{_langfuse_public_key}:{_langfuse_secret_key}".encode("utf-8") + ).decode("ascii") + else: + # for all other headers + headers[key] = value + if isinstance(value, str) and "os.environ/" in value: + verbose_proxy_logger.debug( + "pass through endpoint - looking up 'os.environ/' variable" + ) + # get string section that is os.environ/ + start_index = value.find("os.environ/") + _variable_name = value[start_index:] + + verbose_proxy_logger.debug( + "pass through endpoint - getting secret for variable name: %s", + _variable_name, + ) + _secret_value = get_secret_str(_variable_name) + if _secret_value is not None: + new_value = value.replace(_variable_name, _secret_value) + headers[key] = new_value + return headers + + +async def chat_completion_pass_through_endpoint( # noqa: PLR0915 + fastapi_response: Response, + request: Request, + adapter_id: str, + user_api_key_dict: UserAPIKeyAuth, +): + from litellm.proxy.proxy_server import ( + add_litellm_data_to_request, + general_settings, + llm_router, + proxy_config, + proxy_logging_obj, + user_api_base, + user_max_tokens, + user_model, + user_request_timeout, + user_temperature, + version, + ) + + data = {} + try: + body = await request.body() + body_str = body.decode() + try: + data = ast.literal_eval(body_str) + except Exception: + data = json.loads(body_str) + + data["adapter_id"] = adapter_id + + verbose_proxy_logger.debug( + "Request received by LiteLLM:\n{}".format(json.dumps(data, indent=4)), + ) + data["model"] = ( + general_settings.get("completion_model", None) # server default + or user_model # model name passed via cli args + or data.get("model", None) # default passed in http request + ) + if user_model: + data["model"] = user_model + + data = await add_litellm_data_to_request( + data=data, # type: ignore + request=request, + general_settings=general_settings, + user_api_key_dict=user_api_key_dict, + version=version, + proxy_config=proxy_config, + ) + + # override with user settings, these are params passed via cli + if user_temperature: + data["temperature"] = user_temperature + if user_request_timeout: + data["request_timeout"] = user_request_timeout + if user_max_tokens: + data["max_tokens"] = user_max_tokens + if user_api_base: + data["api_base"] = user_api_base + + ### MODEL ALIAS MAPPING ### + # check if model name in model alias map + # get the actual model name + if data["model"] in litellm.model_alias_map: + data["model"] = litellm.model_alias_map[data["model"]] + + # Check key-specific aliases + if ( + isinstance(data["model"], str) + and user_api_key_dict.aliases + and isinstance(user_api_key_dict.aliases, dict) + and data["model"] in user_api_key_dict.aliases + ): + data["model"] = user_api_key_dict.aliases[data["model"]] + + ### CALL HOOKS ### - modify incoming data before calling the model + data = await proxy_logging_obj.pre_call_hook( # type: ignore + user_api_key_dict=user_api_key_dict, data=data, call_type="text_completion" + ) + + ### ROUTE THE REQUESTs ### + router_model_names = llm_router.model_names if llm_router is not None else [] + # skip router if user passed their key + if "api_key" in data: + llm_response = asyncio.create_task(litellm.aadapter_completion(**data)) + elif ( + llm_router is not None and data["model"] in router_model_names + ): # model in router model list + llm_response = asyncio.create_task(llm_router.aadapter_completion(**data)) + elif ( + llm_router is not None + and llm_router.model_group_alias is not None + and data["model"] in llm_router.model_group_alias + ): # model set in model_group_alias + llm_response = asyncio.create_task(llm_router.aadapter_completion(**data)) + elif llm_router is not None and llm_router.has_model_id( + data["model"] + ): # model in router model list + llm_response = asyncio.create_task(llm_router.aadapter_completion(**data)) + elif ( + llm_router is not None + and data["model"] not in router_model_names + and ( + llm_router.default_deployment is not None + or len(llm_router.pattern_router.patterns) > 0 + ) + ): # check for wildcard routes or default deployment before checking deployment_names + llm_response = asyncio.create_task(llm_router.aadapter_completion(**data)) + elif ( + llm_router is not None and data["model"] in llm_router.deployment_names + ): # model in router deployments, calling a specific deployment on the router (lowest priority) + llm_response = asyncio.create_task( + llm_router.aadapter_completion(**data, specific_deployment=True) + ) + elif user_model is not None: # `litellm --model ` + llm_response = asyncio.create_task(litellm.aadapter_completion(**data)) + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "error": "completion: Invalid model name passed in model=" + + data.get("model", "") + }, + ) + + # Await the llm_response task + response = await llm_response + + hidden_params = getattr(response, "_hidden_params", {}) or {} + model_id = hidden_params.get("model_id", None) or "" + cache_key = hidden_params.get("cache_key", None) or "" + api_base = hidden_params.get("api_base", None) or "" + response_cost = hidden_params.get("response_cost", None) or "" + + ### ALERTING ### + asyncio.create_task( + proxy_logging_obj.update_request_status( + litellm_call_id=data.get("litellm_call_id", ""), status="success" + ) + ) + + verbose_proxy_logger.debug("final response: %s", response) + + fastapi_response.headers.update( + ProxyBaseLLMRequestProcessing.get_custom_headers( + user_api_key_dict=user_api_key_dict, + model_id=model_id, + cache_key=cache_key, + api_base=api_base, + version=version, + response_cost=response_cost, + ) + ) + + verbose_proxy_logger.debug("\nResponse from Litellm:\n{}".format(response)) + return response + except Exception as e: + await proxy_logging_obj.post_call_failure_hook( + user_api_key_dict=user_api_key_dict, original_exception=e, request_data=data + ) + verbose_proxy_logger.exception( + "litellm.proxy.proxy_server.completion(): Exception occured - {}".format( + str(e) + ) + ) + error_msg = f"{str(e)}" + raise ProxyException( + message=getattr(e, "message", error_msg), + type=getattr(e, "type", "None"), + param=getattr(e, "param", "None"), + code=getattr(e, "status_code", 500), + ) + + +class HttpPassThroughEndpointHelpers(BasePassthroughUtils): + @staticmethod + def get_response_headers( + headers: httpx.Headers, + litellm_call_id: Optional[str] = None, + custom_headers: Optional[dict] = None, + ) -> dict: + excluded_headers = {"transfer-encoding", "content-encoding"} + + return_headers = { + key: value + for key, value in headers.items() + if key.lower() not in excluded_headers + } + if litellm_call_id: + return_headers["x-litellm-call-id"] = litellm_call_id + if custom_headers: + return_headers.update(custom_headers) + + return return_headers + + @staticmethod + def get_endpoint_type(url: str) -> EndpointType: + parsed_url = urlparse(url) + if ( + ("generateContent") in url + or ("streamGenerateContent") in url + or ("rawPredict") in url + or ("streamRawPredict") in url + ): + return EndpointType.VERTEX_AI + elif parsed_url.hostname == "api.anthropic.com": + return EndpointType.ANTHROPIC + elif ( + parsed_url.hostname == "api.openai.com" + or parsed_url.hostname == "openai.azure.com" + or (parsed_url.hostname and "openai.com" in parsed_url.hostname) + ): + return EndpointType.OPENAI + return EndpointType.GENERIC + + @staticmethod + async def _make_non_streaming_http_request( + request: Request, + async_client: httpx.AsyncClient, + url: str, + headers: dict, + requested_query_params: Optional[dict] = None, + custom_body: Optional[dict] = None, + ) -> httpx.Response: + """ + Make a non-streaming HTTP request + + If request is GET, don't include a JSON body + """ + if request.method == "GET": + response = await async_client.request( + method=request.method, + url=url, + headers=headers, + params=requested_query_params, + ) + else: + response = await async_client.request( + method=request.method, + url=url, + headers=headers, + params=requested_query_params, + json=custom_body, + ) + return response + + @staticmethod + async def non_streaming_http_request_handler( + request: Request, + async_client: httpx.AsyncClient, + url: httpx.URL, + headers: dict, + requested_query_params: Optional[dict] = None, + _parsed_body: Optional[dict] = None, + forward_multipart: bool = False, + ) -> httpx.Response: + """ + Handle non-streaming HTTP requests + + Handles special cases when GET requests, multipart/form-data requests, and generic httpx requests + """ + if request.method == "GET": + response = await async_client.request( + method=request.method, + url=url, + headers=headers, + params=requested_query_params, + ) + elif ( + HttpPassThroughEndpointHelpers.is_multipart(request) is True + and forward_multipart + ): + # Forward multipart via make_multipart_http_request even when _parsed_body is + # non-empty (pass_through_request always injects litellm_logging_obj, etc.). + # forward_multipart is False when custom_body was supplied (JSON body despite + # multipart content-type) — those requests use the generic json= path. + return await HttpPassThroughEndpointHelpers.make_multipart_http_request( + request=request, + async_client=async_client, + url=url, + headers=headers, + requested_query_params=requested_query_params, + ) + else: + # Generic httpx method + response = await async_client.request( + method=request.method, + url=url, + headers=headers, + params=requested_query_params, + json=_parsed_body, + ) + return response + + @staticmethod + def is_multipart(request: Request) -> bool: + """Check if the request is a multipart/form-data request""" + return "multipart/form-data" in request.headers.get("content-type", "") + + @staticmethod + async def _build_request_files_from_upload_file( + upload_file: Union[UploadFile, StarletteUploadFile], + ) -> Tuple[Optional[str], bytes, Optional[str]]: + """Build a request files dict from an UploadFile object""" + file_content = await upload_file.read() + return (upload_file.filename, file_content, upload_file.content_type) + + @staticmethod + async def make_multipart_http_request( + request: Request, + async_client: httpx.AsyncClient, + url: httpx.URL, + headers: dict, + requested_query_params: Optional[dict] = None, + stream: bool = False, + ) -> httpx.Response: + """Process multipart/form-data requests, handling both files and form fields""" + form_data = await request.form() + files = {} + form_data_dict = {} + + for field_name, field_value in form_data.items(): + if isinstance(field_value, (StarletteUploadFile, UploadFile)): + files[field_name] = ( + await HttpPassThroughEndpointHelpers._build_request_files_from_upload_file( + upload_file=field_value + ) + ) + else: + form_data_dict[field_name] = field_value + + # Remove content-type header - httpx will set it correctly with the new boundary + # when it creates the multipart body from files/data parameters + headers_copy = headers.copy() + headers_copy.pop("content-type", None) + + # httpx.AsyncClient.request() does not accept stream=; use send() for streaming. + if stream: + req = async_client.build_request( + request.method, + url, + headers=headers_copy, + params=requested_query_params, + files=files, + data=form_data_dict, + ) + return await async_client.send(req, stream=True) + + return await async_client.request( + method=request.method, + url=url, + headers=headers_copy, + params=requested_query_params, + files=files, + data=form_data_dict, + ) + + @staticmethod + def _init_kwargs_for_pass_through_endpoint( + request: Request, + user_api_key_dict: UserAPIKeyAuth, + passthrough_logging_payload: PassthroughStandardLoggingPayload, + logging_obj: LiteLLMLoggingObj, + _parsed_body: Optional[dict] = None, + litellm_call_id: Optional[str] = None, + ) -> dict: + """ + Filter out litellm params from the request body + """ + from litellm.types.utils import all_litellm_params + + _parsed_body = _parsed_body or {} + + litellm_params_in_body = {} + for k in all_litellm_params: + if k in _parsed_body: + litellm_params_in_body[k] = _parsed_body.pop(k, None) + + _metadata = dict( + LiteLLMProxyRequestSetup.get_sanitized_user_information_from_key( + user_api_key_dict=user_api_key_dict + ) + ) + + _metadata["user_api_key"] = user_api_key_dict.api_key + + litellm_metadata = litellm_params_in_body.pop("litellm_metadata", None) + metadata = litellm_params_in_body.pop("metadata", None) + if litellm_metadata: + _metadata.update(litellm_metadata) + if metadata: + _metadata.update(metadata) + + _metadata = _update_metadata_with_tags_in_header( + request=request, + metadata=_metadata, + ) + + kwargs = { + "litellm_params": { + **litellm_params_in_body, # type: ignore + "metadata": _metadata, + "proxy_server_request": { + "url": str(request.url), + "method": request.method, + "body": copy.copy(_parsed_body), # use copy instead of deepcopy + "headers": request.headers, + }, + }, + "call_type": "pass_through_endpoint", + "litellm_call_id": litellm_call_id, + "passthrough_logging_payload": passthrough_logging_payload, + } + + logging_obj.model_call_details["passthrough_logging_payload"] = ( + passthrough_logging_payload + ) + + return kwargs + + @staticmethod + def construct_target_url_with_subpath( + base_target: str, subpath: str, include_subpath: Optional[bool] + ) -> str: + """ + Helper function to construct the full target URL with subpath handling. + + Args: + base_target: The base target URL + subpath: The captured subpath from the request + include_subpath: Whether to include the subpath in the target URL + + Returns: + The constructed full target URL + """ + if not include_subpath: + return base_target + + if not subpath: + return base_target + + # Ensure base_target ends with / and subpath doesn't start with / + if not base_target.endswith("/"): + base_target = base_target + "/" + if subpath.startswith("/"): + subpath = subpath[1:] + + return base_target + subpath + + @staticmethod + def _update_stream_param_based_on_request_body( + parsed_body: dict, + stream: Optional[bool] = None, + ) -> Optional[bool]: + """ + If stream is provided in the request body, use it. + Otherwise, use the stream parameter passed to the `pass_through_request` function + """ + if "stream" in parsed_body: + return parsed_body.get("stream", stream) + return stream + + +async def pass_through_request( # noqa: PLR0915 + request: Request, + target: str, + custom_headers: dict, + user_api_key_dict: UserAPIKeyAuth, + custom_body: Optional[dict] = None, + forward_headers: Optional[bool] = False, + merge_query_params: Optional[bool] = False, + query_params: Optional[dict] = None, + default_query_params: Optional[dict] = None, + stream: Optional[bool] = None, + cost_per_request: Optional[float] = None, + custom_llm_provider: Optional[str] = None, + guardrails_config: Optional[dict] = None, +): + """ + Pass through endpoint handler, makes the httpx request for pass-through endpoints and ensures logging hooks are called + + Args: + request: The incoming request + target: The target URL + custom_headers: The custom headers + user_api_key_dict: The user API key dictionary + custom_body: The custom body + forward_headers: Whether to forward headers + merge_query_params: Whether to merge query params + query_params: The query params + default_query_params: The default query params to be applied if not overridden by client + stream: Whether to stream the response + cost_per_request: Optional field - cost per request to the target endpoint + custom_llm_provider: Optional field - custom LLM provider for the endpoint + guardrails_config: Optional field - guardrails configuration for passthrough endpoint + """ + from litellm.litellm_core_utils.litellm_logging import Logging + from litellm.proxy.pass_through_endpoints.passthrough_guardrails import ( + PassthroughGuardrailHandler, + ) + from litellm.proxy.proxy_server import proxy_logging_obj + + ######################################################### + # Initialize variables + ######################################################### + litellm_call_id = str(uuid.uuid4()) + url: Optional[httpx.URL] = None + + # parsed request body + _parsed_body: Optional[dict] = None + # kwargs for pass through endpoint, contains metadata, litellm_params, call_type, litellm_call_id, passthrough_logging_payload + kwargs: Optional[dict] = None + logging_obj: Optional[Logging] = None + + ######################################################### + try: + url = httpx.URL(target) + headers = custom_headers + headers = HttpPassThroughEndpointHelpers.forward_headers_from_request( + request_headers=_safe_get_request_headers(request).copy(), + headers=headers, + forward_headers=forward_headers, + ) + + # Apply default query parameters if provided, regardless of merge_query_params setting + if default_query_params or merge_query_params: + # Determine what to merge based on settings + request_params = dict(request.query_params) if merge_query_params else {} + + # Create a new URL with the merged query params + url = url.copy_with( + query=urlencode( + HttpPassThroughEndpointHelpers.get_merged_query_parameters( + existing_url=url, + request_query_params=request_params, + default_query_params=default_query_params, + ) + ).encode("ascii") + ) + + endpoint_type: EndpointType = HttpPassThroughEndpointHelpers.get_endpoint_type( + str(url) + ) + + # Skip body parsing for multipart requests - make_multipart_http_request will handle it + # But if custom_body is provided (e.g., JSON parsed despite multipart content-type), use it + is_multipart = ( + HttpPassThroughEndpointHelpers.is_multipart(request) and not custom_body + ) + + if custom_body: + _parsed_body = custom_body + elif is_multipart: + # Don't parse multipart body here - it will be handled by make_multipart_http_request + _parsed_body = {} + else: + _parsed_body = await _read_request_body(request) + verbose_proxy_logger.debug( + "Pass through endpoint sending request to \nURL {}\nheaders: {}\nbody: {}\n".format( + url, headers, _parsed_body + ) + ) + + ### COLLECT GUARDRAILS FOR PASSTHROUGH ENDPOINT ### + # Passthrough endpoints are opt-in only for guardrails + # When enabled, collect guardrails from org/team/key levels + passthrough-specific + guardrails_to_run = PassthroughGuardrailHandler.collect_guardrails( + user_api_key_dict=user_api_key_dict, + passthrough_guardrails_config=guardrails_config, + ) + + # Add guardrails to metadata if any should run + if guardrails_to_run and len(guardrails_to_run) > 0: + if _parsed_body is None: + _parsed_body = {} + if "metadata" not in _parsed_body: + _parsed_body["metadata"] = {} + _parsed_body["metadata"]["guardrails"] = guardrails_to_run + verbose_proxy_logger.debug( + f"Added guardrails to passthrough request metadata: {guardrails_to_run}" + ) + + ## LOGGING OBJECT ## - initialize before pre_call_hook so guardrails can access it + start_time = datetime.now() + logging_obj = Logging( + model="unknown", + messages=[{"role": "user", "content": safe_dumps(_parsed_body)}], + stream=False, + call_type="pass_through_endpoint", + start_time=start_time, + litellm_call_id=litellm_call_id, + function_id="1245", + ) + + # Store passthrough guardrails config on logging_obj for field targeting + logging_obj.passthrough_guardrails_config = guardrails_config + + # Store logging_obj in data so guardrails can access it + if _parsed_body is None: + _parsed_body = {} + _parsed_body["litellm_logging_obj"] = logging_obj + + ### CALL HOOKS ### - modify incoming data / reject request before calling the model + _parsed_body = await proxy_logging_obj.pre_call_hook( + user_api_key_dict=user_api_key_dict, + data=_parsed_body, + call_type="pass_through_endpoint", + ) + async_client_obj = get_async_httpx_client( + llm_provider=httpxSpecialProvider.PassThroughEndpoint, + params={"timeout": 600}, + ) + async_client = async_client_obj.client + passthrough_logging_payload = PassthroughStandardLoggingPayload( + url=str(url), + request_body=_parsed_body, + request_method=getattr(request, "method", None), + cost_per_request=cost_per_request, + ) + kwargs = HttpPassThroughEndpointHelpers._init_kwargs_for_pass_through_endpoint( + user_api_key_dict=user_api_key_dict, + _parsed_body=_parsed_body, + passthrough_logging_payload=passthrough_logging_payload, + litellm_call_id=litellm_call_id, + request=request, + logging_obj=logging_obj, + ) + + # Store custom_llm_provider in kwargs and logging object if provided + if custom_llm_provider: + logging_obj.model_call_details["custom_llm_provider"] = custom_llm_provider + logging_obj.model_call_details["litellm_params"] = kwargs.get( + "litellm_params", {} + ) + + # done for supporting 'parallel_request_limiter.py' with pass-through endpoints + logging_obj.update_environment_variables( + model="unknown", + user="unknown", + optional_params={}, + litellm_params=kwargs["litellm_params"], + call_type="pass_through_endpoint", + ) + logging_obj.model_call_details["litellm_call_id"] = litellm_call_id + + # combine url with query params for logging + requested_query_params: Optional[dict] = query_params or dict( + request.query_params + ) + + requested_query_params_str = None + if requested_query_params: + requested_query_params_str = "&".join( + f"{k}={v}" for k, v in requested_query_params.items() + ) + + logging_url = str(url) + if requested_query_params_str: + if "?" in str(url): + logging_url = str(url) + "&" + requested_query_params_str + else: + logging_url = str(url) + "?" + requested_query_params_str + + logging_obj.pre_call( + input=[{"role": "user", "content": safe_dumps(_parsed_body)}], + api_key="", + additional_args={ + "complete_input_dict": _parsed_body, + "api_base": str(logging_url), + "headers": headers, + }, + ) + stream = ( + HttpPassThroughEndpointHelpers._update_stream_param_based_on_request_body( + parsed_body=_parsed_body, + stream=stream, + ) + ) + + if stream: + if is_multipart: + response = ( + await HttpPassThroughEndpointHelpers.make_multipart_http_request( + request=request, + async_client=async_client, + url=url, + headers=headers, + requested_query_params=requested_query_params, + stream=True, + ) + ) + else: + req = async_client.build_request( + "POST", + url, + json=_parsed_body, + params=requested_query_params, + headers=headers, + ) + + response = await async_client.send(req, stream=stream) + + try: + response.raise_for_status() + except httpx.HTTPStatusError as e: + raise HTTPException( + status_code=e.response.status_code, detail=await e.response.aread() + ) + + return StreamingResponse( + PassThroughStreamingHandler.chunk_processor( + response=response, + request_body=_parsed_body, + litellm_logging_obj=logging_obj, + endpoint_type=endpoint_type, + start_time=start_time, + passthrough_success_handler_obj=pass_through_endpoint_logging, + url_route=str(url), + ), + headers=HttpPassThroughEndpointHelpers.get_response_headers( + headers=response.headers, + litellm_call_id=litellm_call_id, + ), + status_code=response.status_code, + ) + + response = ( + await HttpPassThroughEndpointHelpers.non_streaming_http_request_handler( + request=request, + async_client=async_client, + url=url, + headers=headers, + requested_query_params=requested_query_params, + _parsed_body=_parsed_body, + forward_multipart=is_multipart, + ) + ) + verbose_proxy_logger.debug("response.headers= %s", response.headers) + + if _is_streaming_response(response) is True: + try: + response.raise_for_status() + except httpx.HTTPStatusError as e: + raise HTTPException( + status_code=e.response.status_code, detail=await e.response.aread() + ) + + return StreamingResponse( + PassThroughStreamingHandler.chunk_processor( + response=response, + request_body=_parsed_body, + litellm_logging_obj=logging_obj, + endpoint_type=endpoint_type, + start_time=start_time, + passthrough_success_handler_obj=pass_through_endpoint_logging, + url_route=str(url), + ), + headers=HttpPassThroughEndpointHelpers.get_response_headers( + headers=response.headers, + litellm_call_id=litellm_call_id, + ), + status_code=response.status_code, + ) + + try: + response.raise_for_status() + except httpx.HTTPStatusError as e: + raise HTTPException( + status_code=e.response.status_code, detail=e.response.text + ) + + if response.status_code >= 300: + raise HTTPException(status_code=response.status_code, detail=response.text) + + content = await response.aread() + + ## LOG SUCCESS + response_body: Optional[dict] = get_response_body(response) + passthrough_logging_payload["response_body"] = response_body + end_time = datetime.now() + asyncio.create_task( + pass_through_endpoint_logging.pass_through_async_success_handler( + httpx_response=response, + response_body=response_body, + url_route=str(url), + result="", + start_time=start_time, + end_time=end_time, + logging_obj=logging_obj, + cache_hit=False, + request_body=_parsed_body, + custom_llm_provider=custom_llm_provider, + **kwargs, + ) + ) + + ## CUSTOM HEADERS - `x-litellm-*` + custom_headers = ProxyBaseLLMRequestProcessing.get_custom_headers( + user_api_key_dict=user_api_key_dict, + call_id=litellm_call_id, + model_id=None, + cache_key=None, + api_base=str(url._uri_reference), + ) + + return Response( + content=content, + status_code=response.status_code, + headers=HttpPassThroughEndpointHelpers.get_response_headers( + headers=response.headers, + custom_headers=custom_headers, + ), + ) + except Exception as e: + custom_headers = ProxyBaseLLMRequestProcessing.get_custom_headers( + user_api_key_dict=user_api_key_dict, + call_id=litellm_call_id, + model_id=None, + cache_key=None, + api_base=str(url._uri_reference) if url else None, + ) + verbose_proxy_logger.exception( + "litellm.proxy.proxy_server.pass_through_endpoint(): Exception occured - {}".format( + str(e) + ) + ) + + ######################################################### + # Monitoring: Trigger post_call_failure_hook + # for pass through endpoint failure + ######################################################### + request_payload: dict = _parsed_body or {} + # add user_api_key_dict, litellm_call_id, passthrough_logging_payloa for logging + if kwargs: + for key, value in kwargs.items(): + request_payload[key] = value + if logging_obj is not None: + request_payload["litellm_logging_obj"] = logging_obj + + if ( + "model" not in request_payload + and _parsed_body + and isinstance(_parsed_body, dict) + ): + request_payload["model"] = _parsed_body.get("model", "") + if "custom_llm_provider" not in request_payload and custom_llm_provider: + request_payload["custom_llm_provider"] = custom_llm_provider + + await proxy_logging_obj.post_call_failure_hook( + user_api_key_dict=user_api_key_dict, + original_exception=e, + request_data=request_payload, + traceback_str=traceback.format_exc( + limit=MAXIMUM_TRACEBACK_LINES_TO_LOG, + ), + ) + + ######################################################### + + if isinstance(e, HTTPException): + raise ProxyException( + message=getattr(e, "message", str(getattr(e, "detail", str(e)))), + type=getattr(e, "type", "None"), + param=getattr(e, "param", "None"), + code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST), + headers=custom_headers, + ) + else: + error_msg = f"{str(e)}" + raise ProxyException( + message=getattr(e, "message", error_msg), + type=getattr(e, "type", "None"), + param=getattr(e, "param", "None"), + code=getattr(e, "status_code", 500), + headers=custom_headers, + ) + + +def _update_metadata_with_tags_in_header(request: Request, metadata: dict) -> dict: + """ + If tags are in the request headers, add them to the metadata + + Used for google and vertex JS SDKs, and Azure passthrough + Checks both 'tags' and 'x-litellm-tags' headers + """ + tags_to_add = [] + + # Check for 'tags' header first + _tags = request.headers.get("tags") + if _tags: + tags_to_add.extend([tag.strip() for tag in _tags.split(",")]) + + _tags = request.headers.get("x-litellm-tags") + if _tags: + tags_to_add.extend([tag.strip() for tag in _tags.split(",")]) + + # Only add tags key if there are tags to add + if tags_to_add: + if "tags" not in metadata: + metadata["tags"] = [] + metadata["tags"].extend(tags_to_add) + + return metadata + + +async def _parse_request_data_by_content_type( + request: Request, +) -> Tuple[Optional[Any], Optional[Any], Optional[Any], Optional[Any]]: + """ + Parse request data based on content type. + + Handles JSON, multipart/form-data, and URL-encoded form data. + + Returns: + Tuple of (query_params_data, custom_body_data, file_data, stream) + """ + content_type = request.headers.get("content-type", "") + + query_params_data = None + custom_body_data = None + file_data = None + stream = None + + if "application/json" in content_type: + # ✅ Handle JSON + try: + body = await request.json() + query_params_data = body.get("query_params") + custom_body_data = body.get("custom_body") + stream = body.get("stream") + except json.JSONDecodeError: + # Handle requests with no body (e.g., DELETE requests) + pass + elif "multipart/form-data" in content_type: + # ✅ Try to parse as JSON first (handles misconfigured clients sending JSON with multipart content-type) + # If that fails, skip parsing - pass_through_request will handle actual multipart + try: + body = await request.json() + # Successfully parsed as JSON - treat as JSON body + query_params_data = body.get("query_params") + custom_body_data = body.get("custom_body") + stream = body.get("stream") + # If custom_body is not set, use the entire body + if custom_body_data is None and body: + custom_body_data = body + except (json.JSONDecodeError, Exception): + # Not JSON - this is actual multipart data + # Skip parsing here to avoid consuming the request body stream + # make_multipart_http_request will handle it + pass + + elif "application/x-www-form-urlencoded" in content_type: + # ✅ Handle URL-encoded form data + form = await request.form() + query_params_data = form.get("query_params") + custom_body_data = form.get("custom_body") + + else: + # ✅ Fallback: maybe no body, just query params + query_params_data = dict(request.query_params) or None + + return query_params_data, custom_body_data, file_data, stream + + +def create_pass_through_route( + endpoint, + target: str, + custom_headers: Optional[dict] = None, + _forward_headers: Optional[bool] = False, + _merge_query_params: Optional[bool] = False, + dependencies: Optional[List] = None, + include_subpath: Optional[bool] = False, + cost_per_request: Optional[float] = None, + custom_llm_provider: Optional[str] = None, + is_streaming_request: Optional[bool] = False, + query_params: Optional[dict] = None, + default_query_params: Optional[dict] = None, + guardrails: Optional[Dict[str, Any]] = None, +): + # check if target is an adapter.py or a url + from litellm._uuid import uuid + from litellm.proxy.types_utils.utils import get_instance_fn + + try: + if isinstance(target, CustomLogger): + adapter = target + else: + adapter = get_instance_fn(value=target) + adapter_id = str(uuid.uuid4()) + litellm.adapters = [{"id": adapter_id, "adapter": adapter}] + + async def endpoint_func( # type: ignore + request: Request, + fastapi_response: Response, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), + subpath: str = "", # captures sub-paths when include_subpath=True + ): + return await chat_completion_pass_through_endpoint( + fastapi_response=fastapi_response, + request=request, + adapter_id=adapter_id, + user_api_key_dict=user_api_key_dict, + ) + + except Exception: + verbose_proxy_logger.debug("Defaulting to target being a url.") + + async def endpoint_func( # type: ignore + request: Request, + fastapi_response: Response, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), + subpath: str = "", # captures sub-paths when include_subpath=True + ): + from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( + InitPassThroughEndpointHelpers, + ) + + path = request.url.path + + # Parse request data based on content type + ( + query_params_data, + custom_body_data, + file_data, + stream, + ) = await _parse_request_data_by_content_type(request) + + if not InitPassThroughEndpointHelpers.is_registered_pass_through_route( + route=path + ): + raise HTTPException( + status_code=404, + detail=f"Pass-through endpoint {endpoint} not found. This could have been deleted or not yet added to the proxy.", + ) + + passthrough_params = ( + InitPassThroughEndpointHelpers.get_registered_pass_through_route( + route=path, method=request.method + ) + ) + target_params = { + "target": target, + "custom_headers": custom_headers, + "forward_headers": _forward_headers, + "merge_query_params": _merge_query_params, + "cost_per_request": cost_per_request, + "guardrails": None, + } + + if passthrough_params is not None: + target_params.update(passthrough_params.get("passthrough_params", {})) + + # Extract and cast parameters with proper types + param_target = target_params.get("target") or target + param_custom_headers = target_params.get("custom_headers", custom_headers) + param_forward_headers = target_params.get( + "forward_headers", _forward_headers + ) + param_merge_query_params = target_params.get( + "merge_query_params", _merge_query_params + ) + param_cost_per_request = target_params.get( + "cost_per_request", cost_per_request + ) + param_guardrails = target_params.get("guardrails", None) + param_default_query_params = target_params.get("default_query_params", None) + + # Construct the full target URL with subpath if needed + full_target = ( + HttpPassThroughEndpointHelpers.construct_target_url_with_subpath( + base_target=cast(str, param_target), + subpath=subpath, + include_subpath=include_subpath, + ) + ) + + # Ensure custom_headers is a dict + headers_dict = ( + param_custom_headers if isinstance(param_custom_headers, dict) else {} + ) + + # Ensure query_params and custom_body are dicts or None + final_query_params = ( + query_params_data if isinstance(query_params_data, dict) else {} + ) + if query_params: + final_query_params.update(query_params) + # Programmatic callers set LITELLM_PASS_THROUGH_CUSTOM_BODY_STATE_KEY on + # request.state (see Bedrock proxy). Parsed JSON envelope otherwise. + state_custom_body: Optional[dict] = getattr( + request.state, + LITELLM_PASS_THROUGH_CUSTOM_BODY_STATE_KEY, + None, + ) + final_custom_body: Optional[dict] = None + if isinstance(state_custom_body, dict): + final_custom_body = state_custom_body + elif isinstance(custom_body_data, dict): + final_custom_body = custom_body_data + + try: + return await pass_through_request( # type: ignore + request=request, + target=full_target, + custom_headers=headers_dict, + user_api_key_dict=user_api_key_dict, + forward_headers=cast(Optional[bool], param_forward_headers), + merge_query_params=cast(Optional[bool], param_merge_query_params), + query_params=final_query_params, + default_query_params=cast( + Optional[dict], param_default_query_params + ), + stream=is_streaming_request or stream, + custom_body=final_custom_body, + cost_per_request=cast(Optional[float], param_cost_per_request), + custom_llm_provider=custom_llm_provider, + guardrails_config=cast(Optional[dict], param_guardrails), + ) + finally: + if hasattr(request.state, LITELLM_PASS_THROUGH_CUSTOM_BODY_STATE_KEY): + delattr(request.state, LITELLM_PASS_THROUGH_CUSTOM_BODY_STATE_KEY) + + return endpoint_func + + +def create_websocket_passthrough_route( + endpoint: str, + target: str, + custom_headers: Optional[dict] = None, + _forward_headers: Optional[bool] = False, + dependencies: Optional[List] = None, + cost_per_request: Optional[float] = None, +): + """ + Create a WebSocket passthrough route function. + + Args: + endpoint: The endpoint path (for logging purposes) + target: The target WebSocket URL (e.g., "wss://api.example.com/ws") + custom_headers: Custom headers to include in the WebSocket connection + _forward_headers: Whether to forward incoming headers + dependencies: FastAPI dependencies to inject + + Returns: + A WebSocket passthrough function that can be registered with app.websocket() + """ + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth_websocket + + async def websocket_endpoint_func( + websocket: WebSocket, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth_websocket), + **kwargs, # For additional query parameters + ): + """ + WebSocket passthrough endpoint function. + + This function handles the WebSocket connection by: + 1. Accepting the incoming WebSocket connection + 2. Establishing a connection to the target WebSocket + 3. Forwarding messages bidirectionally + 4. Handling connection cleanup + """ + return await websocket_passthrough_request( + websocket=websocket, + target=target, + custom_headers=custom_headers or {}, + user_api_key_dict=user_api_key_dict, + forward_headers=_forward_headers, + endpoint=endpoint, + cost_per_request=cost_per_request, + accept_websocket=True, # Generic usage should accept the WebSocket + ) + + return websocket_endpoint_func + + +async def websocket_passthrough_request( # noqa: PLR0915 + websocket: WebSocket, + target: str, + custom_headers: dict, + user_api_key_dict: UserAPIKeyAuth, + forward_headers: Optional[bool] = False, + endpoint: Optional[str] = None, + cost_per_request: Optional[float] = None, + accept_websocket: bool = True, +): + """ + WebSocket passthrough request handler. + + Args: + websocket: The incoming WebSocket connection + target: The target WebSocket URL + custom_headers: Custom headers to include in the connection + user_api_key_dict: The user API key dictionary + forward_headers: Whether to forward incoming headers + endpoint: The endpoint path (for logging purposes) + cost_per_request: Optional field - cost per request to the target endpoint + """ + from litellm.litellm_core_utils.litellm_logging import Logging + from litellm.proxy.proxy_server import proxy_logging_obj + from litellm.types.passthrough_endpoints.pass_through_endpoints import ( + PassthroughStandardLoggingPayload, + ) + + # Initialize tracking variables + start_time = datetime.now() + websocket_messages: list[dict[str, Any]] = [] + litellm_call_id = str(uuid.uuid4()) + + verbose_proxy_logger.info( + f"WebSocket passthrough ({endpoint}): Starting WebSocket connection to {target}" + ) + + # Only accept the WebSocket if requested (for generic usage) + if accept_websocket: + await websocket.accept() + verbose_proxy_logger.debug( + f"WebSocket passthrough ({endpoint}): WebSocket connection accepted" + ) + + # Prepare headers for the upstream connection + upstream_headers = custom_headers.copy() + + if forward_headers: + # Forward relevant headers from the incoming request + incoming_headers = dict(websocket.headers) + for header_name, header_value in incoming_headers.items(): + # Only forward certain headers to avoid conflicts + if header_name.lower() in [ + "authorization", + "x-api-key", + "x-goog-user-project", + ]: + upstream_headers[header_name] = header_value + + # Initialize logging object similar to HTTP passthrough + logging_obj = Logging( + model="unknown", + messages=[{"role": "user", "content": "WebSocket connection"}], + stream=True, # WebSockets are inherently streaming + call_type="pass_through_endpoint", + start_time=start_time, + litellm_call_id=litellm_call_id, + function_id="websocket_passthrough", + ) + + # Create passthrough logging payload + passthrough_logging_payload = PassthroughStandardLoggingPayload( + url=target, + request_body={}, # WebSocket doesn't have a traditional request body + request_method="WEBSOCKET", + cost_per_request=cost_per_request, + ) + + # Create a dummy request object for WebSocket connections to maintain compatibility + # with the existing _init_kwargs_for_pass_through_endpoint function + class DummyRequest: + def __init__( + self, url: str, method: str = "WEBSOCKET", headers: Optional[dict] = None + ): + self.url = url + self.method = method + self.headers = headers or {} + + def __str__(self): + return f"DummyRequest(url={self.url}, method={self.method})" + + dummy_request = DummyRequest( + url=target, + method="WEBSOCKET", + headers=dict(websocket.headers) if hasattr(websocket, "headers") else {}, + ) + + # Initialize kwargs for logging using the same pattern as HTTP passthrough + kwargs = HttpPassThroughEndpointHelpers._init_kwargs_for_pass_through_endpoint( + user_api_key_dict=user_api_key_dict, + _parsed_body={}, # WebSocket doesn't have a traditional request body + passthrough_logging_payload=passthrough_logging_payload, + litellm_call_id=litellm_call_id, + request=dummy_request, # type: ignore + logging_obj=logging_obj, + ) + + # Update logging environment variables + logging_obj.update_environment_variables( + model="unknown", + user="unknown", + optional_params={}, + litellm_params=dict(kwargs.get("litellm_params", {})), + call_type="pass_through_endpoint", + ) + logging_obj.model_call_details["litellm_call_id"] = litellm_call_id + + # Pre-call logging + logging_obj.pre_call( + input=[{"role": "user", "content": "WebSocket connection"}], + api_key="", + additional_args={ + "complete_input_dict": {}, + "api_base": target, + "headers": upstream_headers, + }, + ) + + ### CALL HOOKS ### - modify incoming data / reject request before calling the model + websocket_data: dict[str, Any] = {} + websocket_data = await proxy_logging_obj.pre_call_hook( + user_api_key_dict=user_api_key_dict, + data=websocket_data, + call_type="pass_through_endpoint", + ) + + try: + verbose_proxy_logger.debug( + f"WebSocket passthrough ({endpoint}): Establishing upstream connection to {target}" + ) + async with connect( + target, + additional_headers=upstream_headers, + ) as upstream_ws: + verbose_proxy_logger.info( + f"WebSocket passthrough ({endpoint}): Upstream connection established successfully" + ) + + async def forward_client_to_upstream() -> None: + """Forward messages from client to upstream WebSocket""" + try: + while True: + message = await websocket.receive() + message_type = message.get("type") + if message_type == "websocket.disconnect": + await upstream_ws.close() + break + + text_data = message.get("text") + bytes_data = message.get("bytes") + + if text_data is not None: + # Try to extract model from client setup message for Vertex AI Live + if endpoint and "/vertex_ai/live" in endpoint: + verbose_proxy_logger.debug( + f"WebSocket passthrough ({endpoint}): Processing client message for model extraction" + ) + try: + client_message = json.loads(text_data) + if ( + isinstance(client_message, dict) + and "setup" in client_message + ): + setup_data = client_message["setup"] + verbose_proxy_logger.debug( + f"WebSocket passthrough ({endpoint}): Found setup data in client message: {setup_data}" + ) + if ( + isinstance(setup_data, dict) + and "model" in setup_data + ): + extracted_model = ( + _extract_model_from_vertex_ai_setup( + setup_data + ) + ) + if extracted_model: + kwargs["model"] = extracted_model + kwargs["custom_llm_provider"] = ( + "vertex_ai-language-models" + ) + # Update logging object with correct model + logging_obj.model = extracted_model + logging_obj.model_call_details[ + "model" + ] = extracted_model + logging_obj.model_call_details[ + "custom_llm_provider" + ] = "vertex_ai" + verbose_proxy_logger.info( + f"WebSocket passthrough ({endpoint}): Successfully extracted model '{extracted_model}' and set provider to 'vertex_ai' from client setup message" + ) + else: + verbose_proxy_logger.warning( + f"WebSocket passthrough ({endpoint}): Failed to extract model from client setup data: {setup_data}" + ) + else: + verbose_proxy_logger.debug( + f"WebSocket passthrough ({endpoint}): Setup data does not contain model field: {setup_data}" + ) + else: + verbose_proxy_logger.debug( + f"WebSocket passthrough ({endpoint}): Client message does not contain setup data" + ) + except (json.JSONDecodeError, KeyError, TypeError) as e: + verbose_proxy_logger.debug( + f"WebSocket passthrough ({endpoint}): Client message is not a valid setup message: {e}" + ) + pass # Not a JSON message or doesn't contain setup data + + await upstream_ws.send(text_data) + elif bytes_data is not None: + await upstream_ws.send(bytes_data) + except asyncio.CancelledError: + raise + except Exception: + verbose_proxy_logger.exception( + f"WebSocket passthrough ({endpoint}): error forwarding client message" + ) + await upstream_ws.close() + + async def forward_upstream_to_client() -> None: + """Forward messages from upstream to client WebSocket""" + try: + # Wait for the first response from upstream + raw_response = await upstream_ws.recv(decode=False) + # Ensure raw_response is bytes before decoding + if isinstance(raw_response, str): + raw_response = raw_response.encode("ascii") + setup_response = json.loads(raw_response.decode("ascii")) + verbose_proxy_logger.debug(f"Setup response: {setup_response}") + + # Extract model and provider from setup response for Vertex AI Live + if endpoint and "/vertex_ai/live" in endpoint: + verbose_proxy_logger.debug( + f"WebSocket passthrough ({endpoint}): Processing server setup response for model extraction" + ) + extracted_model = _extract_model_from_vertex_ai_setup( + setup_response + ) + if extracted_model: + kwargs["model"] = extracted_model + kwargs["custom_llm_provider"] = "vertex_ai_language_models" + # Update logging object with correct model + logging_obj.model = extracted_model + logging_obj.model_call_details["model"] = extracted_model + logging_obj.model_call_details["custom_llm_provider"] = ( + "vertex_ai_language_models" + ) + verbose_proxy_logger.debug( + f"WebSocket passthrough ({endpoint}): Successfully extracted model '{extracted_model}' and set provider to 'vertex_ai' from server setup response" + ) + else: + verbose_proxy_logger.warning( + f"WebSocket passthrough ({endpoint}): Failed to extract model from server setup response: {setup_response}" + ) + else: + verbose_proxy_logger.debug( + f"WebSocket passthrough ({endpoint}): Not a Vertex AI Live endpoint, skipping model extraction" + ) + + # Send the setup response to the client + await websocket.send_text(json.dumps(setup_response)) + + # Now continuously forward messages from upstream to client + async for upstream_message in upstream_ws: + if isinstance(upstream_message, bytes): + await websocket.send_bytes(upstream_message) + # Parse and collect for cost tracking + try: + message_data = json.loads(upstream_message.decode()) + websocket_messages.append(message_data) + except (json.JSONDecodeError, UnicodeDecodeError): + pass + else: + await websocket.send_text(upstream_message) + # Parse and collect for cost tracking + try: + message_data = json.loads(upstream_message) + websocket_messages.append(message_data) + except json.JSONDecodeError: + pass + + except (ConnectionClosedOK, ConnectionClosedError) as e: + verbose_proxy_logger.debug( + f"Upstream WebSocket connection closed: {e}" + ) + pass + except asyncio.CancelledError: + verbose_proxy_logger.debug( + "asyncio.CancelledError in forward_upstream_to_client" + ) + raise + except Exception as e: + verbose_proxy_logger.debug( + f"Exception in forward_upstream_to_client: {e}" + ) + verbose_proxy_logger.exception( + f"WebSocket passthrough ({endpoint}): error forwarding upstream message" + ) + raise + + # Create tasks for bidirectional message forwarding + tasks = [ + asyncio.create_task(forward_client_to_upstream()), + asyncio.create_task(forward_upstream_to_client()), + ] + + done, pending = await asyncio.wait( + tasks, return_when=asyncio.FIRST_COMPLETED + ) + + # Cancel remaining tasks + for task in pending: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + # Check for exceptions in completed tasks + for task in done: + exception = task.exception() + if exception is not None: + raise exception + + end_time = datetime.now() + + # Update passthrough logging payload with response data + passthrough_logging_payload["response_body"] = websocket_messages # type: ignore + passthrough_logging_payload["end_time"] = end_time # type: ignore + + # Remove logging_obj from kwargs to avoid duplicate keyword argument + success_kwargs = kwargs.copy() + success_kwargs.pop("logging_obj", None) + + # # Add user authentication context for database logging + # if user_api_key_dict: + # success_kwargs.setdefault('litellm_params', {}) + # success_kwargs['litellm_params'].update({ + # 'proxy_server_request': { + # 'body': { + # 'user': user_api_key_dict.user_id, + # 'team_id': user_api_key_dict.team_id, + # 'end_user_id': user_api_key_dict.end_user_id, + # } + # } + # }) + # # Also add the user_api_key for direct access + # success_kwargs['user_api_key'] = user_api_key_dict.api_key + + # Create a dummy httpx.Response for WebSocket connections + class MockWebSocketResponse: + def __init__(self, target_url: str): + self.status_code = 200 + self.text = "WebSocket connection successful" + self.headers: dict[str, str] = {} + self.request = MockWebSocketRequest(target_url) + + class MockWebSocketRequest: + def __init__(self, target_url: str): + self.method = "WEBSOCKET" + self.url = target_url + + mock_response = MockWebSocketResponse(target) + + # Use the same success handler as HTTP passthrough endpoints + asyncio.create_task( + pass_through_endpoint_logging.pass_through_async_success_handler( + httpx_response=mock_response, # type: ignore + response_body=websocket_messages, # type: ignore + url_route=endpoint or "", + result="websocket_connection_successful", + start_time=start_time, + end_time=end_time, + logging_obj=logging_obj, + cache_hit=False, + request_body={}, + **success_kwargs, + ) + ) + + # Call the proxy logging success hook + if proxy_logging_obj: + await proxy_logging_obj.post_call_success_hook( + data={}, + user_api_key_dict=user_api_key_dict, + response={"status": "websocket_connection_successful"}, # type: ignore + ) + + except InvalidStatus as exc: + verbose_proxy_logger.exception( + f"WebSocket passthrough ({endpoint}): upstream rejected WebSocket connection" + ) + + # Prepare request payload for logging + request_payload = {} + if kwargs: + for key, value in kwargs.items(): + request_payload[key] = value + if logging_obj is not None: + request_payload["litellm_logging_obj"] = logging_obj + + # Log the connection failure using the same pattern as HTTP + await proxy_logging_obj.post_call_failure_hook( + user_api_key_dict=user_api_key_dict, + original_exception=exc, + request_data=request_payload, + traceback_str=traceback.format_exc( + limit=MAXIMUM_TRACEBACK_LINES_TO_LOG, + ), + ) + + if websocket.client_state != WebSocketState.DISCONNECTED: + await websocket.close( + code=getattr(exc, "status_code", 1011), + reason="Upstream connection rejected", + ) + except Exception as e: + verbose_proxy_logger.exception( + f"WebSocket passthrough ({endpoint}): unexpected error while proxying WebSocket" + ) + + # Prepare request payload for logging + request_payload = {} + if kwargs: + for key, value in kwargs.items(): + request_payload[key] = value + if logging_obj is not None: + request_payload["litellm_logging_obj"] = logging_obj + + # Log the unexpected error using the same pattern as HTTP + await proxy_logging_obj.post_call_failure_hook( + user_api_key_dict=user_api_key_dict, + original_exception=e, + request_data=request_payload, + traceback_str=traceback.format_exc( + limit=MAXIMUM_TRACEBACK_LINES_TO_LOG, + ), + ) + + if websocket.client_state != WebSocketState.DISCONNECTED: + await websocket.close(code=1011, reason="WebSocket passthrough error") + finally: + if websocket.client_state != WebSocketState.DISCONNECTED: + await websocket.close() + + +def _is_streaming_response(response: httpx.Response) -> bool: + _content_type = response.headers.get("content-type") + if _content_type is not None and "text/event-stream" in _content_type: + return True + return False + + +def _extract_model_from_vertex_ai_setup(setup_response: dict) -> Optional[str]: + """ + Extract the model name from Vertex AI Live setup response. + + The setup response can contain a model field in two formats: + 1. Direct: {"model": "projects/.../models/gemini-2.0-flash-live-preview-04-09"} + 2. Nested: {"setup": {"model": "projects/.../models/gemini-2.0-flash-live-preview-04-09"}} + + We extract just the model name: "gemini-2.0-flash-live-preview-04-09" + """ + try: + # Handle both direct model field and nested setup.model field + model_path = None + if isinstance(setup_response, dict): + if "model" in setup_response: + model_path = setup_response["model"] + elif ( + "setup" in setup_response + and isinstance(setup_response["setup"], dict) + and "model" in setup_response["setup"] + ): + model_path = setup_response["setup"]["model"] + + if isinstance(model_path, str) and "/models/" in model_path: + # Extract the model name after the last "/models/" + model_name = model_path.split("/models/")[-1] + return model_name + except Exception as e: + verbose_proxy_logger.debug(f"Error extracting model from setup response: {e}") + return None + + +class SafeRouteAdder: + """ + Wrapper class for adding routes to FastAPI app. + Only adds routes if they don't already exist on the app. + """ + + @staticmethod + def _is_path_registered(app: FastAPI, path: str, methods: List[str]) -> bool: + """ + Check if a path with any of the specified methods is already registered on the app. + + Args: + app: The FastAPI application instance + path: The path to check (e.g., "/v1/chat/completions") + methods: List of HTTP methods to check (e.g., ["GET", "POST"]) + + Returns: + True if the path is already registered with any of the methods, False otherwise + """ + for route in app.routes: + # Use getattr to safely access route attributes + route_path = getattr(route, "path", None) + route_methods = getattr(route, "methods", None) + + if route_path == path and route_methods is not None: + # Check if any of the methods overlap + if any(method in route_methods for method in methods): + return True + return False + + @staticmethod + def add_api_route_if_not_exists( + app: FastAPI, + path: str, + endpoint: Any, + methods: List[str], + dependencies: Optional[List] = None, + ) -> bool: + """ + Add an API route to the app only if it doesn't already exist. + + Args: + app: The FastAPI application instance + path: The path for the route + endpoint: The endpoint function/callable + methods: List of HTTP methods + dependencies: Optional list of dependencies + + Returns: + True if route was added, False if it already existed + """ + if SafeRouteAdder._is_path_registered(app=app, path=path, methods=methods): + verbose_proxy_logger.debug( + "Skipping route registration - path %s with methods %s already registered on app", + path, + methods, + ) + return False + + app.add_api_route( + path=path, + endpoint=endpoint, + methods=methods, + dependencies=dependencies, + ) + verbose_proxy_logger.debug( + "Successfully added route: %s with methods %s", + path, + methods, + ) + return True + + +class InitPassThroughEndpointHelpers: + @staticmethod + def add_exact_path_route( + app: FastAPI, + path: str, + target: str, + custom_headers: Optional[dict], + forward_headers: Optional[bool], + merge_query_params: Optional[bool], + dependencies: Optional[List], + cost_per_request: Optional[float], + endpoint_id: str, + guardrails: Optional[dict] = None, + methods: Optional[List[str]] = None, + default_query_params: Optional[dict] = None, + ): + """Add exact path route for pass-through endpoint""" + # Default to all methods if none specified (backward compatibility) + if methods is None or len(methods) == 0: + methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] + + # Create route key that includes methods for uniqueness + methods_str = ",".join(sorted(methods)) + route_key = f"{endpoint_id}:exact:{path}:{methods_str}" + + # Check if this exact route is already registered + if route_key in _registered_pass_through_routes: + verbose_proxy_logger.debug( + "Updating duplicate exact pass through endpoint: %s with methods %s (already registered)", + path, + methods, + ) + + verbose_proxy_logger.debug( + "adding exact pass through endpoint: %s, methods: %s, dependencies: %s", + path, + methods, + dependencies, + ) + + # Use SafeRouteAdder to only add route if it doesn't exist on the app + SafeRouteAdder.add_api_route_if_not_exists( + app=app, + path=path, + endpoint=create_pass_through_route( # type: ignore + path, + target, + custom_headers, + forward_headers, + merge_query_params, + dependencies, + cost_per_request=cost_per_request, + default_query_params=default_query_params, + guardrails=guardrails, + ), + methods=methods, + dependencies=dependencies, + ) + + # Always register/update the route metadata (headers, target) even if FastAPI route exists + _registered_pass_through_routes[route_key] = { + "endpoint_id": endpoint_id, + "path": path, + "type": "exact", + "methods": methods, + "passthrough_params": { + "target": target, + "custom_headers": custom_headers, + "forward_headers": forward_headers, + "merge_query_params": merge_query_params, + "default_query_params": default_query_params, + "dependencies": dependencies, + "cost_per_request": cost_per_request, + "guardrails": guardrails, + }, + } + + @staticmethod + def add_subpath_route( + app: FastAPI, + path: str, + target: str, + custom_headers: Optional[dict], + forward_headers: Optional[bool], + merge_query_params: Optional[bool], + dependencies: Optional[List], + cost_per_request: Optional[float], + endpoint_id: str, + guardrails: Optional[dict] = None, + methods: Optional[List[str]] = None, + default_query_params: Optional[dict] = None, + ): + """Add wildcard route for sub-paths""" + # Default to all methods if none specified (backward compatibility) + if methods is None or len(methods) == 0: + methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] + + wildcard_path = f"{path}/{{subpath:path}}" + methods_str = ",".join(sorted(methods)) + route_key = f"{endpoint_id}:subpath:{path}:{methods_str}" + + # Check if this subpath route is already registered + if route_key in _registered_pass_through_routes: + verbose_proxy_logger.debug( + "Updating duplicate wildcard pass through endpoint: %s with methods %s (already registered)", + wildcard_path, + methods, + ) + + verbose_proxy_logger.debug( + "adding wildcard pass through endpoint: %s, methods: %s, dependencies: %s", + wildcard_path, + methods, + dependencies, + ) + + # Use SafeRouteAdder to only add route if it doesn't exist on the app + SafeRouteAdder.add_api_route_if_not_exists( + app=app, + path=wildcard_path, + endpoint=create_pass_through_route( # type: ignore + path, + target, + custom_headers, + forward_headers, + merge_query_params, + dependencies, + include_subpath=True, + cost_per_request=cost_per_request, + default_query_params=default_query_params, + guardrails=guardrails, + ), + methods=methods, + dependencies=dependencies, + ) + + # Register the route to prevent duplicates only if it was added + _registered_pass_through_routes[route_key] = { + "endpoint_id": endpoint_id, + "path": path, + "type": "subpath", + "methods": methods, + "passthrough_params": { + "target": target, + "custom_headers": custom_headers, + "forward_headers": forward_headers, + "merge_query_params": merge_query_params, + "default_query_params": default_query_params, + "dependencies": dependencies, + "cost_per_request": cost_per_request, + "guardrails": guardrails, + }, + } + + @staticmethod + def remove_endpoint_routes(endpoint_id: str): + """Remove all routes for a specific endpoint ID from the registry + and clean up corresponding entries from LiteLLMRoutes.openai_routes.""" + keys_to_remove = [ + key + for key, value in _registered_pass_through_routes.items() + if value["endpoint_id"] == endpoint_id + ] + for key in keys_to_remove: + route_info = _registered_pass_through_routes[key] + path = route_info.get("path") + if isinstance(path, str): + openai_routes = LiteLLMRoutes.openai_routes.value + if path in openai_routes: + openai_routes.remove(path) + if route_info.get("type") == "subpath": + wildcard_path = path.rstrip("/") + "/*" + if wildcard_path in openai_routes: + openai_routes.remove(wildcard_path) + del _registered_pass_through_routes[key] + verbose_proxy_logger.debug( + "Removed pass-through route from registry: %s", key + ) + + @staticmethod + def clear_all_pass_through_routes(): + """Clear all pass-through routes from the registry""" + _registered_pass_through_routes.clear() + + @staticmethod + def get_all_registered_pass_through_routes() -> List[str]: + """Get all registered pass-through endpoints from the registry""" + return list(_registered_pass_through_routes.keys()) + + @staticmethod + def _build_full_path_with_root(path: str) -> str: + """ + Build full path by prepending server root path if needed. + + Args: + path: The relative path to build + + Returns: + Full path with server root prepended (if root is not "/") + """ + root_path = get_server_root_path() + if root_path == "/": + return path + return f"{root_path}{path}" + + @staticmethod + def is_registered_pass_through_route(route: str) -> bool: + """ + Check if route is a registered pass-through endpoint from DB + + Uses the in-memory registry to avoid additional DB queries + Optimized for minimal latency + + Args: + route: The route to check + + Returns: + bool: True if route is a registered pass-through endpoint, False otherwise + """ + ## CHECK IF MAPPED PASS THROUGH ENDPOINT + normalized_route = normalize_route_for_root_path(route) + if normalized_route is not None: + for mapped_route in LiteLLMRoutes.mapped_pass_through_routes.value: + if normalized_route.startswith(mapped_route): + return True + + # Fast path: check if any registered route key contains this path + # Keys are in format: "{endpoint_id}:exact:{path}:{methods}" or "{endpoint_id}:subpath:{path}:{methods}" + # For backward compatibility, also support old format: "{endpoint_id}:exact:{path}" or "{endpoint_id}:subpath:{path}" + # Extract unique paths from keys for quick checking + for key in _registered_pass_through_routes.keys(): + parts = key.split(":", 3) # Split into [endpoint_id, type, path, methods?] + if len(parts) >= 3: + route_type = parts[1] + registered_path = ( + InitPassThroughEndpointHelpers._build_full_path_with_root(parts[2]) + ) + if route_type == "exact" and route == registered_path: + return True + elif route_type == "subpath": + if route == registered_path or route.startswith( + registered_path + "/" + ): + return True + + return False + + @staticmethod + def get_registered_pass_through_route( + route: str, method: Optional[str] = None + ) -> Optional[Dict[str, Any]]: + """Get passthrough params for a given route and optionally filter by HTTP method""" + for key in _registered_pass_through_routes.keys(): + parts = key.split(":", 3) # Split into [endpoint_id, type, path, methods?] + if len(parts) >= 3: + route_type = parts[1] + registered_path = ( + InitPassThroughEndpointHelpers._build_full_path_with_root(parts[2]) + ) + + # Get the methods for this route + route_methods = _registered_pass_through_routes[key].get("methods", []) + + # Check if path matches + path_matches = False + if route_type == "exact" and route == registered_path: + path_matches = True + elif route_type == "subpath": + if route == registered_path or route.startswith( + registered_path + "/" + ): + path_matches = True + + # If path matches and method filter is provided, check if method is allowed + if path_matches: + if method is None or not route_methods or method in route_methods: + return _registered_pass_through_routes[key] + + return None + + +def _get_combined_pass_through_endpoints( + pass_through_endpoints: Union[List[Dict], List[PassThroughGenericEndpoint]], + config_pass_through_endpoints: List[Dict], +): + """Get combined pass-through endpoints from db + config""" + return pass_through_endpoints + config_pass_through_endpoints + + +async def _register_pass_through_endpoint( + endpoint: Union[Dict[str, Any], PassThroughGenericEndpoint], + app: FastAPI, + premium_user: bool, + visited_endpoints: set[str], +) -> None: + endpoint_data: Dict[str, Any] + if isinstance(endpoint, PassThroughGenericEndpoint): + endpoint_data = endpoint.model_dump() + else: + endpoint_data = endpoint + + if endpoint_data.get("id") is None: + endpoint_data["id"] = str(uuid.uuid4()) + endpoint_id = cast(str, endpoint_data["id"]) + + target = endpoint_data.get("target") + path = endpoint_data.get("path") + if path is None: + raise ValueError("Path is required for pass-through endpoint") + + custom_headers = await set_env_variables_in_header( + custom_headers=endpoint_data.get("headers") + ) + forward_headers = endpoint_data.get("forward_headers") + merge_query_params = endpoint_data.get("merge_query_params") + default_query_params = endpoint_data.get("default_query_params") + auth = endpoint_data.get("auth") + dependencies = None + + if auth is not None and str(auth).lower() == "true": + if premium_user is not True: + raise ValueError( + "Error Setting Authentication on Pass Through Endpoint: {}".format( + CommonProxyErrors.not_premium_user.value + ) + ) + dependencies = [Depends(user_api_key_auth)] + if path not in LiteLLMRoutes.openai_routes.value: + LiteLLMRoutes.openai_routes.value.append(path) + + if target is None: + return + + guardrails = endpoint_data.get("guardrails") + methods = endpoint_data.get("methods") + cost_per_request = endpoint_data.get("cost_per_request") + + verbose_proxy_logger.debug( + "Initializing pass through endpoint: %s (ID: %s)", path, endpoint_id + ) + InitPassThroughEndpointHelpers.add_exact_path_route( + app=app, + path=path, + target=target, + custom_headers=custom_headers, + forward_headers=forward_headers, + merge_query_params=merge_query_params, + dependencies=dependencies, + cost_per_request=cost_per_request, + endpoint_id=endpoint_id, + guardrails=guardrails, + methods=methods, + default_query_params=default_query_params, + ) + + methods_for_key = methods if methods else ["GET", "POST", "PUT", "DELETE", "PATCH"] + methods_str = ",".join(sorted(methods_for_key)) + visited_endpoints.add(f"{endpoint_id}:exact:{path}:{methods_str}") + + if endpoint_data.get("include_subpath", False) is True: + if auth is not None and str(auth).lower() == "true": + wildcard_path = path.rstrip("/") + "/*" + if wildcard_path not in LiteLLMRoutes.openai_routes.value: + LiteLLMRoutes.openai_routes.value.append(wildcard_path) + InitPassThroughEndpointHelpers.add_subpath_route( + app=app, + path=path, + target=target, + custom_headers=custom_headers, + forward_headers=forward_headers, + merge_query_params=merge_query_params, + dependencies=dependencies, + cost_per_request=cost_per_request, + endpoint_id=endpoint_id, + guardrails=guardrails, + methods=methods, + default_query_params=default_query_params, + ) + visited_endpoints.add(f"{endpoint_id}:subpath:{path}:{methods_str}") + + verbose_proxy_logger.debug( + "Added new pass through endpoint: %s (ID: %s)", path, endpoint_id + ) + + +async def initialize_pass_through_endpoints( + pass_through_endpoints: Union[List[Dict], List[PassThroughGenericEndpoint]], +): + """ + 1. Create a global list of pass-through endpoints (db + config) + 2. Clear all existing pass-through endpoints from the FastAPI app routes + 3. Add new endpoints to the in-memory registry + + Initialize a list of pass-through endpoints by adding them to the FastAPI app routes + + Args: + pass_through_endpoints: List of pass-through endpoints to initialize + + Returns: + None + """ + verbose_proxy_logger.debug("initializing pass through endpoints") + from litellm.proxy.proxy_server import ( + app, + config_passthrough_endpoints, + premium_user, + ) + + ## get combined pass-through endpoints from db + config + combined_pass_through_endpoints: List[Union[Dict, PassThroughGenericEndpoint]] + + if config_passthrough_endpoints is not None: + combined_pass_through_endpoints = _get_combined_pass_through_endpoints( # type: ignore + pass_through_endpoints, config_passthrough_endpoints + ) + else: + combined_pass_through_endpoints = pass_through_endpoints # type: ignore + + ## clear all existing pass-through endpoints from the FastAPI app routes + # InitPassThroughEndpointHelpers.clear_all_pass_through_routes() + + # get a list of all registered pass-through endpoints + # mark the ones that are visited in the list + # remove the ones that are not visited from the list + registered_pass_through_endpoints = ( + InitPassThroughEndpointHelpers.get_all_registered_pass_through_routes() + ) + + visited_endpoints: set[str] = set() + + for endpoint in combined_pass_through_endpoints: + await _register_pass_through_endpoint( + endpoint=endpoint, + app=app, + premium_user=premium_user, + visited_endpoints=visited_endpoints, + ) + + # remove the ones that are not visited from the list + for endpoint_key in registered_pass_through_endpoints: + if endpoint_key not in visited_endpoints: + InitPassThroughEndpointHelpers.remove_endpoint_routes(endpoint_key) + + +def _get_pass_through_endpoints_from_config() -> List[PassThroughGenericEndpoint]: + """ + Get pass-through endpoints defined in the config file. + These are read-only and cannot be edited via the UI. + Malformed endpoints are logged and skipped; they do not crash the function. + """ + from pydantic import ValidationError + + from litellm.proxy.proxy_server import config_passthrough_endpoints + + if config_passthrough_endpoints is None or len(config_passthrough_endpoints) == 0: + return [] + + returned_endpoints: List[PassThroughGenericEndpoint] = [] + for endpoint in config_passthrough_endpoints: + try: + if isinstance(endpoint, dict): + endpoint_dict = dict(endpoint) + endpoint_dict["is_from_config"] = True + returned_endpoints.append(PassThroughGenericEndpoint(**endpoint_dict)) + elif isinstance(endpoint, PassThroughGenericEndpoint): + # Create a copy with is_from_config=True + endpoint_dict = endpoint.model_dump() + endpoint_dict["is_from_config"] = True + returned_endpoints.append(PassThroughGenericEndpoint(**endpoint_dict)) + except ValidationError as e: + verbose_proxy_logger.warning( + "Skipping malformed pass-through endpoint from config: %s", + e, + exc_info=False, + ) + + return returned_endpoints + + +async def _get_pass_through_endpoints_from_db( + endpoint_id: Optional[str] = None, + user_api_key_dict: Optional[UserAPIKeyAuth] = None, +) -> List[PassThroughGenericEndpoint]: + from litellm.proxy._types import LitellmUserRoles + from litellm.proxy.proxy_server import get_config_general_settings + + try: + if user_api_key_dict is None: + user_api_key_dict = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN) + response: ConfigFieldInfo = await get_config_general_settings( + field_name="pass_through_endpoints", user_api_key_dict=user_api_key_dict + ) + except Exception: + return [] + + pass_through_endpoint_data: Optional[List] = response.field_value + if pass_through_endpoint_data is None: + return [] + + returned_endpoints: List[PassThroughGenericEndpoint] = [] + if endpoint_id is None: + # Return all endpoints from DB, mark as not from config + for endpoint in pass_through_endpoint_data: + if isinstance(endpoint, dict): + endpoint_dict = dict(endpoint) + endpoint_dict["is_from_config"] = False + returned_endpoints.append(PassThroughGenericEndpoint(**endpoint_dict)) + elif isinstance(endpoint, PassThroughGenericEndpoint): + endpoint_dict = endpoint.model_dump() + endpoint_dict["is_from_config"] = False + returned_endpoints.append(PassThroughGenericEndpoint(**endpoint_dict)) + else: + # Find specific endpoint by ID + found_endpoint = _find_endpoint_by_id(pass_through_endpoint_data, endpoint_id) + if found_endpoint is not None: + endpoint_dict = ( + found_endpoint.model_dump() + if isinstance(found_endpoint, PassThroughGenericEndpoint) + else dict(found_endpoint) + ) + endpoint_dict["is_from_config"] = False + returned_endpoints.append(PassThroughGenericEndpoint(**endpoint_dict)) + + return returned_endpoints + + +async def _filter_endpoints_by_team_allowed_routes( + team_id: str, + pass_through_endpoints: List[PassThroughGenericEndpoint], + prisma_client, +) -> List[PassThroughGenericEndpoint]: + """ + Filter pass-through endpoints based on team's allowed_passthrough_routes metadata. + + Args: + team_id: The team ID to check permissions for + pass_through_endpoints: List of endpoints to filter + prisma_client: Database client + + Returns: + Filtered list of endpoints based on team permissions + + Raises: + HTTPException: If team is not found + """ + # retrieve team from db + team = await prisma_client.db.litellm_teamtable.find_unique( + where={"team_id": team_id}, + ) + if team is None: + raise HTTPException( + status_code=404, + detail={"error": "Team not found"}, + ) + + # retrieve team metadata + team_metadata = team.metadata + if ( + team_metadata is not None + and team_metadata.get("allowed_passthrough_routes") is not None + ): + ## FILTER pass_through_endpoints by allowed_passthrough_routes + pass_through_endpoints = [ + endpoint + for endpoint in pass_through_endpoints + if endpoint.path in team_metadata.get("allowed_passthrough_routes") + ] + + return pass_through_endpoints + + +@router.get( + "/config/pass_through_endpoint", + dependencies=[Depends(user_api_key_auth)], + response_model=PassThroughEndpointResponse, +) +@router.get( + "/config/pass_through_endpoint/team/{team_id}", + dependencies=[Depends(user_api_key_auth)], + response_model=PassThroughEndpointResponse, +) +async def get_pass_through_endpoints( + endpoint_id: Optional[str] = None, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), + team_id: Optional[str] = None, +): + """ + GET configured pass through endpoint. + + If no endpoint_id given, return all configured endpoints. + """ ## Get existing pass-through endpoint field value + from litellm.proxy._types import CommonProxyErrors + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + raise HTTPException( + status_code=500, + detail={"error": CommonProxyErrors.db_not_connected_error.value}, + ) + + # Get endpoints from DB (editable via UI) + db_endpoints = await _get_pass_through_endpoints_from_db( + endpoint_id=endpoint_id, user_api_key_dict=user_api_key_dict + ) + + # Get endpoints from config file (read-only, not editable via UI) + config_endpoints = _get_pass_through_endpoints_from_config() + + # Merge: config endpoints not in DB + all DB endpoints (DB overrides config for same path) + db_paths = {ep.path for ep in db_endpoints} + config_only_endpoints = [ep for ep in config_endpoints if ep.path not in db_paths] + if endpoint_id is not None: + # When filtering by endpoint_id, only return if found in DB (config endpoints use generated IDs) + pass_through_endpoints = db_endpoints + else: + pass_through_endpoints = config_only_endpoints + db_endpoints + + if team_id is not None: + pass_through_endpoints = await _filter_endpoints_by_team_allowed_routes( + team_id=team_id, + pass_through_endpoints=pass_through_endpoints, + prisma_client=prisma_client, + ) + + return PassThroughEndpointResponse(endpoints=pass_through_endpoints) + + +@router.post( + "/config/pass_through_endpoint/{endpoint_id}", + dependencies=[Depends(user_api_key_auth)], +) +async def update_pass_through_endpoints( + endpoint_id: str, + data: PassThroughGenericEndpoint, + request: Request, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + Update a pass-through endpoint by ID. + """ + from litellm.proxy.proxy_server import ( + get_config_general_settings, + update_config_general_settings, + ) + + ## Get existing pass-through endpoint field value + try: + response: ConfigFieldInfo = await get_config_general_settings( + field_name="pass_through_endpoints", user_api_key_dict=user_api_key_dict + ) + except Exception: + raise HTTPException( + status_code=404, + detail={"error": "No pass-through endpoints found"}, + ) + + pass_through_endpoint_data: Optional[List] = response.field_value + if pass_through_endpoint_data is None: + raise HTTPException( + status_code=404, + detail={"error": "No pass-through endpoints found"}, + ) + + # Find the endpoint to update + found_endpoint = _find_endpoint_by_id(pass_through_endpoint_data, endpoint_id) + + if found_endpoint is None: + raise HTTPException( + status_code=404, + detail={"error": f"Endpoint with ID '{endpoint_id}' not found"}, + ) + + # Find the index for updating the list + endpoint_index = None + for idx, endpoint in enumerate(pass_through_endpoint_data): + _endpoint = ( + PassThroughGenericEndpoint(**endpoint) + if isinstance(endpoint, dict) + else endpoint + ) + if _endpoint.id == endpoint_id: + endpoint_index = idx + break + + if endpoint_index is None: + raise HTTPException( + status_code=404, + detail={ + "error": f"Could not find index for endpoint with ID '{endpoint_id}'" + }, + ) + + # Get the update data as dict, excluding None values for partial updates + # Exclude is_from_config as it's a response-only field (computed at read time) + update_data = data.model_dump(exclude_none=True, exclude={"is_from_config"}) + + # Start with existing endpoint data + endpoint_dict = found_endpoint.model_dump() + + # Update with new data (only non-None values) + endpoint_dict.update(update_data) + + # Preserve existing ID if not provided in update and endpoint has ID + if "id" not in update_data and found_endpoint.id is not None: + endpoint_dict["id"] = found_endpoint.id + + # Remove is_from_config before saving - it's a response-only field (computed at read time) + endpoint_dict.pop("is_from_config", None) + + # Create updated endpoint object + updated_endpoint = PassThroughGenericEndpoint(**endpoint_dict) + + # Update the list + pass_through_endpoint_data[endpoint_index] = endpoint_dict + + # Remove old routes from registry before they get re-registered + InitPassThroughEndpointHelpers.remove_endpoint_routes(endpoint_id) + + ## Update db + updated_data = ConfigFieldUpdate( + field_name="pass_through_endpoints", + field_value=pass_through_endpoint_data, + config_type="general_settings", + ) + + await update_config_general_settings( + data=updated_data, user_api_key_dict=user_api_key_dict + ) + + # Re-register the route with updated headers + _custom_headers: Optional[dict] = updated_endpoint.headers or {} + _custom_headers = await set_env_variables_in_header(custom_headers=_custom_headers) + + if updated_endpoint.include_subpath: + InitPassThroughEndpointHelpers.add_subpath_route( + app=request.app, + path=updated_endpoint.path, + target=updated_endpoint.target, + custom_headers=_custom_headers, + forward_headers=None, # Defaults not available in model? assuming None logic handles it + merge_query_params=None, + dependencies=None, + cost_per_request=updated_endpoint.cost_per_request, + endpoint_id=updated_endpoint.id or endpoint_id or "", + guardrails=getattr(updated_endpoint, "guardrails", None), + methods=updated_endpoint.methods, + default_query_params=updated_endpoint.default_query_params, + ) + else: + InitPassThroughEndpointHelpers.add_exact_path_route( + app=request.app, + path=updated_endpoint.path, + target=updated_endpoint.target, + custom_headers=_custom_headers, + forward_headers=None, + merge_query_params=None, + dependencies=None, + cost_per_request=updated_endpoint.cost_per_request, + endpoint_id=updated_endpoint.id or endpoint_id or "", + guardrails=getattr(updated_endpoint, "guardrails", None), + methods=updated_endpoint.methods, + default_query_params=updated_endpoint.default_query_params, + ) + + return PassThroughEndpointResponse( + endpoints=[updated_endpoint] if updated_endpoint else [] + ) + + +@router.post( + "/config/pass_through_endpoint", + dependencies=[Depends(user_api_key_auth)], +) +async def create_pass_through_endpoints( + data: PassThroughGenericEndpoint, + request: Request, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + Create new pass-through endpoint + """ + from litellm._uuid import uuid + from litellm.proxy.proxy_server import ( + get_config_general_settings, + update_config_general_settings, + ) + + ## Get existing pass-through endpoint field value + + try: + response: ConfigFieldInfo = await get_config_general_settings( + field_name="pass_through_endpoints", user_api_key_dict=user_api_key_dict + ) + except Exception: + response = ConfigFieldInfo( + field_name="pass_through_endpoints", field_value=None + ) + + ## Auto-generate ID if not provided + # Exclude is_from_config as it's a response-only field (computed at read time) + data_dict = data.model_dump(exclude={"is_from_config"}) + if data_dict.get("id") is None: + data_dict["id"] = str(uuid.uuid4()) + + if response.field_value is None: + response.field_value = [data_dict] + elif isinstance(response.field_value, List): + response.field_value.append(data_dict) + + ## Update db + updated_data = ConfigFieldUpdate( + field_name="pass_through_endpoints", + field_value=response.field_value, + config_type="general_settings", + ) + await update_config_general_settings( + data=updated_data, user_api_key_dict=user_api_key_dict + ) + + # Return the created endpoint with the generated ID + created_endpoint = PassThroughGenericEndpoint(**data_dict) + + # Register the new route + _custom_headers: Optional[dict] = created_endpoint.headers or {} + _custom_headers = await set_env_variables_in_header(custom_headers=_custom_headers) + + if created_endpoint.include_subpath: + InitPassThroughEndpointHelpers.add_subpath_route( + app=request.app, + path=created_endpoint.path, + target=created_endpoint.target, + custom_headers=_custom_headers, + forward_headers=None, + merge_query_params=None, + dependencies=None, + cost_per_request=created_endpoint.cost_per_request, + endpoint_id=created_endpoint.id or "", + guardrails=getattr(created_endpoint, "guardrails", None), + methods=created_endpoint.methods, + default_query_params=created_endpoint.default_query_params, + ) + else: + InitPassThroughEndpointHelpers.add_exact_path_route( + app=request.app, + path=created_endpoint.path, + target=created_endpoint.target, + custom_headers=_custom_headers, + forward_headers=None, + merge_query_params=None, + dependencies=None, + cost_per_request=created_endpoint.cost_per_request, + endpoint_id=created_endpoint.id or "", + guardrails=getattr(created_endpoint, "guardrails", None), + methods=created_endpoint.methods, + default_query_params=created_endpoint.default_query_params, + ) + + return PassThroughEndpointResponse(endpoints=[created_endpoint]) + + +@router.delete( + "/config/pass_through_endpoint", + dependencies=[Depends(user_api_key_auth)], + response_model=PassThroughEndpointResponse, +) +async def delete_pass_through_endpoints( + endpoint_id: str, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + Delete a pass-through endpoint by ID. + + Returns - the deleted endpoint + """ + from litellm.proxy.proxy_server import ( + get_config_general_settings, + update_config_general_settings, + ) + + ## Get existing pass-through endpoint field value + + try: + response: ConfigFieldInfo = await get_config_general_settings( + field_name="pass_through_endpoints", user_api_key_dict=user_api_key_dict + ) + except Exception: + response = ConfigFieldInfo( + field_name="pass_through_endpoints", field_value=None + ) + + ## Update field by removing endpoint + pass_through_endpoint_data: Optional[List] = response.field_value + if response.field_value is None or pass_through_endpoint_data is None: + raise HTTPException( + status_code=400, + detail={"error": "There are no pass-through endpoints setup."}, + ) + + # Find the endpoint to delete + found_endpoint = _find_endpoint_by_id(pass_through_endpoint_data, endpoint_id) + + if found_endpoint is None: + raise HTTPException( + status_code=400, + detail={ + "error": "Endpoint with ID '{}' was not found in pass-through endpoint list.".format( + endpoint_id + ) + }, + ) + + # Find the index for deleting from the list + endpoint_index = None + for idx, endpoint in enumerate(pass_through_endpoint_data): + _endpoint = ( + PassThroughGenericEndpoint(**endpoint) + if isinstance(endpoint, dict) + else endpoint + ) + if _endpoint.id == endpoint_id: + endpoint_index = idx + break + + if endpoint_index is None: + raise HTTPException( + status_code=400, + detail={ + "error": f"Could not find index for endpoint with ID '{endpoint_id}'" + }, + ) + + # Remove the endpoint + pass_through_endpoint_data.pop(endpoint_index) + response_obj = found_endpoint + + # Remove routes from registry + InitPassThroughEndpointHelpers.remove_endpoint_routes(endpoint_id) + + ## Update db + updated_data = ConfigFieldUpdate( + field_name="pass_through_endpoints", + field_value=pass_through_endpoint_data, + config_type="general_settings", + ) + await update_config_general_settings( + data=updated_data, user_api_key_dict=user_api_key_dict + ) + + return PassThroughEndpointResponse(endpoints=[response_obj]) + + +def _find_endpoint_by_id( + endpoints_data: List, + endpoint_id: str, +) -> Optional[PassThroughGenericEndpoint]: + """ + Find an endpoint by ID. + + Args: + endpoints_data: List of endpoint data (dicts or PassThroughGenericEndpoint objects) + endpoint_id: ID to search for + + Returns: + Found endpoint or None if not found + """ + for endpoint in endpoints_data: + _endpoint: Optional[PassThroughGenericEndpoint] = None + if isinstance(endpoint, dict): + _endpoint = PassThroughGenericEndpoint(**endpoint) + elif isinstance(endpoint, PassThroughGenericEndpoint): + _endpoint = endpoint + + # Only compare IDs to IDs + if _endpoint is not None and _endpoint.id == endpoint_id: + return _endpoint + + return None + + +async def initialize_pass_through_endpoints_in_db(): + """ + Gets all pass-through endpoints from db and initializes them in the proxy server. + """ + pass_through_endpoints = await _get_pass_through_endpoints_from_db() + await initialize_pass_through_endpoints( + pass_through_endpoints=pass_through_endpoints + ) diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py index ea68e8566a0..8c1ebe85d0a 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py @@ -2,6 +2,7 @@ import json import os import sys from io import BytesIO +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import httpx @@ -16,6 +17,7 @@ sys.path.insert( from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( HttpPassThroughEndpointHelpers, + LITELLM_PASS_THROUGH_CUSTOM_BODY_STATE_KEY, pass_through_request, ) from litellm.proxy.pass_through_endpoints.success_handler import ( @@ -193,6 +195,47 @@ async def test_make_multipart_http_request_removes_content_type_header(): assert "content-type" in original_headers +@pytest.mark.asyncio +async def test_non_streaming_http_request_handler_multipart_with_non_empty_parsed_body(): + """ + Regression: pass_through_request injects litellm_logging_obj into _parsed_body before + forwarding. Multipart uploads must still use files=, not json=_parsed_body. + """ + request = MagicMock(spec=Request) + request.method = "POST" + request.headers = Headers( + {"content-type": "multipart/form-data; boundary=------------------------test"} + ) + + file_content = b"test file content" + file = BytesIO(file_content) + upload_headers = Headers({"content-type": "text/plain"}) + upload_file = UploadFile(file=file, filename="test.txt", headers=upload_headers) + upload_file.read = AsyncMock(return_value=file_content) + request.form = AsyncMock(return_value={"file": upload_file}) + + mock_response = MagicMock() + mock_response.status_code = 200 + async_client = MagicMock() + async_client.request = AsyncMock(return_value=mock_response) + + await HttpPassThroughEndpointHelpers.non_streaming_http_request_handler( + request=request, + async_client=async_client, + url=httpx.URL("http://test.com"), + headers={}, + requested_query_params=None, + _parsed_body={"litellm_logging_obj": MagicMock()}, + forward_multipart=True, + ) + + async_client.request.assert_called_once() + call_args = async_client.request.call_args[1] + assert "files" in call_args + assert "json" not in call_args + assert call_args["files"]["file"][0] == "test.txt" + + @pytest.mark.asyncio async def test_pass_through_request_failure_handler(): """ @@ -1571,6 +1614,7 @@ async def test_pass_through_request_query_params_forwarding(): assert call_kwargs["requested_query_params"] == { "api-version": "2025-01-01-preview" } + assert call_kwargs.get("forward_multipart") is False # Verify the target URL is correct assert ( @@ -2090,13 +2134,12 @@ async def test_add_litellm_data_to_request_adds_headers_to_metadata(): @pytest.mark.asyncio async def test_create_pass_through_route_custom_body_url_target(): """ - Test that the URL-based endpoint_func created by create_pass_through_route - accepts a custom_body parameter and forwards it to pass_through_request, - taking precedence over the request-parsed body. + Test that programmatic callers (e.g. Bedrock proxy) can attach a JSON body via + request.state[LITELLM_PASS_THROUGH_CUSTOM_BODY_STATE_KEY]; it is forwarded to + pass_through_request and takes precedence over the request-parsed body. - This verifies the fix for issue #16999 where bedrock_proxy_route passes - custom_body=data to the endpoint function, which previously crashed with: - TypeError: endpoint_func() got an unexpected keyword argument 'custom_body' + We cannot use a `custom_body: dict` route parameter: FastAPI would treat it as + the HTTP body and reject multipart/form-data before the handler runs. """ from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( create_pass_through_route, @@ -2135,6 +2178,7 @@ async def test_create_pass_through_route_custom_body_url_target(): mock_request.url.path = unique_path mock_request.path_params = {} mock_request.query_params = QueryParams({}) + mock_request.state = SimpleNamespace() mock_user_api_key_dict = MagicMock() mock_user_api_key_dict.api_key = "test-key" @@ -2144,13 +2188,14 @@ async def test_create_pass_through_route_custom_body_url_target(): "retrievalQuery": {"text": "What is in the knowledge base?"}, } - # Call endpoint_func with custom_body — this is the call that - # used to crash with TypeError before the fix + setattr( + mock_request.state, LITELLM_PASS_THROUGH_CUSTOM_BODY_STATE_KEY, bedrock_body + ) + await endpoint_func( request=mock_request, fastapi_response=MagicMock(), user_api_key_dict=mock_user_api_key_dict, - custom_body=bedrock_body, ) mock_pass_through.assert_called_once() @@ -2206,11 +2251,12 @@ async def test_create_pass_through_route_no_custom_body_falls_back(): mock_request.url.path = unique_path mock_request.path_params = {} mock_request.query_params = QueryParams({}) + mock_request.state = SimpleNamespace() mock_user_api_key_dict = MagicMock() mock_user_api_key_dict.api_key = "test-key" - # Call without custom_body — should use the request-parsed body + # Call without state body — should use the request-parsed body await endpoint_func( request=mock_request, fastapi_response=MagicMock(), @@ -2232,11 +2278,15 @@ def test_build_full_path_with_root_default(): InitPassThroughEndpointHelpers, ) - with patch("litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path") as mock_get_root: + with patch( + "litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path" + ) as mock_get_root: # Test with default root path mock_get_root.return_value = "/" - result = InitPassThroughEndpointHelpers._build_full_path_with_root("/api/v1/endpoint") + result = InitPassThroughEndpointHelpers._build_full_path_with_root( + "/api/v1/endpoint" + ) assert result == "/api/v1/endpoint" @@ -2248,11 +2298,15 @@ def test_build_full_path_with_root_custom(): InitPassThroughEndpointHelpers, ) - with patch("litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path") as mock_get_root: + with patch( + "litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path" + ) as mock_get_root: # Test with custom root path /proxy mock_get_root.return_value = "/proxy" - result = InitPassThroughEndpointHelpers._build_full_path_with_root("/api/v1/endpoint") + result = InitPassThroughEndpointHelpers._build_full_path_with_root( + "/api/v1/endpoint" + ) assert result == "/proxy/api/v1/endpoint" @@ -2264,7 +2318,9 @@ def test_build_full_path_with_root_nested(): InitPassThroughEndpointHelpers, ) - with patch("litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path") as mock_get_root: + with patch( + "litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path" + ) as mock_get_root: # Test with nested root path /api/v2 mock_get_root.return_value = "/api/v2" @@ -2296,24 +2352,46 @@ def test_is_registered_pass_through_route_with_custom_root(): "headers": {}, } - with patch("litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path") as mock_get_root: + with patch( + "litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path" + ) as mock_get_root: # Test with custom root path /proxy mock_get_root.return_value = "/proxy" # Should match when request route includes the root path - assert InitPassThroughEndpointHelpers.is_registered_pass_through_route("/proxy/api/endpoint") is True + assert ( + InitPassThroughEndpointHelpers.is_registered_pass_through_route( + "/proxy/api/endpoint" + ) + is True + ) # Should not match when request route doesn't include root path - assert InitPassThroughEndpointHelpers.is_registered_pass_through_route("/api/endpoint") is False + assert ( + InitPassThroughEndpointHelpers.is_registered_pass_through_route( + "/api/endpoint" + ) + is False + ) # Test with default root path mock_get_root.return_value = "/" # Should match with default root - assert InitPassThroughEndpointHelpers.is_registered_pass_through_route("/api/endpoint") is True + assert ( + InitPassThroughEndpointHelpers.is_registered_pass_through_route( + "/api/endpoint" + ) + is True + ) # Should not match with root prepended when root is / - assert InitPassThroughEndpointHelpers.is_registered_pass_through_route("/proxy/api/endpoint") is False + assert ( + InitPassThroughEndpointHelpers.is_registered_pass_through_route( + "/proxy/api/endpoint" + ) + is False + ) # Clean up _registered_pass_through_routes.clear() @@ -2345,25 +2423,33 @@ def test_get_registered_pass_through_route_with_custom_root(): route_key = f"{endpoint_id}:exact:{path}" _registered_pass_through_routes[route_key] = target_config - with patch("litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path") as mock_get_root: + with patch( + "litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path" + ) as mock_get_root: # Test with custom root path /litellm mock_get_root.return_value = "/litellm" # Should return config when request route includes root path - result = InitPassThroughEndpointHelpers.get_registered_pass_through_route("/litellm/chat/completions") + result = InitPassThroughEndpointHelpers.get_registered_pass_through_route( + "/litellm/chat/completions" + ) assert result is not None assert result["target"] == "http://api.example.com/v1/chat/completions" assert result["headers"]["Authorization"] == "Bearer token123" # Should return None when route doesn't match - result = InitPassThroughEndpointHelpers.get_registered_pass_through_route("/chat/completions") + result = InitPassThroughEndpointHelpers.get_registered_pass_through_route( + "/chat/completions" + ) assert result is None # Test with default root path mock_get_root.return_value = "/" # Should return config with default root - result = InitPassThroughEndpointHelpers.get_registered_pass_through_route("/chat/completions") + result = InitPassThroughEndpointHelpers.get_registered_pass_through_route( + "/chat/completions" + ) assert result is not None assert result["target"] == "http://api.example.com/v1/chat/completions" @@ -2382,9 +2468,7 @@ def test_mapped_pass_through_routes_with_server_root_path(): InitPassThroughEndpointHelpers, ) - with patch( - "litellm.proxy.utils.get_server_root_path" - ) as mock_get_root: + with patch("litellm.proxy.utils.get_server_root_path") as mock_get_root: mock_get_root.return_value = "/litellm" # prefixed route should match mapped routes like /vertex_ai @@ -2410,7 +2494,6 @@ def test_mapped_pass_through_routes_with_server_root_path(): ) - @pytest.mark.asyncio async def test_multipart_passthrough_preserves_boundary(): """ @@ -2425,7 +2508,9 @@ async def test_multipart_passthrough_preserves_boundary(): mock_response = MagicMock() mock_response.status_code = 200 mock_response.headers = httpx.Headers({"content-type": "application/json"}) - mock_response.aread = AsyncMock(return_value=b'{"filename": "test.txt", "size": 17}') + mock_response.aread = AsyncMock( + return_value=b'{"filename": "test.txt", "size": 17}' + ) mock_response.text = '{"filename": "test.txt", "size": 17}' async def mock_httpx_request(method, url, **kwargs): @@ -2435,7 +2520,9 @@ async def test_multipart_passthrough_preserves_boundary(): # Verify content-type is NOT in headers (httpx will set it with correct boundary) headers = kwargs.get("headers", {}) - assert "content-type" not in headers, "content-type should be removed for multipart" + assert ( + "content-type" not in headers + ), "content-type should be removed for multipart" filename, content, content_type = kwargs["files"]["file"] assert filename == "test.txt" From 4dc416ee749122ca91e3bca095217478663419e7 Mon Sep 17 00:00:00 2001 From: jayden Date: Thu, 9 Apr 2026 20:10:40 -0700 Subject: [PATCH 030/130] fix(proxy): use parameterized query for combined_view token lookup --- litellm/proxy/utils.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index 635204f3362..a62f34764d3 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -2645,7 +2645,7 @@ class PrismaClient: raise e async def _query_first_with_cached_plan_fallback( - self, sql_query: str + self, sql_query: str, *args ) -> Optional[dict]: """ Execute a query with automatic fallback for PostgreSQL cached plan errors. @@ -2664,7 +2664,7 @@ class PrismaClient: Original exception if not a cached plan error """ try: - return await self.db.query_first(query=sql_query) + return await self.db.query_first(sql_query, *args) except Exception as e: error_str = str(e) if "cached plan must not change result type" in error_str: @@ -2679,7 +2679,7 @@ class PrismaClient: "retrying with fresh plan. This may occur during rolling deployments " "when schema changes are applied." ) - return await self.db.query_first(query=sql_query_retry) + return await self.db.query_first(sql_query_retry, *args) else: raise @@ -3016,11 +3016,11 @@ class PrismaClient: LEFT JOIN "LiteLLM_ProjectTable" AS p ON v.project_id = p.project_id LEFT JOIN "LiteLLM_OrganizationTable" AS o ON v.organization_id = o.organization_id LEFT JOIN "LiteLLM_BudgetTable" AS b2 ON o.budget_id = b2.budget_id - WHERE v.token = '{token}' + WHERE v.token = $1 """ response = await self._query_first_with_cached_plan_fallback( - sql_query + sql_query, hashed_token ) # If not found in main table, check deprecated keys (grace period) From 839d9bd5f33ae238567925387dd619b79f4b51f5 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 9 Apr 2026 20:14:01 -0700 Subject: [PATCH 031/130] refactor(ui): polish regenerate key success view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Label the key block with a small "Virtual Key" caption so the gray box is clearly the key container. - Move the Copy Key action to the modal footer as a primary button with icon; inline copy icon next to the key is removed. - Swap the button to "Copied" with a check icon on success instead of firing a notification — less noisy and keeps feedback in place. - Disable clicking outside the modal to close (maskClosable=false) so users must explicitly dismiss via Close or X. - Enlarge the key text and let its container span the full modal width. - Tests updated accordingly, including a new test for the copied-state swap and the "Virtual Key" label. --- .../e2e_tests/tests/proxy-admin/keys.spec.ts | 4 +- .../organisms/RegenerateKeyModal.test.tsx | 38 ++++++++++++- .../organisms/RegenerateKeyModal.tsx | 53 +++++++++++++------ 3 files changed, 76 insertions(+), 19 deletions(-) diff --git a/ui/litellm-dashboard/e2e_tests/tests/proxy-admin/keys.spec.ts b/ui/litellm-dashboard/e2e_tests/tests/proxy-admin/keys.spec.ts index 9c19bb9b88c..3b9da5d468d 100644 --- a/ui/litellm-dashboard/e2e_tests/tests/proxy-admin/keys.spec.ts +++ b/ui/litellm-dashboard/e2e_tests/tests/proxy-admin/keys.spec.ts @@ -68,9 +68,9 @@ test.describe("Proxy Admin - Keys", () => { const modal = page.locator(".ant-modal:visible"); await modal.getByRole("button", { name: /Regenerate/ }).click(); - // Success view shows the warning banner and a Copy button for the regenerated key + // Success view shows the warning banner and a Copy Key button in the footer await expect(modal.getByText("Save it now, you will not see it again")).toBeVisible({ timeout: 10_000 }); - await expect(modal.getByRole("button", { name: "Copy", exact: true })).toBeVisible({ timeout: 10_000 }); + await expect(modal.getByRole("button", { name: /Copy Key/ })).toBeVisible({ timeout: 10_000 }); }); test("Update key TPM and RPM limits", async ({ page }) => { diff --git a/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.test.tsx b/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.test.tsx index d6eb7dd55ac..1237082d2c4 100644 --- a/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.test.tsx +++ b/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.test.tsx @@ -152,7 +152,7 @@ describe("RegenerateKeyModal", () => { expect(screen.queryByRole("button", { name: /Regenerate/ })).not.toBeInTheDocument(); }); - it("should show Copy Virtual Key button after successful regeneration", async () => { + it("should show Copy Key button after successful regeneration", async () => { const user = userEvent.setup(); mockRegenerateKeyCall.mockResolvedValue({ key: "sk-new-regenerated-key", @@ -163,7 +163,41 @@ describe("RegenerateKeyModal", () => { await user.click(screen.getByRole("button", { name: /Regenerate/ })); await waitFor(() => { - expect(screen.getByRole("button", { name: /Copy/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Copy Key/ })).toBeInTheDocument(); + }); + }); + + it("should swap the Copy Key button to 'Copied' after clicking it", async () => { + const user = userEvent.setup(); + mockRegenerateKeyCall.mockResolvedValue({ + key: "sk-new-regenerated-key", + token: "new-token-hash", + }); + + renderWithProviders(); + await user.click(screen.getByRole("button", { name: /Regenerate/ })); + + const copyButton = await screen.findByRole("button", { name: /Copy Key/ }); + await user.click(copyButton); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /Copied/ })).toBeInTheDocument(); + }); + expect(screen.queryByRole("button", { name: /Copy Key/ })).not.toBeInTheDocument(); + }); + + it("should display the 'Virtual Key' label above the key in the success view", async () => { + const user = userEvent.setup(); + mockRegenerateKeyCall.mockResolvedValue({ + key: "sk-new-regenerated-key", + token: "new-token-hash", + }); + + renderWithProviders(); + await user.click(screen.getByRole("button", { name: /Regenerate/ })); + + await waitFor(() => { + expect(screen.getByText("Virtual Key")).toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.tsx b/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.tsx index c942832e9f4..3f254319bdb 100644 --- a/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.tsx +++ b/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.tsx @@ -1,13 +1,14 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; -import { SyncOutlined } from "@ant-design/icons"; +import { CheckOutlined, CopyOutlined, SyncOutlined } from "@ant-design/icons"; import { Alert, Button, Col, Flex, Form, Input, InputNumber, Modal, Row, Space, Typography } from "antd"; import { add } from "date-fns"; import { useEffect, useState } from "react"; +import { CopyToClipboard } from "react-copy-to-clipboard"; import { KeyResponse } from "../key_team_helpers/key_list"; import NotificationManager from "../molecules/notifications_manager"; import { regenerateKeyCall } from "../networking"; -const { Text, Paragraph } = Typography; +const { Text } = Typography; interface RegenerateKeyModalProps { selectedToken: KeyResponse | null; @@ -23,6 +24,7 @@ export function RegenerateKeyModal({ selectedToken, visible, onClose, onKeyUpdat const [regenerateFormData, setRegenerateFormData] = useState(null); const [newExpiryTime, setNewExpiryTime] = useState(null); const [isRegenerating, setIsRegenerating] = useState(false); + const [copied, setCopied] = useState(false); // Track whether this is the user's own authentication key const [isOwnKey, setIsOwnKey] = useState(false); @@ -57,6 +59,7 @@ export function RegenerateKeyModal({ selectedToken, visible, onClose, onKeyUpdat setIsRegenerating(false); setIsOwnKey(false); setCurrentAccessToken(null); + setCopied(false); form.resetFields(); } }, [visible, form]); @@ -143,22 +146,33 @@ export function RegenerateKeyModal({ selectedToken, visible, onClose, onKeyUpdat setIsRegenerating(false); setIsOwnKey(false); setCurrentAccessToken(null); + setCopied(false); form.resetFields(); onClose(); }; + const handleCopyKey = () => { + setCopied(true); + }; + return ( - Close - , + + + + + + , ] : [ @@ -181,16 +195,25 @@ export function RegenerateKeyModal({ selectedToken, visible, onClose, onKeyUpdat {selectedToken?.key_alias || "No alias set"} - NotificationManager.success("Virtual Key copied to clipboard"), - }} - style={{ marginBottom: 0, wordBreak: "break-all" }} - > - {regeneratedKey} - + + + Virtual Key + +
+ {regeneratedKey} +
+
) : ( Date: Thu, 9 Apr 2026 20:25:10 -0700 Subject: [PATCH 032/130] fix(ui): prefer form values over API echo in regenerate update payload The regenerate endpoint returns a GenerateKeyResponse that inherits max_budget/tpm_limit/rpm_limit from KeyRequestBase, so the API echoes the existing values back. The previous updatedKeyData layout spread ...response *after* the explicit formValues assignments, which meant the user's just-submitted edits were silently overwritten by the API echo before being propagated to the parent via onKeyUpdate. Reorder so the response spread comes first and the formValues-derived fields override it, and add a regression test that mocks a response with stale limits to lock the behavior in. Also drop the two leftover debug console.log statements. --- .../organisms/RegenerateKeyModal.test.tsx | 28 +++++++++++++++++++ .../organisms/RegenerateKeyModal.tsx | 17 +++++------ 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.test.tsx b/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.test.tsx index 1237082d2c4..f77cf787a3e 100644 --- a/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.test.tsx +++ b/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.test.tsx @@ -219,6 +219,34 @@ describe("RegenerateKeyModal", () => { expect(updateCall.key_name).toBe("sk-new-regenerated-key"); }); + it("should pass form values to onKeyUpdate even when the API echoes back different limits", async () => { + // Regression: when the regenerate endpoint returns GenerateKeyResponse, it echoes + // back the existing max_budget / tpm_limit / rpm_limit. The modal must prefer the + // values the user just submitted, not whatever the server echoes. + const user = userEvent.setup(); + mockRegenerateKeyCall.mockResolvedValue({ + key: "sk-new-regenerated-key", + token: "new-token-hash", + // stale values echoed from the server + max_budget: 9999, + tpm_limit: 9999, + rpm_limit: 9999, + }); + + renderWithProviders(); + await user.click(screen.getByRole("button", { name: /Regenerate/ })); + + await waitFor(() => { + expect(mockOnKeyUpdate).toHaveBeenCalledOnce(); + }); + + const updateCall = mockOnKeyUpdate.mock.calls[0][0]; + // The form's pre-filled values (from makeToken) must win over the API echo. + expect(updateCall.max_budget).toBe(100); + expect(updateCall.tpm_limit).toBe(5000); + expect(updateCall.rpm_limit).toBe(500); + }); + it("should display key alias in success view", async () => { const user = userEvent.setup(); mockRegenerateKeyCall.mockResolvedValue({ diff --git a/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.tsx b/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.tsx index 3f254319bdb..c714a15eb98 100644 --- a/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.tsx +++ b/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.tsx @@ -111,23 +111,20 @@ export function RegenerateKeyModal({ selectedToken, visible, onClose, onKeyUpdat setRegeneratedKey(response.key); NotificationManager.success("Virtual Key regenerated successfully"); - console.log("Full regenerate response:", response); // Debug log to see what's returned - - // Create updated key data with ALL new values from the response + // Build the update payload. Spread the API response first so any new + // fields it returns (new token, timestamps, etc.) are captured, then + // override with the explicit form values — the user's just-submitted + // edits must win over whatever the API echoes back. const updatedKeyData: Partial = { - // Use the new token/key ID from the response (this is what was missing!) - token: response.token || response.key_id || selectedToken.token, // Try different possible field names - key_name: response.key, // This is the new secret key string + ...response, + token: response.token || response.key_id || selectedToken.token, + key_name: response.key, max_budget: formValues.max_budget, tpm_limit: formValues.tpm_limit, rpm_limit: formValues.rpm_limit, expires: formValues.duration ? calculateNewExpiryTime(formValues.duration) : selectedToken.expires, - // Include any other fields that might be returned by the API - ...response, // Spread the entire response to capture all updated fields }; - console.log("Updated key data with new token:", updatedKeyData); // Debug log - // Update the parent component with new key data if (onKeyUpdate) { onKeyUpdate(updatedKeyData); From 1d50f774e253909fdda1847fa27871e3e6cd5b59 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 9 Apr 2026 20:31:35 -0700 Subject: [PATCH 033/130] fix(ui): support all duration suffixes in regenerate expiry preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit calculateNewExpiryTime only handled s/h/d, but the grace-period validation and backend accept m, w, and mo as well. Entering any of those in the Expire Key field caused the function to return null, which then propagated as expires: null in the onKeyUpdate payload — the parent UI would then render the expiry as "Never" even though the backend had correctly applied the new expiry. Extend the suffix check to cover s/m/h/d/w/mo, matching "mo" before "m" so "1mo" isn't misread as minutes. Also nullish-coalesce the call site so an unparseable duration falls back to the previous expiry instead of null. Add parametric tests for each supported suffix plus a regression test for the null fallback. --- .../organisms/RegenerateKeyModal.test.tsx | 48 +++++++++++++++++++ .../organisms/RegenerateKeyModal.tsx | 24 +++++++--- 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.test.tsx b/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.test.tsx index f77cf787a3e..a69ae779249 100644 --- a/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.test.tsx +++ b/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.test.tsx @@ -219,6 +219,54 @@ describe("RegenerateKeyModal", () => { expect(updateCall.key_name).toBe("sk-new-regenerated-key"); }); + it.each([ + ["30s", /New expiry:/], + ["15m", /New expiry:/], + ["2h", /New expiry:/], + ["7d", /New expiry:/], + ["2w", /New expiry:/], + ["1mo", /New expiry:/], + ])("should compute a new expiry preview for duration '%s'", async (durationInput, expected) => { + const user = userEvent.setup(); + renderWithProviders(); + + const durationField = screen.getByPlaceholderText("e.g. 30s, 30h, 30d"); + await user.clear(durationField); + await user.type(durationField, durationInput); + + await waitFor(() => { + expect(screen.getByText(expected)).toBeInTheDocument(); + }); + }); + + it("should fall back to the previous expiry when duration is unparseable", async () => { + // Regression: if calculateNewExpiryTime returns null (unrecognised suffix), + // the payload should fall back to the previous expires rather than null. + const user = userEvent.setup(); + const previousExpires = "2026-12-31T00:00:00Z"; + mockRegenerateKeyCall.mockResolvedValue({ + key: "sk-new-regenerated-key", + token: "new-token-hash", + }); + + renderWithProviders( + , + ); + + const durationField = screen.getByPlaceholderText("e.g. 30s, 30h, 30d"); + await user.clear(durationField); + await user.type(durationField, "bogus"); + + await user.click(screen.getByRole("button", { name: /Regenerate/ })); + + await waitFor(() => { + expect(mockOnKeyUpdate).toHaveBeenCalledOnce(); + }); + + const updateCall = mockOnKeyUpdate.mock.calls[0][0]; + expect(updateCall.expires).toBe(previousExpires); + }); + it("should pass form values to onKeyUpdate even when the API echoes back different limits", async () => { // Regression: when the regenerate endpoint returns GenerateKeyResponse, it echoes // back the existing max_budget / tpm_limit / rpm_limit. The modal must prefer the diff --git a/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.tsx b/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.tsx index c714a15eb98..babbf9989e6 100644 --- a/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.tsx +++ b/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.tsx @@ -68,15 +68,25 @@ export function RegenerateKeyModal({ selectedToken, visible, onClose, onKeyUpdat if (!duration) return null; try { + const amount = parseInt(duration); + if (Number.isNaN(amount)) { + throw new Error("Invalid duration format"); + } const now = new Date(); + // Check "mo" before "m" to avoid a false prefix match (e.g. "1mo" → minutes). let newExpiry: Date; - - if (duration.endsWith("s")) { - newExpiry = add(now, { seconds: parseInt(duration) }); + if (duration.endsWith("mo")) { + newExpiry = add(now, { months: amount }); + } else if (duration.endsWith("s")) { + newExpiry = add(now, { seconds: amount }); + } else if (duration.endsWith("m")) { + newExpiry = add(now, { minutes: amount }); } else if (duration.endsWith("h")) { - newExpiry = add(now, { hours: parseInt(duration) }); + newExpiry = add(now, { hours: amount }); } else if (duration.endsWith("d")) { - newExpiry = add(now, { days: parseInt(duration) }); + newExpiry = add(now, { days: amount }); + } else if (duration.endsWith("w")) { + newExpiry = add(now, { weeks: amount }); } else { throw new Error("Invalid duration format"); } @@ -122,7 +132,9 @@ export function RegenerateKeyModal({ selectedToken, visible, onClose, onKeyUpdat max_budget: formValues.max_budget, tpm_limit: formValues.tpm_limit, rpm_limit: formValues.rpm_limit, - expires: formValues.duration ? calculateNewExpiryTime(formValues.duration) : selectedToken.expires, + expires: formValues.duration + ? (calculateNewExpiryTime(formValues.duration) ?? selectedToken.expires) + : selectedToken.expires, }; // Update the parent component with new key data From 3a316b913179ade01fe12b42162089c6a80271de Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 9 Apr 2026 20:42:30 -0700 Subject: [PATCH 034/130] [Test] UI - Unit tests: raise global vitest timeout and remove per-test overrides Raise vitest testTimeout from 10s to 30s and drop per-test timeout overrides across UI unit tests. Group CreateUserButton and TeamInfo tests under nested describe blocks to make the most flaky suites easier to scan. --- .../ModelsAndEndpointsView.test.tsx | 8 +- .../src/components/CreateUserButton.test.tsx | 524 +++---- .../src/components/OldTeams.test.tsx | 2 +- .../add_model/add_model_tab.test.tsx | 2 +- .../mcp_tools/create_mcp_server.test.tsx | 290 ++-- .../src/components/team/TeamInfo.test.tsx | 1232 +++++++++-------- ui/litellm-dashboard/vitest.config.ts | 2 +- 7 files changed, 1029 insertions(+), 1031 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.test.tsx index e1b3b358300..3c5101fc2dc 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.test.tsx @@ -131,7 +131,7 @@ describe("ModelsAndEndpointsView", () => { , ); expect(await findByText("Model Management", {}, { timeout: 10000 })).toBeInTheDocument(); - }, 15000); + }); it("should show Missing provider banner by default", async () => { localStorageMock.clear(); @@ -149,7 +149,7 @@ describe("ModelsAndEndpointsView", () => { , ); expect(await findByText("Missing a provider?", {}, { timeout: 10000 })).toBeInTheDocument(); - }, 15000); + }); it("should hide Missing provider banner when dismiss button is clicked and persist to localStorage", async () => { localStorageMock.clear(); @@ -180,7 +180,7 @@ describe("ModelsAndEndpointsView", () => { // LocalStorage should be updated expect(localStorageMock.getItem("hideMissingProviderBanner")).toBe("true"); - }, 15000); + }); it("should show compact Request Provider button when banner is dismissed", async () => { // Set localStorage to hide banner @@ -209,7 +209,7 @@ describe("ModelsAndEndpointsView", () => { const requestProviderLinks = document.querySelectorAll('a[href="https://models.litellm.ai/?request=true"]'); // There should be a compact button when banner is hidden expect(requestProviderLinks.length).toBeGreaterThan(0); - }, 15000); + }); it("should pass model IDs (not model names) to HealthCheckComponent as all_models_on_proxy", async () => { mockHealthCheckComponent.mockClear(); diff --git a/ui/litellm-dashboard/src/components/CreateUserButton.test.tsx b/ui/litellm-dashboard/src/components/CreateUserButton.test.tsx index 9a4659da9d3..03d982ca7c2 100644 --- a/ui/litellm-dashboard/src/components/CreateUserButton.test.tsx +++ b/ui/litellm-dashboard/src/components/CreateUserButton.test.tsx @@ -51,7 +51,7 @@ function renderWithProviders(ui: React.ReactElement) { return render({ui}); } -describe("CreateUserButton", { timeout: 20000 }, () => { +describe("CreateUserButton", () => { beforeEach(() => { vi.clearAllMocks(); mockGetProxyUISettings.mockResolvedValue({ @@ -62,288 +62,296 @@ describe("CreateUserButton", { timeout: 20000 }, () => { }); }); - it("should render the create user form when embedded", () => { - renderWithProviders( - , - ); - expect(screen.getByRole("button", { name: /create user/i })).toBeInTheDocument(); - }); + describe("rendering and visibility", () => { + it("should render the create user form when embedded", () => { + renderWithProviders( + , + ); + expect(screen.getByRole("button", { name: /create user/i })).toBeInTheDocument(); + }); - it("should render the invite user button when not embedded", async () => { - renderWithProviders(); - await waitFor(() => { - expect(screen.getByRole("button", { name: /\+ invite user/i })).toBeInTheDocument(); + it("should render the invite user button when not embedded", async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getByRole("button", { name: /\+ invite user/i })).toBeInTheDocument(); + }); + }); + + it("should open the invite modal when invite user button is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByRole("button", { name: /\+ invite user/i })).toBeInTheDocument(); + }); + await user.click(screen.getByRole("button", { name: /\+ invite user/i })); + const dialog = screen.getByRole("dialog", { name: /invite user/i }); + expect(dialog).toBeInTheDocument(); + expect(within(dialog).getByRole("button", { name: /invite user/i })).toBeInTheDocument(); + }); + + it("should display email invitations info message in embedded mode", () => { + renderWithProviders(); + expect(screen.getByText("Email invitations")).toBeInTheDocument(); + }); + + it("should display user role options when possibleUIRoles is provided", async () => { + const possibleUIRoles = { + proxy_admin: { ui_label: "Admin", description: "Full access" }, + proxy_user: { ui_label: "User", description: "Limited access" }, + }; + renderWithProviders( + , + ); + await userEvent.click(screen.getByRole("combobox", { name: /user role/i })); + expect(screen.getByText("Admin")).toBeInTheDocument(); + expect(screen.getByText("User")).toBeInTheDocument(); + }); + + it("should close modal when cancel is clicked in standalone mode", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /\+ invite user/i })).toBeInTheDocument(); + }); + await user.click(screen.getByRole("button", { name: /\+ invite user/i })); + expect(screen.getByRole("dialog", { name: /invite user/i })).toBeInTheDocument(); + + const dialog = screen.getByRole("dialog", { name: /invite user/i }); + await user.click(within(dialog).getByRole("button", { name: /close/i })); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); }); - it("should open the invite modal when invite user button is clicked", async () => { - const user = userEvent.setup(); - renderWithProviders(); - await waitFor(() => { - expect(screen.getByRole("button", { name: /\+ invite user/i })).toBeInTheDocument(); + describe("embedded mode submission", () => { + it("should call userCreateCall when form is submitted in embedded mode", async () => { + const user = userEvent.setup(); + mockUserCreateCall.mockResolvedValue({ data: { user_id: "new-user-123" } }); + mockInvitationCreateCall.mockResolvedValue({ + id: "inv-1", + user_id: "new-user-123", + has_user_setup_sso: false, + } as any); + + renderWithProviders( + , + ); + + await user.type(screen.getByLabelText(/user email/i), "test@example.com"); + await user.click(screen.getByRole("combobox", { name: /user role/i })); + await user.click(screen.getByText("User")); + await user.click(screen.getByRole("button", { name: /create user/i })); + + await waitFor(() => { + expect(mockUserCreateCall).toHaveBeenCalledWith("token", null, expect.objectContaining({ + user_email: "test@example.com", + user_role: "proxy_user", + })); + }); }); - await user.click(screen.getByRole("button", { name: /\+ invite user/i })); - const dialog = screen.getByRole("dialog", { name: /invite user/i }); - expect(dialog).toBeInTheDocument(); - expect(within(dialog).getByRole("button", { name: /invite user/i })).toBeInTheDocument(); - }); - it("should display email invitations info message in embedded mode", () => { - renderWithProviders(); - expect(screen.getByText("Email invitations")).toBeInTheDocument(); - }); + it("should call onUserCreated callback when user is created in embedded mode", async () => { + const user = userEvent.setup(); + const onUserCreated = vi.fn(); + mockUserCreateCall.mockResolvedValue({ data: { user_id: "new-user-456" } }); - it("should display user role options when possibleUIRoles is provided", async () => { - const possibleUIRoles = { - proxy_admin: { ui_label: "Admin", description: "Full access" }, - proxy_user: { ui_label: "User", description: "Limited access" }, - }; - renderWithProviders( - , - ); - await userEvent.click(screen.getByRole("combobox", { name: /user role/i })); - expect(screen.getByText("Admin")).toBeInTheDocument(); - expect(screen.getByText("User")).toBeInTheDocument(); - }); + renderWithProviders( + , + ); - it("should call userCreateCall when form is submitted in embedded mode", async () => { - const user = userEvent.setup(); - mockUserCreateCall.mockResolvedValue({ data: { user_id: "new-user-123" } }); - mockInvitationCreateCall.mockResolvedValue({ - id: "inv-1", - user_id: "new-user-123", - has_user_setup_sso: false, - } as any); + await user.type(screen.getByLabelText(/user email/i), "embedded@example.com"); + await user.click(screen.getByRole("combobox", { name: /user role/i })); + await user.click(screen.getByText("User")); + await user.click(screen.getByRole("button", { name: /create user/i })); - renderWithProviders( - , - ); + await waitFor(() => { + expect(onUserCreated).toHaveBeenCalledWith("new-user-456"); + }); + }); - await user.type(screen.getByLabelText(/user email/i), "test@example.com"); - await user.click(screen.getByRole("combobox", { name: /user role/i })); - await user.click(screen.getByText("User")); - await user.click(screen.getByRole("button", { name: /create user/i })); + it("should show error notification when user creation fails", async () => { + const user = userEvent.setup(); + mockUserCreateCall.mockRejectedValue({ response: { data: { detail: "Email already exists" } } }); - await waitFor(() => { - expect(mockUserCreateCall).toHaveBeenCalledWith("token", null, expect.objectContaining({ - user_email: "test@example.com", - user_role: "proxy_user", - })); + renderWithProviders( + , + ); + + await user.type(screen.getByLabelText(/user email/i), "duplicate@example.com"); + await user.click(screen.getByRole("combobox", { name: /user role/i })); + await user.click(screen.getByText("User")); + await user.click(screen.getByRole("button", { name: /create user/i })); + + await waitFor(() => { + expect(mockNotificationsManager.fromBackend).toHaveBeenCalledWith("Email already exists"); + }); + }); + + it("should show info notification when making API call", async () => { + const user = userEvent.setup(); + mockUserCreateCall.mockResolvedValue({ data: { user_id: "new-user" } }); + mockInvitationCreateCall.mockResolvedValue({ + id: "inv-3", + user_id: "new-user", + has_user_setup_sso: false, + } as any); + + renderWithProviders( + , + ); + + await user.type(screen.getByLabelText(/user email/i), "info@example.com"); + await user.click(screen.getByRole("combobox", { name: /user role/i })); + await user.click(screen.getByText("User")); + await user.click(screen.getByRole("button", { name: /create user/i })); + + await waitFor(() => { + expect(mockNotificationsManager.info).toHaveBeenCalledWith("Making API Call"); + }); }); }); - it("should call onUserCreated callback when user is created in embedded mode", async () => { - const user = userEvent.setup(); - const onUserCreated = vi.fn(); - mockUserCreateCall.mockResolvedValue({ data: { user_id: "new-user-456" } }); + describe("standalone mode submission", () => { + it("should show success notification when user is created successfully in standalone mode", async () => { + const user = userEvent.setup(); + mockUserCreateCall.mockResolvedValue({ data: { user_id: "new-user-789" } }); + mockInvitationCreateCall.mockResolvedValue({ + id: "inv-2", + user_id: "new-user-789", + has_user_setup_sso: false, + } as any); - renderWithProviders( - , - ); + renderWithProviders( + , + ); - await user.type(screen.getByLabelText(/user email/i), "embedded@example.com"); - await user.click(screen.getByRole("combobox", { name: /user role/i })); - await user.click(screen.getByText("User")); - await user.click(screen.getByRole("button", { name: /create user/i })); + await waitFor(() => { + expect(screen.getByRole("button", { name: /\+ invite user/i })).toBeInTheDocument(); + }); + await user.click(screen.getByRole("button", { name: /\+ invite user/i })); - await waitFor(() => { - expect(onUserCreated).toHaveBeenCalledWith("new-user-456"); + const dialog = screen.getByRole("dialog", { name: /invite user/i }); + await user.type(within(dialog).getByLabelText(/user email/i), "standalone@example.com"); + await user.click(within(dialog).getByRole("combobox", { name: /global proxy role/i })); + await user.click(screen.getByText("User")); + await user.click(within(dialog).getByRole("button", { name: /invite user/i })); + + await waitFor(() => { + expect(mockNotificationsManager.success).toHaveBeenCalledWith("API user Created"); + }); + }); + + it("should show onboarding modal when user is created and SSO is disabled", async () => { + const user = userEvent.setup(); + mockUserCreateCall.mockResolvedValue({ data: { user_id: "sso-user" } }); + mockInvitationCreateCall.mockResolvedValue({ + id: "inv-sso", + user_id: "sso-user", + has_user_setup_sso: false, + } as any); + + renderWithProviders( + , + ); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /\+ invite user/i })).toBeInTheDocument(); + }); + await user.click(screen.getByRole("button", { name: /\+ invite user/i })); + + const dialog = screen.getByRole("dialog", { name: /invite user/i }); + await user.type(within(dialog).getByLabelText(/user email/i), "sso@example.com"); + await user.click(within(dialog).getByRole("combobox", { name: /global proxy role/i })); + await user.click(screen.getByText("User")); + await user.click(within(dialog).getByRole("button", { name: /invite user/i })); + + await waitFor(() => { + expect(mockInvitationCreateCall).toHaveBeenCalledWith("token", "sso-user"); + }); + await waitFor(() => { + expect(mockNotificationsManager.success).toHaveBeenCalledWith("API user Created"); + }); }); }); - it("should show success notification when user is created successfully in standalone mode", async () => { - const user = userEvent.setup(); - mockUserCreateCall.mockResolvedValue({ data: { user_id: "new-user-789" } }); - mockInvitationCreateCall.mockResolvedValue({ - id: "inv-2", - user_id: "new-user-789", - has_user_setup_sso: false, - } as any); + describe("organizations", () => { + it("should send organizations list in POST body when organizations are selected", async () => { + const { useOrganizations } = await import("@/app/(dashboard)/hooks/organizations/useOrganizations"); + vi.mocked(useOrganizations).mockReturnValue({ + data: [{ organization_id: "org-1", organization_alias: "My Org" }], + isLoading: false, + } as any); - renderWithProviders( - , - ); + const user = userEvent.setup(); + mockUserCreateCall.mockResolvedValue({ data: { user_id: "org-user" } }); + mockInvitationCreateCall.mockResolvedValue({ + id: "inv-org", + user_id: "org-user", + has_user_setup_sso: false, + } as any); - await waitFor(() => { - expect(screen.getByRole("button", { name: /\+ invite user/i })).toBeInTheDocument(); + renderWithProviders( + , + ); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /\+ invite user/i })).toBeInTheDocument(); + }); + await user.click(screen.getByRole("button", { name: /\+ invite user/i })); + + const dialog = screen.getByRole("dialog", { name: /invite user/i }); + await user.type(within(dialog).getByLabelText(/user email/i), "org@example.com"); + await user.click(within(dialog).getByRole("combobox", { name: /global proxy role/i })); + await user.click(screen.getByText("User")); + + // Select org from the dropdown + const orgSelect = within(dialog).getByRole("combobox", { name: /organization/i }); + await user.click(orgSelect); + await user.click(screen.getByText("My Org (org-1)")); + + await user.click(within(dialog).getByRole("button", { name: /invite user/i })); + + await waitFor(() => { + expect(mockUserCreateCall).toHaveBeenCalledWith("token", null, expect.objectContaining({ + organizations: ["org-1"], + })); + }); }); - await user.click(screen.getByRole("button", { name: /\+ invite user/i })); - const dialog = screen.getByRole("dialog", { name: /invite user/i }); - await user.type(within(dialog).getByLabelText(/user email/i), "standalone@example.com"); - await user.click(within(dialog).getByRole("combobox", { name: /global proxy role/i })); - await user.click(screen.getByText("User")); - await user.click(within(dialog).getByRole("button", { name: /invite user/i })); + it("should not call organizationMemberAddCall after user creation", async () => { + const { useOrganizations } = await import("@/app/(dashboard)/hooks/organizations/useOrganizations"); + vi.mocked(useOrganizations).mockReturnValue({ + data: [{ organization_id: "org-1", organization_alias: "My Org" }], + isLoading: false, + } as any); - await waitFor(() => { - expect(mockNotificationsManager.success).toHaveBeenCalledWith("API user Created"); + const user = userEvent.setup(); + mockUserCreateCall.mockResolvedValue({ data: { user_id: "no-member-add-user" } }); + mockInvitationCreateCall.mockResolvedValue({ + id: "inv-nma", + user_id: "no-member-add-user", + has_user_setup_sso: false, + } as any); + + renderWithProviders( + , + ); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /\+ invite user/i })).toBeInTheDocument(); + }); + await user.click(screen.getByRole("button", { name: /\+ invite user/i })); + + const dialog = screen.getByRole("dialog", { name: /invite user/i }); + await user.type(within(dialog).getByLabelText(/user email/i), "nomemberadd@example.com"); + await user.click(within(dialog).getByRole("combobox", { name: /global proxy role/i })); + await user.click(screen.getByText("User")); + await user.click(within(dialog).getByRole("button", { name: /invite user/i })); + + await waitFor(() => { + expect(mockUserCreateCall).toHaveBeenCalled(); + }); + expect(mockOrganizationMemberAddCall).not.toHaveBeenCalled(); }); }); - - it("should show error notification when user creation fails", async () => { - const user = userEvent.setup(); - mockUserCreateCall.mockRejectedValue({ response: { data: { detail: "Email already exists" } } }); - - renderWithProviders( - , - ); - - await user.type(screen.getByLabelText(/user email/i), "duplicate@example.com"); - await user.click(screen.getByRole("combobox", { name: /user role/i })); - await user.click(screen.getByText("User")); - await user.click(screen.getByRole("button", { name: /create user/i })); - - await waitFor(() => { - expect(mockNotificationsManager.fromBackend).toHaveBeenCalledWith("Email already exists"); - }); - }); - - it("should show info notification when making API call", async () => { - const user = userEvent.setup(); - mockUserCreateCall.mockResolvedValue({ data: { user_id: "new-user" } }); - mockInvitationCreateCall.mockResolvedValue({ - id: "inv-3", - user_id: "new-user", - has_user_setup_sso: false, - } as any); - - renderWithProviders( - , - ); - - await user.type(screen.getByLabelText(/user email/i), "info@example.com"); - await user.click(screen.getByRole("combobox", { name: /user role/i })); - await user.click(screen.getByText("User")); - await user.click(screen.getByRole("button", { name: /create user/i })); - - await waitFor(() => { - expect(mockNotificationsManager.info).toHaveBeenCalledWith("Making API Call"); - }); - }); - - it("should close modal when cancel is clicked in standalone mode", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByRole("button", { name: /\+ invite user/i })).toBeInTheDocument(); - }); - await user.click(screen.getByRole("button", { name: /\+ invite user/i })); - expect(screen.getByRole("dialog", { name: /invite user/i })).toBeInTheDocument(); - - const dialog = screen.getByRole("dialog", { name: /invite user/i }); - await user.click(within(dialog).getByRole("button", { name: /close/i })); - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - }); - - it("should show onboarding modal when user is created and SSO is disabled", async () => { - const user = userEvent.setup(); - mockUserCreateCall.mockResolvedValue({ data: { user_id: "sso-user" } }); - mockInvitationCreateCall.mockResolvedValue({ - id: "inv-sso", - user_id: "sso-user", - has_user_setup_sso: false, - } as any); - - renderWithProviders( - , - ); - - await waitFor(() => { - expect(screen.getByRole("button", { name: /\+ invite user/i })).toBeInTheDocument(); - }); - await user.click(screen.getByRole("button", { name: /\+ invite user/i })); - - const dialog = screen.getByRole("dialog", { name: /invite user/i }); - await user.type(within(dialog).getByLabelText(/user email/i), "sso@example.com"); - await user.click(within(dialog).getByRole("combobox", { name: /global proxy role/i })); - await user.click(screen.getByText("User")); - await user.click(within(dialog).getByRole("button", { name: /invite user/i })); - - await waitFor(() => { - expect(mockInvitationCreateCall).toHaveBeenCalledWith("token", "sso-user"); - }); - await waitFor(() => { - expect(mockNotificationsManager.success).toHaveBeenCalledWith("API user Created"); - }); - }); - - it("should send organizations list in POST body when organizations are selected", async () => { - const { useOrganizations } = await import("@/app/(dashboard)/hooks/organizations/useOrganizations"); - vi.mocked(useOrganizations).mockReturnValue({ - data: [{ organization_id: "org-1", organization_alias: "My Org" }], - isLoading: false, - } as any); - - const user = userEvent.setup(); - mockUserCreateCall.mockResolvedValue({ data: { user_id: "org-user" } }); - mockInvitationCreateCall.mockResolvedValue({ - id: "inv-org", - user_id: "org-user", - has_user_setup_sso: false, - } as any); - - renderWithProviders( - , - ); - - await waitFor(() => { - expect(screen.getByRole("button", { name: /\+ invite user/i })).toBeInTheDocument(); - }); - await user.click(screen.getByRole("button", { name: /\+ invite user/i })); - - const dialog = screen.getByRole("dialog", { name: /invite user/i }); - await user.type(within(dialog).getByLabelText(/user email/i), "org@example.com"); - await user.click(within(dialog).getByRole("combobox", { name: /global proxy role/i })); - await user.click(screen.getByText("User")); - - // Select org from the dropdown - const orgSelect = within(dialog).getByRole("combobox", { name: /organization/i }); - await user.click(orgSelect); - await user.click(screen.getByText("My Org (org-1)")); - - await user.click(within(dialog).getByRole("button", { name: /invite user/i })); - - await waitFor(() => { - expect(mockUserCreateCall).toHaveBeenCalledWith("token", null, expect.objectContaining({ - organizations: ["org-1"], - })); - }); - }); - - it("should not call organizationMemberAddCall after user creation", async () => { - const { useOrganizations } = await import("@/app/(dashboard)/hooks/organizations/useOrganizations"); - vi.mocked(useOrganizations).mockReturnValue({ - data: [{ organization_id: "org-1", organization_alias: "My Org" }], - isLoading: false, - } as any); - - const user = userEvent.setup(); - mockUserCreateCall.mockResolvedValue({ data: { user_id: "no-member-add-user" } }); - mockInvitationCreateCall.mockResolvedValue({ - id: "inv-nma", - user_id: "no-member-add-user", - has_user_setup_sso: false, - } as any); - - renderWithProviders( - , - ); - - await waitFor(() => { - expect(screen.getByRole("button", { name: /\+ invite user/i })).toBeInTheDocument(); - }); - await user.click(screen.getByRole("button", { name: /\+ invite user/i })); - - const dialog = screen.getByRole("dialog", { name: /invite user/i }); - await user.type(within(dialog).getByLabelText(/user email/i), "nomemberadd@example.com"); - await user.click(within(dialog).getByRole("combobox", { name: /global proxy role/i })); - await user.click(screen.getByText("User")); - await user.click(within(dialog).getByRole("button", { name: /invite user/i })); - - await waitFor(() => { - expect(mockUserCreateCall).toHaveBeenCalled(); - }); - expect(mockOrganizationMemberAddCall).not.toHaveBeenCalled(); - }); }); diff --git a/ui/litellm-dashboard/src/components/OldTeams.test.tsx b/ui/litellm-dashboard/src/components/OldTeams.test.tsx index 651d1495c61..4b89820bad6 100644 --- a/ui/litellm-dashboard/src/components/OldTeams.test.tsx +++ b/ui/litellm-dashboard/src/components/OldTeams.test.tsx @@ -843,7 +843,7 @@ describe("OldTeams - access_group_ids in team create", () => { }), ); }); - }, { timeout: 30000 }); + }); }); describe("OldTeams - models dropdown options", () => { 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 index 197bcd6569f..59970547c1a 100644 --- 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 @@ -269,7 +269,7 @@ describe("Add Model Tab", () => { }, { timeout: 10000 }, ); - }, 15000); // 15 second timeout to allow waitFor to complete + }); it("should show team selection when team-only switch is enabled", async () => { const props = createTestProps(); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx index c92956b430f..b4251267137 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx @@ -150,151 +150,139 @@ describe("CreateMCPServer", () => { }); }); - it( - "should not require auth value when creating a server with API Key auth type", - { timeout: 15000 }, - async () => { - await selectHttpTransport(); + it("should not require auth value when creating a server with API Key auth type", async () => { + await selectHttpTransport(); - const user = userEvent.setup({ delay: null }); + const user = userEvent.setup({ delay: null }); - // Fill in server name (use id to avoid duplicate placeholder) - const nameInput = getServerNameInput(); - await user.type(nameInput, "Test_Server"); + // Fill in server name (use id to avoid duplicate placeholder) + const nameInput = getServerNameInput(); + await user.type(nameInput, "Test_Server"); - // Fill in URL - const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com"); - await user.type(urlInput, "https://example.com/mcp"); + // Fill in URL + const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com"); + await user.type(urlInput, "https://example.com/mcp"); - // Select API Key auth type - await selectAntOption("Authentication", "API Key"); + // Select API Key auth type + await selectAntOption("Authentication", "API Key"); - await waitFor(() => { - expect(screen.getByText("Authentication Value")).toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.getByText("Authentication Value")).toBeInTheDocument(); + }); - // Leave auth value empty and submit - vi.mocked(networking.createMCPServer).mockResolvedValue({ - server_id: "new-server-1", - server_name: "Test_Server", - alias: "Test_Server", - url: "https://example.com/mcp", - transport: "http", - auth_type: "api_key", - created_at: "2024-01-01T00:00:00Z", - created_by: "user-1", - updated_at: "2024-01-01T00:00:00Z", - updated_by: "user-1", - }); + // Leave auth value empty and submit + vi.mocked(networking.createMCPServer).mockResolvedValue({ + server_id: "new-server-1", + server_name: "Test_Server", + alias: "Test_Server", + url: "https://example.com/mcp", + transport: "http", + auth_type: "api_key", + created_at: "2024-01-01T00:00:00Z", + created_by: "user-1", + updated_at: "2024-01-01T00:00:00Z", + updated_by: "user-1", + }); - const submitButton = screen.getByRole("button", { name: "Add MCP Server" }); - await act(async () => { - fireEvent.click(submitButton); - }); + const submitButton = screen.getByRole("button", { name: "Add MCP Server" }); + await act(async () => { + fireEvent.click(submitButton); + }); - // The form should submit without validation error on auth_value - await waitFor(() => { - expect(networking.createMCPServer).toHaveBeenCalledTimes(1); - }); - }, - ); + // The form should submit without validation error on auth_value + await waitFor(() => { + expect(networking.createMCPServer).toHaveBeenCalledTimes(1); + }); + }); - it( - "should not require auth value when creating a server with Bearer Token auth type", - { timeout: 15000 }, - async () => { - await selectHttpTransport(); + it("should not require auth value when creating a server with Bearer Token auth type", async () => { + await selectHttpTransport(); - const user = userEvent.setup({ delay: null }); + const user = userEvent.setup({ delay: null }); - const nameInput = getServerNameInput(); - await user.type(nameInput, "Test_Server"); + const nameInput = getServerNameInput(); + await user.type(nameInput, "Test_Server"); - const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com"); - await user.type(urlInput, "https://example.com/mcp"); + const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com"); + await user.type(urlInput, "https://example.com/mcp"); - await selectAntOption("Authentication", "Bearer Token"); + await selectAntOption("Authentication", "Bearer Token"); - await waitFor(() => { - expect(screen.getByText("Authentication Value")).toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.getByText("Authentication Value")).toBeInTheDocument(); + }); - // Leave auth value empty and submit - vi.mocked(networking.createMCPServer).mockResolvedValue({ - server_id: "new-server-1", - server_name: "Test_Server", - alias: "Test_Server", - url: "https://example.com/mcp", - transport: "http", - auth_type: "bearer_token", - created_at: "2024-01-01T00:00:00Z", - created_by: "user-1", - updated_at: "2024-01-01T00:00:00Z", - updated_by: "user-1", - }); + // Leave auth value empty and submit + vi.mocked(networking.createMCPServer).mockResolvedValue({ + server_id: "new-server-1", + server_name: "Test_Server", + alias: "Test_Server", + url: "https://example.com/mcp", + transport: "http", + auth_type: "bearer_token", + created_at: "2024-01-01T00:00:00Z", + created_by: "user-1", + updated_at: "2024-01-01T00:00:00Z", + updated_by: "user-1", + }); - const submitButton = screen.getByRole("button", { name: "Add MCP Server" }); - await act(async () => { - fireEvent.click(submitButton); - }); + const submitButton = screen.getByRole("button", { name: "Add MCP Server" }); + await act(async () => { + fireEvent.click(submitButton); + }); - await waitFor(() => { - expect(networking.createMCPServer).toHaveBeenCalledTimes(1); - }); - }, - ); + await waitFor(() => { + expect(networking.createMCPServer).toHaveBeenCalledTimes(1); + }); + }); - it( - "should successfully create a server when auth value is provided", - { timeout: 15000 }, - async () => { - await selectHttpTransport(); + it("should successfully create a server when auth value is provided", async () => { + await selectHttpTransport(); - const user = userEvent.setup({ delay: null }); + const user = userEvent.setup({ delay: null }); - const nameInput = getServerNameInput(); - await user.type(nameInput, "My_Server"); + const nameInput = getServerNameInput(); + await user.type(nameInput, "My_Server"); - const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com"); - await user.type(urlInput, "https://example.com/mcp"); + const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com"); + await user.type(urlInput, "https://example.com/mcp"); - await selectAntOption("Authentication", "API Key"); + await selectAntOption("Authentication", "API Key"); - await waitFor(() => { - expect(screen.getByText("Authentication Value")).toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.getByText("Authentication Value")).toBeInTheDocument(); + }); - // Fill in auth value - const authInput = screen.getByPlaceholderText("Enter token or secret"); - await user.type(authInput, "my-secret-key"); + // Fill in auth value + const authInput = screen.getByPlaceholderText("Enter token or secret"); + await user.type(authInput, "my-secret-key"); - vi.mocked(networking.createMCPServer).mockResolvedValue({ - server_id: "new-server-1", - server_name: "My_Server", - alias: "My_Server", - url: "https://example.com/mcp", - transport: "http", - auth_type: "api_key", - created_at: "2024-01-01T00:00:00Z", - created_by: "user-1", - updated_at: "2024-01-01T00:00:00Z", - updated_by: "user-1", - }); + vi.mocked(networking.createMCPServer).mockResolvedValue({ + server_id: "new-server-1", + server_name: "My_Server", + alias: "My_Server", + url: "https://example.com/mcp", + transport: "http", + auth_type: "api_key", + created_at: "2024-01-01T00:00:00Z", + created_by: "user-1", + updated_at: "2024-01-01T00:00:00Z", + updated_by: "user-1", + }); - const submitButton = screen.getByRole("button", { name: "Add MCP Server" }); - await act(async () => { - fireEvent.click(submitButton); - }); + const submitButton = screen.getByRole("button", { name: "Add MCP Server" }); + await act(async () => { + fireEvent.click(submitButton); + }); - await waitFor(() => { - expect(networking.createMCPServer).toHaveBeenCalledTimes(1); - }); + await waitFor(() => { + expect(networking.createMCPServer).toHaveBeenCalledTimes(1); + }); - const [token, payload] = vi.mocked(networking.createMCPServer).mock.calls[0]; - expect(token).toBe("test-token"); - expect(payload.credentials).toEqual({ auth_value: "my-secret-key" }); - }, - ); + const [token, payload] = vi.mocked(networking.createMCPServer).mock.calls[0]; + expect(token).toBe("test-token"); + expect(payload.credentials).toEqual({ auth_value: "my-secret-key" }); + }); it("should not show auth value field when None auth type is selected", async () => { await selectHttpTransport(); @@ -307,50 +295,46 @@ describe("CreateMCPServer", () => { }); }); - it( - "should successfully create a server with no auth", - { timeout: 15000 }, - async () => { - await selectHttpTransport(); + it("should successfully create a server with no auth", async () => { + await selectHttpTransport(); - const user = userEvent.setup({ delay: null }); + const user = userEvent.setup({ delay: null }); - const nameInput = getServerNameInput(); - await user.type(nameInput, "No_Auth_Server"); + const nameInput = getServerNameInput(); + await user.type(nameInput, "No_Auth_Server"); - const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com"); - await user.type(urlInput, "https://example.com/mcp"); + const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com"); + await user.type(urlInput, "https://example.com/mcp"); - await selectAntOption("Authentication", "None"); + await selectAntOption("Authentication", "None"); - vi.mocked(networking.createMCPServer).mockResolvedValue({ - server_id: "new-server-1", - server_name: "No_Auth_Server", - alias: "No_Auth_Server", - url: "https://example.com/mcp", - transport: "http", - auth_type: "none", - created_at: "2024-01-01T00:00:00Z", - created_by: "user-1", - updated_at: "2024-01-01T00:00:00Z", - updated_by: "user-1", - }); + vi.mocked(networking.createMCPServer).mockResolvedValue({ + server_id: "new-server-1", + server_name: "No_Auth_Server", + alias: "No_Auth_Server", + url: "https://example.com/mcp", + transport: "http", + auth_type: "none", + created_at: "2024-01-01T00:00:00Z", + created_by: "user-1", + updated_at: "2024-01-01T00:00:00Z", + updated_by: "user-1", + }); - const submitButton = screen.getByRole("button", { name: "Add MCP Server" }); - await act(async () => { - fireEvent.click(submitButton); - }); + const submitButton = screen.getByRole("button", { name: "Add MCP Server" }); + await act(async () => { + fireEvent.click(submitButton); + }); - await waitFor(() => { - expect(networking.createMCPServer).toHaveBeenCalledTimes(1); - }); + await waitFor(() => { + expect(networking.createMCPServer).toHaveBeenCalledTimes(1); + }); - const [, payload] = vi.mocked(networking.createMCPServer).mock.calls[0]; - expect(payload.auth_type).toBe("none"); - // No credentials should be sent for "none" auth - expect(payload.credentials).toBeUndefined(); - }, - ); + const [, payload] = vi.mocked(networking.createMCPServer).mock.calls[0]; + expect(payload.auth_type).toBe("none"); + // No credentials should be sent for "none" auth + expect(payload.credentials).toBeUndefined(); + }); }); describe("when OAuth interactive auth is selected", () => { diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx index fb149458f61..f24b4e77e16 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx @@ -222,634 +222,640 @@ describe("TeamInfoView", () => { vi.clearAllMocks(); }); - it("should render", async () => { - vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData()); + describe("display and rendering", () => { + it("should render", async () => { + vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData()); - renderWithProviders(); + renderWithProviders(); - await waitFor(() => { - const teamNameElements = screen.queryAllByText("Test Team"); - expect(teamNameElements.length).toBeGreaterThan(0); - }); - }); - - it("should display loading state while fetching team data", () => { - vi.mocked(networking.teamInfoCall).mockImplementation(() => new Promise(() => { })); - - renderWithProviders(); - - expect(screen.getByText("Loading...")).toBeInTheDocument(); - }); - - it("should display error message when team is not found", async () => { - vi.mocked(networking.teamInfoCall).mockResolvedValue({ - team_id: "123", - team_info: null as any, - keys: [], - team_memberships: [], + await waitFor(() => { + const teamNameElements = screen.queryAllByText("Test Team"); + expect(teamNameElements.length).toBeGreaterThan(0); + }); }); - renderWithProviders(); + it("should display loading state while fetching team data", () => { + vi.mocked(networking.teamInfoCall).mockImplementation(() => new Promise(() => {})); - await waitFor(() => { - expect(screen.getByText("Team not found")).toBeInTheDocument(); - }); - }); + renderWithProviders(); - it("should display budget information in overview", async () => { - vi.mocked(networking.teamInfoCall).mockResolvedValue( - createMockTeamData({ - max_budget: 1000, - spend: 250.5, - budget_duration: "30d", - }) - ); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByText("Budget Status")).toBeInTheDocument(); - }); - }); - - it("should display guardrails in overview when present", async () => { - vi.mocked(networking.teamInfoCall).mockResolvedValue( - createMockTeamData({ - guardrails: ["guardrail1", "guardrail2"], - }) - ); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByText("Guardrails")).toBeInTheDocument(); - }); - }); - - it("should display policies in overview when present", async () => { - vi.mocked(networking.teamInfoCall).mockResolvedValue( - createMockTeamData({ - policies: ["policy1"], - }) - ); - vi.mocked(networking.getPolicyInfoWithGuardrails).mockResolvedValue({ - resolved_guardrails: ["guardrail1"], + expect(screen.getByText("Loading...")).toBeInTheDocument(); }); - renderWithProviders(); + it("should display error message when team is not found", async () => { + vi.mocked(networking.teamInfoCall).mockResolvedValue({ + team_id: "123", + team_info: null as any, + keys: [], + team_memberships: [], + }); - await waitFor(() => { - expect(screen.getByText("Policies")).toBeInTheDocument(); - }); - }); + renderWithProviders(); - it("should show members tab when user can edit team", async () => { - vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData()); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByRole("tab", { name: "Members" })).toBeInTheDocument(); - }); - }); - - it("should not show members tab when user cannot edit team", async () => { - vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData()); - - renderWithProviders(); - - await waitFor(() => { - const teamNameElements = screen.queryAllByText("Test Team"); - expect(teamNameElements.length).toBeGreaterThan(0); + await waitFor(() => { + expect(screen.getByText("Team not found")).toBeInTheDocument(); + }); }); - expect(screen.queryByRole("tab", { name: "Members" })).not.toBeInTheDocument(); - }); - - it("should show settings tab when user can edit team", async () => { - vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData()); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByRole("tab", { name: "Settings" })).toBeInTheDocument(); - }); - }); - - it("should navigate to settings tab when clicked", async () => { - const user = userEvent.setup({ delay: null }); - vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData()); - - renderWithProviders(); - - await waitFor(() => { - const teamNameElements = screen.queryAllByText("Test Team"); - expect(teamNameElements.length).toBeGreaterThan(0); - }); - - const settingsTab = screen.getByRole("tab", { name: "Settings" }); - await user.click(settingsTab); - - await waitFor(() => { - expect(screen.getByText("Team Settings")).toBeInTheDocument(); - }); - }); - - it("should open edit mode when edit button is clicked", async () => { - const user = userEvent.setup({ delay: null }); - vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData()); - - renderWithProviders(); - - await waitFor(() => { - const teamNameElements = screen.queryAllByText("Test Team"); - expect(teamNameElements.length).toBeGreaterThan(0); - }); - - const settingsTab = screen.getByRole("tab", { name: "Settings" }); - await user.click(settingsTab); - - await waitFor(() => { - expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument(); - }); - - const editButton = screen.getByRole("button", { name: /edit settings/i }); - await user.click(editButton); - - await waitFor(() => { - expect(screen.getByLabelText("Team Name")).toBeInTheDocument(); - }); - }); - - it("should close edit mode when cancel button is clicked", { timeout: 15000 }, async () => { - const user = userEvent.setup({ delay: null }); - vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData()); - - renderWithProviders(); - - await waitFor(() => { - const teamNameElements = screen.queryAllByText("Test Team"); - expect(teamNameElements.length).toBeGreaterThan(0); - }); - - const settingsTab = screen.getByRole("tab", { name: "Settings" }); - await user.click(settingsTab); - - await waitFor(() => { - expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument(); - }); - - const editButton = screen.getByRole("button", { name: /edit settings/i }); - await user.click(editButton); - - await waitFor(() => { - expect(screen.getByLabelText("Team Name")).toBeInTheDocument(); - }); - - const cancelButton = screen.getByRole("button", { name: /cancel/i }); - await user.click(cancelButton); - - await waitFor(() => { - expect(screen.queryByLabelText("Team Name")).not.toBeInTheDocument(); - }); - }); - - it("should call onClose when back button is clicked", async () => { - const user = userEvent.setup({ delay: null }); - const onClose = vi.fn(); - vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData()); - - renderWithProviders(); - - await waitFor(() => { - const teamNameElements = screen.queryAllByText("Test Team"); - expect(teamNameElements.length).toBeGreaterThan(0); - }); - - const backButton = screen.getByRole("button", { name: /back to teams/i }); - await user.click(backButton); - - expect(onClose).toHaveBeenCalled(); - }); - - it("should copy team ID to clipboard when copy button is clicked", async () => { - const user = userEvent.setup({ delay: null }); - vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData()); - - renderWithProviders(); - - await waitFor(() => { - const teamNameElements = screen.queryAllByText("Test Team"); - expect(teamNameElements.length).toBeGreaterThan(0); - }); - - const copyButtons = screen.getAllByRole("button"); - const copyButton = copyButtons.find((btn) => btn.querySelector("svg")); - expect(copyButton).toBeTruthy(); - - if (copyButton) { - await user.click(copyButton); - } - }); - - it("should disable secret manager settings for non-premium users", async () => { - const user = userEvent.setup({ delay: null }); - vi.mocked(networking.teamInfoCall).mockResolvedValue( - createMockTeamData({ - metadata: { - secret_manager_settings: { provider: "aws", secret_id: "abc" }, - }, - }) - ); - - renderWithProviders(); - - await waitFor(() => { - const teamNameElements = screen.queryAllByText("Test Team"); - expect(teamNameElements.length).toBeGreaterThan(0); - }); - - const settingsTab = screen.getByRole("tab", { name: "Settings" }); - await user.click(settingsTab); - - await waitFor(() => { - expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument(); - }); - - const editButton = screen.getByRole("button", { name: /edit settings/i }); - await user.click(editButton); - - const secretField = await screen.findByPlaceholderText( - '{"namespace": "admin", "mount": "secret", "path_prefix": "litellm"}' - ); - expect(secretField).toBeDisabled(); - }); - - it("should allow premium users to edit secret manager settings", async () => { - const user = userEvent.setup({ delay: null }); - vi.mocked(networking.teamInfoCall).mockResolvedValue( - createMockTeamData({ - metadata: { - secret_manager_settings: { provider: "aws", secret_id: "abc" }, - }, - }) - ); - vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any); - - renderWithProviders(); - - await waitFor(() => { - const teamNameElements = screen.queryAllByText("Test Team"); - expect(teamNameElements.length).toBeGreaterThan(0); - }); - - const settingsTab = screen.getByRole("tab", { name: "Settings" }); - await user.click(settingsTab); - - await waitFor(() => { - expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument(); - }); - - const editButton = screen.getByRole("button", { name: /edit settings/i }); - await user.click(editButton); - - const secretField = await screen.findByPlaceholderText( - '{"namespace": "admin", "mount": "secret", "path_prefix": "litellm"}' - ); - expect(secretField).not.toBeDisabled(); - }); - - it("should add team member when form is submitted", async () => { - const user = userEvent.setup({ delay: null }); - const onUpdate = vi.fn(); - const teamData = createMockTeamData(); - vi.mocked(networking.teamInfoCall).mockResolvedValue(teamData); - vi.mocked(networking.teamMemberAddCall).mockResolvedValue({} as any); - - renderWithProviders(); - - await waitFor(() => { - const teamNameElements = screen.queryAllByText("Test Team"); - expect(teamNameElements.length).toBeGreaterThan(0); - }); - - const membersTab = screen.getByRole("tab", { name: "Members" }); - await user.click(membersTab); - - await waitFor(() => { - expect(screen.getByRole("button", { name: /add member/i })).toBeInTheDocument(); - }); - - const addButton = screen.getByRole("button", { name: /add member/i }); - await user.click(addButton); - - await waitFor(() => { - expect(screen.getByRole("button", { name: "Submit" })).toBeInTheDocument(); - }); - - const submitButton = screen.getByRole("button", { name: "Submit" }); - await user.click(submitButton); - - await waitFor(() => { - expect(networking.teamMemberAddCall).toHaveBeenCalled(); - }); - }); - - it("should display team member budget information when present", async () => { - vi.mocked(networking.teamInfoCall).mockResolvedValue( - createMockTeamData({ - team_member_budget_table: { - max_budget: 500, + it("should display budget information in overview", async () => { + vi.mocked(networking.teamInfoCall).mockResolvedValue( + createMockTeamData({ + max_budget: 1000, + spend: 250.5, budget_duration: "30d", - tpm_limit: 5000, - rpm_limit: 50, - }, - }) - ); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByText("Budget Status")).toBeInTheDocument(); - }); - }); - - it("should display virtual keys information", async () => { - vi.mocked(networking.teamInfoCall).mockResolvedValue({ - ...createMockTeamData(), - keys: [ - { user_id: "user1", token: "key1" }, - { token: "key2" }, - ], - }); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByRole("tab", { name: "Virtual Keys" })).toBeInTheDocument(); - }); - }); - - it("should show Virtual Keys tab when user cannot edit team", async () => { - vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData()); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByRole("tab", { name: "Virtual Keys" })).toBeInTheDocument(); - }); - }); - - it("should display X Members in Virtual Keys tab when navigated to", async () => { - const user = userEvent.setup(); - vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData()); - const fiveKeys = Array.from({ length: 5 }, (_, i) => ({ - token: `sk-${i}`, - token_id: `key-${i}`, - key_alias: `key_${i}`, - key_name: `sk-...${i}`, - user_id: `user-${i}`, - organization_id: null, - user: { user_id: `user-${i}`, user_email: `user${i}@test.com` }, - created_at: "2024-01-01T00:00:00Z", - team_id: "123", - spend: 0, - max_budget: 100, - models: ["gpt-4"], - })); - mockUseKeys.mockReturnValue({ - data: { keys: fiveKeys, total_count: 5, current_page: 1, total_pages: 1 }, - isPending: false, - isFetching: false, - refetch: vi.fn(), - } as any); - - renderWithProviders(); - - await waitFor(() => { - const teamNameElements = screen.queryAllByText("Test Team"); - expect(teamNameElements.length).toBeGreaterThan(0); - }); - - const virtualKeysTab = screen.getByRole("tab", { name: "Virtual Keys" }); - await user.click(virtualKeysTab); - - await waitFor(() => { - expect(screen.getByText("Page 1 of 1")).toBeInTheDocument(); - }); - }); - - it("should show Filters and pagination controls in Virtual Keys tab", async () => { - const user = userEvent.setup(); - vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData()); - mockUseKeys.mockReturnValue({ - data: { - keys: [ - { - token: "sk-1", - token_id: "key-1", - key_alias: "key1", - key_name: "sk-...1", - user_id: "user-1", - organization_id: null, - user: { user_id: "user-1", user_email: "user1@test.com" }, - created_at: "2024-01-01T00:00:00Z", - team_id: "123", - spend: 0, - max_budget: 100, - models: ["gpt-4"], - }, - ], - total_count: 1, - current_page: 1, - total_pages: 1, - }, - isPending: false, - isFetching: false, - refetch: vi.fn(), - } as any); - - renderWithProviders(); - - await waitFor(() => { - const teamNameElements = screen.queryAllByText("Test Team"); - expect(teamNameElements.length).toBeGreaterThan(0); - }); - - const virtualKeysTab = screen.getByRole("tab", { name: "Virtual Keys" }); - await user.click(virtualKeysTab); - - await waitFor(() => { - expect(screen.getByRole("button", { name: "Filters" })).toBeInTheDocument(); - }); - expect(screen.getByRole("button", { name: "Reset Filters" })).toBeInTheDocument(); - expect(screen.getByText("Page 1 of 1")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Previous" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Next" })).toBeInTheDocument(); - }); - - it("should display object permissions when present", async () => { - vi.mocked(networking.teamInfoCall).mockResolvedValue( - createMockTeamData({ - object_permission: { - object_permission_id: "perm-1", - mcp_servers: ["server1"], - vector_stores: ["store1"], - }, - }) - ); - - renderWithProviders(); - - await waitFor(() => { - const teamNameElements = screen.queryAllByText("Test Team"); - expect(teamNameElements.length).toBeGreaterThan(0); - }); - }); - - it("should display soft budget in settings view when present", async () => { - const user = userEvent.setup({ delay: null }); - vi.mocked(networking.teamInfoCall).mockResolvedValue( - createMockTeamData({ - soft_budget: 500.75, - max_budget: 1000, - }) - ); - - renderWithProviders(); - - await waitFor(() => { - const teamNameElements = screen.queryAllByText("Test Team"); - expect(teamNameElements.length).toBeGreaterThan(0); - }); - - const settingsTab = screen.getByRole("tab", { name: "Settings" }); - await user.click(settingsTab); - - await waitFor(() => { - expect(screen.getByText("Team Settings")).toBeInTheDocument(); - }); - - await waitFor(() => { - expect(screen.getByText(/Soft Budget:/)).toBeInTheDocument(); - expect(screen.getByText(/\$500\.75/)).toBeInTheDocument(); - }); - }); - - it("should open Settings tab by default when editTeam is true and user can edit", async () => { - vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData()); - - renderWithProviders(); - - await waitFor(() => { - const teamNameElements = screen.queryAllByText("Test Team"); - expect(teamNameElements.length).toBeGreaterThan(0); - }); - - expect(screen.getByText("Team Settings")).toBeInTheDocument(); - }); - - it("should open Overview tab by default when editTeam is false", async () => { - vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData()); - - renderWithProviders(); - - await waitFor(() => { - const teamNameElements = screen.queryAllByText("Test Team"); - expect(teamNameElements.length).toBeGreaterThan(0); - }); - - expect(screen.getByText("Budget Status")).toBeInTheDocument(); - }); - - it("should open Overview tab by default when editTeam is true but user cannot edit", async () => { - vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData()); - - renderWithProviders( - - ); - - await waitFor(() => { - const teamNameElements = screen.queryAllByText("Test Team"); - expect(teamNameElements.length).toBeGreaterThan(0); - }); - - expect(screen.getByText("Budget Status")).toBeInTheDocument(); - }); - - it("should display soft budget alerting emails in settings view when present", async () => { - const user = userEvent.setup({ delay: null }); - vi.mocked(networking.teamInfoCall).mockResolvedValue( - createMockTeamData({ - metadata: { - soft_budget_alerting_emails: ["alert1@test.com", "alert2@test.com"], - }, - }) - ); - - renderWithProviders(); - - await waitFor(() => { - const teamNameElements = screen.queryAllByText("Test Team"); - expect(teamNameElements.length).toBeGreaterThan(0); - }); - - const settingsTab = screen.getByRole("tab", { name: "Settings" }); - await user.click(settingsTab); - - await waitFor(() => { - expect(screen.getByText("Team Settings")).toBeInTheDocument(); - }); - - await waitFor(() => { - expect(screen.getByText(/Soft Budget Alerting Emails:/)).toBeInTheDocument(); - expect(screen.getByText(/alert1@test\.com, alert2@test\.com/)).toBeInTheDocument(); - }); - }); - - it("should pass access_group_ids to teamUpdateCall when saving team settings", async () => { - const user = userEvent.setup({ delay: null }); - const accessGroupIds = ["ag-1", "ag-2"]; - vi.mocked(networking.teamInfoCall).mockResolvedValue( - createMockTeamData({ - access_group_ids: accessGroupIds, - models: ["gpt-4"], - }) - ); - vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any); - - renderWithProviders(); - - await waitFor(() => { - const teamNameElements = screen.queryAllByText("Test Team"); - expect(teamNameElements.length).toBeGreaterThan(0); - }); - - const settingsTab = screen.getByRole("tab", { name: "Settings" }); - await user.click(settingsTab); - - await waitFor(() => { - expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument(); - }); - - const editButton = screen.getByRole("button", { name: /edit settings/i }); - await user.click(editButton); - - await waitFor(() => { - expect(screen.getByLabelText("Team Name")).toBeInTheDocument(); - }); - - const saveButton = screen.getByRole("button", { name: /save changes/i }); - await user.click(saveButton); - - await waitFor(() => { - expect(networking.teamUpdateCall).toHaveBeenCalledWith( - "test-token", - expect.objectContaining({ - access_group_ids: accessGroupIds, - team_id: "123", }) ); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("Budget Status")).toBeInTheDocument(); + }); + }); + + it("should display guardrails in overview when present", async () => { + vi.mocked(networking.teamInfoCall).mockResolvedValue( + createMockTeamData({ + guardrails: ["guardrail1", "guardrail2"], + }) + ); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("Guardrails")).toBeInTheDocument(); + }); + }); + + it("should display policies in overview when present", async () => { + vi.mocked(networking.teamInfoCall).mockResolvedValue( + createMockTeamData({ + policies: ["policy1"], + }) + ); + vi.mocked(networking.getPolicyInfoWithGuardrails).mockResolvedValue({ + resolved_guardrails: ["guardrail1"], + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("Policies")).toBeInTheDocument(); + }); + }); + + it("should display team member budget information when present", async () => { + vi.mocked(networking.teamInfoCall).mockResolvedValue( + createMockTeamData({ + team_member_budget_table: { + max_budget: 500, + budget_duration: "30d", + tpm_limit: 5000, + rpm_limit: 50, + }, + }) + ); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("Budget Status")).toBeInTheDocument(); + }); + }); + + it("should display virtual keys information", async () => { + vi.mocked(networking.teamInfoCall).mockResolvedValue({ + ...createMockTeamData(), + keys: [ + { user_id: "user1", token: "key1" }, + { token: "key2" }, + ], + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByRole("tab", { name: "Virtual Keys" })).toBeInTheDocument(); + }); + }); + + it("should display object permissions when present", async () => { + vi.mocked(networking.teamInfoCall).mockResolvedValue( + createMockTeamData({ + object_permission: { + object_permission_id: "perm-1", + mcp_servers: ["server1"], + vector_stores: ["store1"], + }, + }) + ); + + renderWithProviders(); + + await waitFor(() => { + const teamNameElements = screen.queryAllByText("Test Team"); + expect(teamNameElements.length).toBeGreaterThan(0); + }); + }); + + it("should open Settings tab by default when editTeam is true and user can edit", async () => { + vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData()); + + renderWithProviders(); + + await waitFor(() => { + const teamNameElements = screen.queryAllByText("Test Team"); + expect(teamNameElements.length).toBeGreaterThan(0); + }); + + expect(screen.getByText("Team Settings")).toBeInTheDocument(); + }); + + it("should open Overview tab by default when editTeam is false", async () => { + vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData()); + + renderWithProviders(); + + await waitFor(() => { + const teamNameElements = screen.queryAllByText("Test Team"); + expect(teamNameElements.length).toBeGreaterThan(0); + }); + + expect(screen.getByText("Budget Status")).toBeInTheDocument(); + }); + + it("should open Overview tab by default when editTeam is true but user cannot edit", async () => { + vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData()); + + renderWithProviders( + + ); + + await waitFor(() => { + const teamNameElements = screen.queryAllByText("Test Team"); + expect(teamNameElements.length).toBeGreaterThan(0); + }); + + expect(screen.getByText("Budget Status")).toBeInTheDocument(); + }); + }); + + describe("tabs and navigation", () => { + it("should show members tab when user can edit team", async () => { + vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData()); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByRole("tab", { name: "Members" })).toBeInTheDocument(); + }); + }); + + it("should not show members tab when user cannot edit team", async () => { + vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData()); + + renderWithProviders(); + + await waitFor(() => { + const teamNameElements = screen.queryAllByText("Test Team"); + expect(teamNameElements.length).toBeGreaterThan(0); + }); + + expect(screen.queryByRole("tab", { name: "Members" })).not.toBeInTheDocument(); + }); + + it("should show settings tab when user can edit team", async () => { + vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData()); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByRole("tab", { name: "Settings" })).toBeInTheDocument(); + }); + }); + + it("should navigate to settings tab when clicked", async () => { + const user = userEvent.setup({ delay: null }); + vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData()); + + renderWithProviders(); + + await waitFor(() => { + const teamNameElements = screen.queryAllByText("Test Team"); + expect(teamNameElements.length).toBeGreaterThan(0); + }); + + const settingsTab = screen.getByRole("tab", { name: "Settings" }); + await user.click(settingsTab); + + await waitFor(() => { + expect(screen.getByText("Team Settings")).toBeInTheDocument(); + }); + }); + + it("should call onClose when back button is clicked", async () => { + const user = userEvent.setup({ delay: null }); + const onClose = vi.fn(); + vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData()); + + renderWithProviders(); + + await waitFor(() => { + const teamNameElements = screen.queryAllByText("Test Team"); + expect(teamNameElements.length).toBeGreaterThan(0); + }); + + const backButton = screen.getByRole("button", { name: /back to teams/i }); + await user.click(backButton); + + expect(onClose).toHaveBeenCalled(); + }); + + it("should copy team ID to clipboard when copy button is clicked", async () => { + const user = userEvent.setup({ delay: null }); + vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData()); + + renderWithProviders(); + + await waitFor(() => { + const teamNameElements = screen.queryAllByText("Test Team"); + expect(teamNameElements.length).toBeGreaterThan(0); + }); + + const copyButtons = screen.getAllByRole("button"); + const copyButton = copyButtons.find((btn) => btn.querySelector("svg")); + expect(copyButton).toBeTruthy(); + + if (copyButton) { + await user.click(copyButton); + } + }); + + it("should show Virtual Keys tab when user cannot edit team", async () => { + vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData()); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByRole("tab", { name: "Virtual Keys" })).toBeInTheDocument(); + }); + }); + + it("should display X Members in Virtual Keys tab when navigated to", async () => { + const user = userEvent.setup(); + vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData()); + const fiveKeys = Array.from({ length: 5 }, (_, i) => ({ + token: `sk-${i}`, + token_id: `key-${i}`, + key_alias: `key_${i}`, + key_name: `sk-...${i}`, + user_id: `user-${i}`, + organization_id: null, + user: { user_id: `user-${i}`, user_email: `user${i}@test.com` }, + created_at: "2024-01-01T00:00:00Z", + team_id: "123", + spend: 0, + max_budget: 100, + models: ["gpt-4"], + })); + mockUseKeys.mockReturnValue({ + data: { keys: fiveKeys, total_count: 5, current_page: 1, total_pages: 1 }, + isPending: false, + isFetching: false, + refetch: vi.fn(), + } as any); + + renderWithProviders(); + + await waitFor(() => { + const teamNameElements = screen.queryAllByText("Test Team"); + expect(teamNameElements.length).toBeGreaterThan(0); + }); + + const virtualKeysTab = screen.getByRole("tab", { name: "Virtual Keys" }); + await user.click(virtualKeysTab); + + await waitFor(() => { + expect(screen.getByText("Page 1 of 1")).toBeInTheDocument(); + }); + }); + + it("should show Filters and pagination controls in Virtual Keys tab", async () => { + const user = userEvent.setup(); + vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData()); + mockUseKeys.mockReturnValue({ + data: { + keys: [ + { + token: "sk-1", + token_id: "key-1", + key_alias: "key1", + key_name: "sk-...1", + user_id: "user-1", + organization_id: null, + user: { user_id: "user-1", user_email: "user1@test.com" }, + created_at: "2024-01-01T00:00:00Z", + team_id: "123", + spend: 0, + max_budget: 100, + models: ["gpt-4"], + }, + ], + total_count: 1, + current_page: 1, + total_pages: 1, + }, + isPending: false, + isFetching: false, + refetch: vi.fn(), + } as any); + + renderWithProviders(); + + await waitFor(() => { + const teamNameElements = screen.queryAllByText("Test Team"); + expect(teamNameElements.length).toBeGreaterThan(0); + }); + + const virtualKeysTab = screen.getByRole("tab", { name: "Virtual Keys" }); + await user.click(virtualKeysTab); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Filters" })).toBeInTheDocument(); + }); + expect(screen.getByRole("button", { name: "Reset Filters" })).toBeInTheDocument(); + expect(screen.getByText("Page 1 of 1")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Previous" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Next" })).toBeInTheDocument(); + }); + }); + + describe("settings and editing", () => { + it("should open edit mode when edit button is clicked", async () => { + const user = userEvent.setup({ delay: null }); + vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData()); + + renderWithProviders(); + + await waitFor(() => { + const teamNameElements = screen.queryAllByText("Test Team"); + expect(teamNameElements.length).toBeGreaterThan(0); + }); + + const settingsTab = screen.getByRole("tab", { name: "Settings" }); + await user.click(settingsTab); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument(); + }); + + const editButton = screen.getByRole("button", { name: /edit settings/i }); + await user.click(editButton); + + await waitFor(() => { + expect(screen.getByLabelText("Team Name")).toBeInTheDocument(); + }); + }); + + it("should close edit mode when cancel button is clicked", async () => { + const user = userEvent.setup({ delay: null }); + vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData()); + + renderWithProviders(); + + await waitFor(() => { + const teamNameElements = screen.queryAllByText("Test Team"); + expect(teamNameElements.length).toBeGreaterThan(0); + }); + + const settingsTab = screen.getByRole("tab", { name: "Settings" }); + await user.click(settingsTab); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument(); + }); + + const editButton = screen.getByRole("button", { name: /edit settings/i }); + await user.click(editButton); + + await waitFor(() => { + expect(screen.getByLabelText("Team Name")).toBeInTheDocument(); + }); + + const cancelButton = screen.getByRole("button", { name: /cancel/i }); + await user.click(cancelButton); + + await waitFor(() => { + expect(screen.queryByLabelText("Team Name")).not.toBeInTheDocument(); + }); + }); + + it("should disable secret manager settings for non-premium users", async () => { + const user = userEvent.setup({ delay: null }); + vi.mocked(networking.teamInfoCall).mockResolvedValue( + createMockTeamData({ + metadata: { + secret_manager_settings: { provider: "aws", secret_id: "abc" }, + }, + }) + ); + + renderWithProviders(); + + await waitFor(() => { + const teamNameElements = screen.queryAllByText("Test Team"); + expect(teamNameElements.length).toBeGreaterThan(0); + }); + + const settingsTab = screen.getByRole("tab", { name: "Settings" }); + await user.click(settingsTab); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument(); + }); + + const editButton = screen.getByRole("button", { name: /edit settings/i }); + await user.click(editButton); + + const secretField = await screen.findByPlaceholderText( + '{"namespace": "admin", "mount": "secret", "path_prefix": "litellm"}' + ); + expect(secretField).toBeDisabled(); + }); + + it("should allow premium users to edit secret manager settings", async () => { + const user = userEvent.setup({ delay: null }); + vi.mocked(networking.teamInfoCall).mockResolvedValue( + createMockTeamData({ + metadata: { + secret_manager_settings: { provider: "aws", secret_id: "abc" }, + }, + }) + ); + vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any); + + renderWithProviders(); + + await waitFor(() => { + const teamNameElements = screen.queryAllByText("Test Team"); + expect(teamNameElements.length).toBeGreaterThan(0); + }); + + const settingsTab = screen.getByRole("tab", { name: "Settings" }); + await user.click(settingsTab); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument(); + }); + + const editButton = screen.getByRole("button", { name: /edit settings/i }); + await user.click(editButton); + + const secretField = await screen.findByPlaceholderText( + '{"namespace": "admin", "mount": "secret", "path_prefix": "litellm"}' + ); + expect(secretField).not.toBeDisabled(); + }); + + it("should add team member when form is submitted", async () => { + const user = userEvent.setup({ delay: null }); + const onUpdate = vi.fn(); + const teamData = createMockTeamData(); + vi.mocked(networking.teamInfoCall).mockResolvedValue(teamData); + vi.mocked(networking.teamMemberAddCall).mockResolvedValue({} as any); + + renderWithProviders(); + + await waitFor(() => { + const teamNameElements = screen.queryAllByText("Test Team"); + expect(teamNameElements.length).toBeGreaterThan(0); + }); + + const membersTab = screen.getByRole("tab", { name: "Members" }); + await user.click(membersTab); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /add member/i })).toBeInTheDocument(); + }); + + const addButton = screen.getByRole("button", { name: /add member/i }); + await user.click(addButton); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Submit" })).toBeInTheDocument(); + }); + + const submitButton = screen.getByRole("button", { name: "Submit" }); + await user.click(submitButton); + + await waitFor(() => { + expect(networking.teamMemberAddCall).toHaveBeenCalled(); + }); + }); + + it("should display soft budget in settings view when present", async () => { + const user = userEvent.setup({ delay: null }); + vi.mocked(networking.teamInfoCall).mockResolvedValue( + createMockTeamData({ + soft_budget: 500.75, + max_budget: 1000, + }) + ); + + renderWithProviders(); + + await waitFor(() => { + const teamNameElements = screen.queryAllByText("Test Team"); + expect(teamNameElements.length).toBeGreaterThan(0); + }); + + const settingsTab = screen.getByRole("tab", { name: "Settings" }); + await user.click(settingsTab); + + await waitFor(() => { + expect(screen.getByText("Team Settings")).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getByText(/Soft Budget:/)).toBeInTheDocument(); + expect(screen.getByText(/\$500\.75/)).toBeInTheDocument(); + }); + }); + + it("should display soft budget alerting emails in settings view when present", async () => { + const user = userEvent.setup({ delay: null }); + vi.mocked(networking.teamInfoCall).mockResolvedValue( + createMockTeamData({ + metadata: { + soft_budget_alerting_emails: ["alert1@test.com", "alert2@test.com"], + }, + }) + ); + + renderWithProviders(); + + await waitFor(() => { + const teamNameElements = screen.queryAllByText("Test Team"); + expect(teamNameElements.length).toBeGreaterThan(0); + }); + + const settingsTab = screen.getByRole("tab", { name: "Settings" }); + await user.click(settingsTab); + + await waitFor(() => { + expect(screen.getByText("Team Settings")).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getByText(/Soft Budget Alerting Emails:/)).toBeInTheDocument(); + expect(screen.getByText(/alert1@test\.com, alert2@test\.com/)).toBeInTheDocument(); + }); + }); + + it("should pass access_group_ids to teamUpdateCall when saving team settings", async () => { + const user = userEvent.setup({ delay: null }); + const accessGroupIds = ["ag-1", "ag-2"]; + vi.mocked(networking.teamInfoCall).mockResolvedValue( + createMockTeamData({ + access_group_ids: accessGroupIds, + models: ["gpt-4"], + }) + ); + vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any); + + renderWithProviders(); + + await waitFor(() => { + const teamNameElements = screen.queryAllByText("Test Team"); + expect(teamNameElements.length).toBeGreaterThan(0); + }); + + const settingsTab = screen.getByRole("tab", { name: "Settings" }); + await user.click(settingsTab); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument(); + }); + + const editButton = screen.getByRole("button", { name: /edit settings/i }); + await user.click(editButton); + + await waitFor(() => { + expect(screen.getByLabelText("Team Name")).toBeInTheDocument(); + }); + + const saveButton = screen.getByRole("button", { name: /save changes/i }); + await user.click(saveButton); + + await waitFor(() => { + expect(networking.teamUpdateCall).toHaveBeenCalledWith( + "test-token", + expect.objectContaining({ + access_group_ids: accessGroupIds, + team_id: "123", + }) + ); + }); }); }); }); diff --git a/ui/litellm-dashboard/vitest.config.ts b/ui/litellm-dashboard/vitest.config.ts index 7c52b88d3b6..d2b6e7b43bf 100644 --- a/ui/litellm-dashboard/vitest.config.ts +++ b/ui/litellm-dashboard/vitest.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ setupFiles: ["tests/setupTests.ts"], globals: true, css: true, // lets you import CSS/modules without extra mocks - testTimeout: 10000, + testTimeout: 30000, coverage: { provider: "v8", reporter: ["text", "lcov"], From 92dbd2c4919f5a95d841893c761328b80ea43ca0 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 9 Apr 2026 20:54:36 -0700 Subject: [PATCH 035/130] address greptile review feedback (greploop iteration 1) Remove leftover 10000ms per-test timeout in add_model_tab.test.tsx that was missed in the initial sweep. The test now inherits the 30000ms global. --- .../src/components/add_model/add_model_tab.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 59970547c1a..0a8d2d9124e 100644 --- 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 @@ -175,7 +175,7 @@ describe("Add Model Tab", () => { ); expect(await screen.findByRole("tab", { name: "Add Model" })).toBeInTheDocument(); - }, 10000); // This test is flaky, adding a timeout until we find a better solution + }); it("should display both Add Model and Add Auto Router tabs", async () => { const props = createTestProps(); From d0168bcff10e9550fa9fda815db8723e3f603f96 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 9 Apr 2026 20:57:03 -0700 Subject: [PATCH 036/130] ci: retrigger e2e From ce0b57b4ffc0c56250a97f192188fca2bd5c946f Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 9 Apr 2026 21:04:34 -0700 Subject: [PATCH 037/130] [Docs] Add missing MCP per-user token env vars to config_settings MCP_PER_USER_TOKEN_DEFAULT_TTL and MCP_PER_USER_TOKEN_EXPIRY_BUFFER_SECONDS were added in #25441 but not documented, causing test_env_keys.py to fail. --- docs/my-website/docs/proxy/config_settings.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/my-website/docs/proxy/config_settings.md b/docs/my-website/docs/proxy/config_settings.md index 88cbcac52cc..c64d475fdaa 100644 --- a/docs/my-website/docs/proxy/config_settings.md +++ b/docs/my-website/docs/proxy/config_settings.md @@ -602,6 +602,8 @@ router_settings: | MCP_OAUTH2_TOKEN_CACHE_MAX_SIZE | Maximum number of entries in MCP OAuth2 token cache. Default is 200 | MCP_OAUTH2_TOKEN_CACHE_MIN_TTL | Minimum TTL in seconds for MCP OAuth2 token cache. Default is 10 | MCP_OAUTH2_TOKEN_EXPIRY_BUFFER_SECONDS | Seconds to subtract from token expiry when computing cache TTL. Default is 60 +| MCP_PER_USER_TOKEN_DEFAULT_TTL | Default TTL in seconds for per-user MCP OAuth tokens stored in Redis. Default is 43200 (12 hours) +| MCP_PER_USER_TOKEN_EXPIRY_BUFFER_SECONDS | Seconds to subtract from per-user MCP OAuth token expiry when computing Redis TTL. Default is 60 | DEFAULT_MOCK_RESPONSE_COMPLETION_TOKEN_COUNT | Default token count for mock response completions. Default is 20 | DEFAULT_MOCK_RESPONSE_PROMPT_TOKEN_COUNT | Default token count for mock response prompts. Default is 10 | DEFAULT_MODEL_CREATED_AT_TIME | Default creation timestamp for models. Default is 1677610602 From ee374c48848f16bc39ff6c7abc536a6553f6754a Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 9 Apr 2026 21:09:29 -0700 Subject: [PATCH 038/130] ci: pass LITELLM_LICENSE to e2e_ui_testing proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Key regeneration is an enterprise feature — without LITELLM_LICENSE the endpoint returns a 403 and the Playwright test for "Regenerate key" never sees the success view. Other CircleCI jobs already pass this secret; the e2e_ui_testing job was missing it. --- .circleci/config.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 2b0a6924cce..810727b0110 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3201,6 +3201,7 @@ jobs: name: Start LiteLLM proxy environment: LITELLM_MASTER_KEY: "sk-1234" + LITELLM_LICENSE: ${LITELLM_LICENSE} MOCK_LLM_URL: "http://127.0.0.1:8090/v1" DISABLE_SCHEMA_UPDATE: "true" SERVER_ROOT_PATH: "" From cc43d09d79833fc69fbaf4b59ddc2ac5486c2e82 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Thu, 9 Apr 2026 21:19:02 -0700 Subject: [PATCH 039/130] Potential fix for pull request finding 'CodeQL / Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .../src/components/organisms/RegenerateKeyModal.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.tsx b/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.tsx index babbf9989e6..bbe3edceb67 100644 --- a/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.tsx +++ b/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.tsx @@ -26,9 +26,6 @@ export function RegenerateKeyModal({ selectedToken, visible, onClose, onKeyUpdat const [isRegenerating, setIsRegenerating] = useState(false); const [copied, setCopied] = useState(false); - // Track whether this is the user's own authentication key - const [isOwnKey, setIsOwnKey] = useState(false); - // Keep track of the current valid access token locally const [currentAccessToken, setCurrentAccessToken] = useState(null); @@ -45,10 +42,6 @@ export function RegenerateKeyModal({ selectedToken, visible, onClose, onKeyUpdat // Initialize the current access token setCurrentAccessToken(accessToken); - - // Check if this is the user's own authentication key by comparing the key values - const isUserOwnKey = selectedToken.key_name === accessToken; - setIsOwnKey(isUserOwnKey); } }, [visible, selectedToken, form, accessToken]); @@ -57,7 +50,6 @@ export function RegenerateKeyModal({ selectedToken, visible, onClose, onKeyUpdat // Reset states when modal is closed setRegeneratedKey(null); setIsRegenerating(false); - setIsOwnKey(false); setCurrentAccessToken(null); setCopied(false); form.resetFields(); From 9071dbba123d66cef07ab579b963cba8f7006ac9 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 9 Apr 2026 21:24:22 -0700 Subject: [PATCH 040/130] fix(ui): remove leftover setIsOwnKey call after state removal --- .../src/components/organisms/RegenerateKeyModal.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.tsx b/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.tsx index bbe3edceb67..04e51a7a6f4 100644 --- a/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.tsx +++ b/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.tsx @@ -145,7 +145,6 @@ export function RegenerateKeyModal({ selectedToken, visible, onClose, onKeyUpdat const handleClose = () => { setRegeneratedKey(null); setIsRegenerating(false); - setIsOwnKey(false); setCurrentAccessToken(null); setCopied(false); form.resetFields(); From 26e99f22b341107ee0b26cd4224d2e51101cefa0 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Thu, 9 Apr 2026 21:36:35 -0700 Subject: [PATCH 041/130] refactor: consolidate route auth for UI and API tokens Unify UI and API token authorization through the shared RBAC path and backfill missing routes in role-based route lists. --- litellm/proxy/_types.py | 134 ++++++++---------- litellm/proxy/auth/auth_checks.py | 67 +-------- tests/proxy_unit_tests/test_jwt.py | 75 ++++++---- .../test_user_api_key_auth.py | 125 ++++++++++------ 4 files changed, 189 insertions(+), 212 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 793742891fc..364e49e6257 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -494,10 +494,12 @@ class LiteLLMRoutes(enum.Enum): "/v2/key/info", "/model_group/info", "/health", + "/health/services", "/key/list", "/user/filter/ui", "/models", "/v1/models", + "/sso/get/ui_settings", ] # NOTE: ROUTES ONLY FOR MASTER KEY - only the Master Key should be able to Reset Spend @@ -566,6 +568,8 @@ class LiteLLMRoutes(enum.Enum): "/spend/tags", "/spend/calculate", "/spend/logs", + "/spend/logs/ui", + "/spend/logs/session/ui", "/cost/estimate", ] @@ -581,6 +585,7 @@ class LiteLLMRoutes(enum.Enum): "/global/spend/report", "/global/spend/provider", "/global/spend/tags", + "/global/spend/all_tag_names", ] public_routes = set( @@ -602,44 +607,18 @@ class LiteLLMRoutes(enum.Enum): ] ) - ui_routes = [ - "/sso", - "/sso/get/ui_settings", - "/get/ui_settings", - "/login", - "/key/info", - "/config", - "/spend", - "/model/info", - "/v2/model/info", - "/v2/key/info", - "/models", - "/v1/models", - "/global/spend", - "/global/spend/logs", - "/global/spend/keys", - "/global/spend/models", - "/global/spend/tags", - "/global/predict/spend/logs", - "/global/activity", - "/health/services", - ] + info_routes - internal_user_routes = ( [ - "/global/spend/tags", - "/global/spend/keys", - "/global/spend/models", - "/global/spend/provider", - "/global/spend/end_users", "/global/activity", "/global/activity/model", + "/global/activity/cache_hits", "/v1/models/{model_id}", "/models/{model_id}", "/guardrails/list", "/v2/guardrails/list", ] + spend_tracking_routes + + global_spend_tracking_routes + key_management_routes ) @@ -694,6 +673,9 @@ class LiteLLMRoutes(enum.Enum): "/tag/list", "/audit", "/audit/{id}", + "/global/activity", + "/global/activity/model", + "/global/activity/cache_hits", ] + info_routes # All routes accesible by an Org Admin @@ -892,9 +874,9 @@ class GenerateRequestBase(LiteLLMPydanticObjectBase): allowed_cache_controls: Optional[list] = [] config: Optional[dict] = {} permissions: Optional[dict] = {} - model_max_budget: Optional[dict] = ( - {} - ) # {"gpt-4": 5.0, "gpt-3.5-turbo": 5.0}, defaults to {} + model_max_budget: Optional[ + dict + ] = {} # {"gpt-4": 5.0, "gpt-3.5-turbo": 5.0}, defaults to {} model_config = ConfigDict(protected_namespaces=()) model_rpm_limit: Optional[dict] = None @@ -1036,9 +1018,9 @@ class RegenerateKeyRequest(GenerateKeyRequest): spend: Optional[float] = None metadata: Optional[dict] = None new_master_key: Optional[str] = None - grace_period: Optional[str] = ( - None # Duration to keep old key valid (e.g. "24h", "2d"); None = immediate revoke - ) + grace_period: Optional[ + str + ] = None # Duration to keep old key valid (e.g. "24h", "2d"); None = immediate revoke class ResetSpendRequest(LiteLLMPydanticObjectBase): @@ -1562,12 +1544,12 @@ class NewCustomerRequest(BudgetNewRequest): blocked: bool = False # allow/disallow requests for this end-user budget_id: Optional[str] = None # give either a budget_id or max_budget spend: Optional[float] = None - allowed_model_region: Optional[AllowedModelRegion] = ( - None # require all user requests to use models in this specific region - ) - default_model: Optional[str] = ( - None # if no equivalent model in allowed region - default all requests to this model - ) + allowed_model_region: Optional[ + AllowedModelRegion + ] = None # require all user requests to use models in this specific region + default_model: Optional[ + str + ] = None # if no equivalent model in allowed region - default all requests to this model object_permission: Optional[LiteLLM_ObjectPermissionBase] = None @model_validator(mode="before") @@ -1590,12 +1572,12 @@ class UpdateCustomerRequest(LiteLLMPydanticObjectBase): blocked: bool = False # allow/disallow requests for this end-user max_budget: Optional[float] = None budget_id: Optional[str] = None # give either a budget_id or max_budget - allowed_model_region: Optional[AllowedModelRegion] = ( - None # require all user requests to use models in this specific region - ) - default_model: Optional[str] = ( - None # if no equivalent model in allowed region - default all requests to this model - ) + allowed_model_region: Optional[ + AllowedModelRegion + ] = None # require all user requests to use models in this specific region + default_model: Optional[ + str + ] = None # if no equivalent model in allowed region - default all requests to this model object_permission: Optional[LiteLLM_ObjectPermissionBase] = None @@ -1685,15 +1667,15 @@ class NewTeamRequest(TeamBase): ] = None # raise an error if 'guaranteed_throughput' is set and we're overallocating tpm model_tpm_limit: Optional[Dict[str, int]] = None - team_member_budget: Optional[float] = ( - None # allow user to set a budget for all team members - ) - team_member_rpm_limit: Optional[int] = ( - None # allow user to set RPM limit for all team members - ) - team_member_tpm_limit: Optional[int] = ( - None # allow user to set TPM limit for all team members - ) + team_member_budget: Optional[ + float + ] = None # allow user to set a budget for all team members + team_member_rpm_limit: Optional[ + int + ] = None # allow user to set RPM limit for all team members + team_member_tpm_limit: Optional[ + int + ] = None # allow user to set TPM limit for all team members team_member_key_duration: Optional[str] = None # e.g. "1d", "1w", "1m" team_member_budget_duration: Optional[str] = None # e.g. "30d", "1mo" allowed_vector_store_indexes: Optional[List[AllowedVectorStoreIndexItem]] = None @@ -1790,9 +1772,9 @@ class BlockKeyRequest(LiteLLMPydanticObjectBase): class AddTeamCallback(LiteLLMPydanticObjectBase): callback_name: str - callback_type: Optional[Literal["success", "failure", "success_and_failure"]] = ( - "success_and_failure" - ) + callback_type: Optional[ + Literal["success", "failure", "success_and_failure"] + ] = "success_and_failure" callback_vars: Dict[str, str] @model_validator(mode="before") @@ -2134,9 +2116,9 @@ class ConfigList(LiteLLMPydanticObjectBase): stored_in_db: Optional[bool] field_default_value: Any premium_field: bool = False - nested_fields: Optional[List[FieldDetail]] = ( - None # For nested dictionary or Pydantic fields - ) + nested_fields: Optional[ + List[FieldDetail] + ] = None # For nested dictionary or Pydantic fields class UserHeaderMapping(LiteLLMPydanticObjectBase): @@ -2495,9 +2477,9 @@ class UserAPIKeyAuth( user_max_budget: Optional[float] = None request_route: Optional[str] = None user: Optional[Any] = None # Expanded user object when expand=user is used - created_by_user: Optional[Any] = ( - None # Expanded created_by user when expand=user is used - ) + created_by_user: Optional[ + Any + ] = None # Expanded created_by user when expand=user is used end_user_object_permission: Optional[LiteLLM_ObjectPermissionTable] = None # Decoded upstream IdP claims (groups, roles, etc.) propagated by JWT auth machinery # and forwarded into outbound tokens by guardrails such as MCPJWTSigner. @@ -2636,9 +2618,9 @@ class LiteLLM_OrganizationMembershipTable(LiteLLMPydanticObjectBase): budget_id: Optional[str] = None created_at: datetime updated_at: datetime - user: Optional[Any] = ( - None # You might want to replace 'Any' with a more specific type if available - ) + user: Optional[ + Any + ] = None # You might want to replace 'Any' with a more specific type if available litellm_budget_table: Optional[LiteLLM_BudgetTable] = None user_email: Optional[str] = None @@ -3793,9 +3775,9 @@ class TeamModelDeleteRequest(BaseModel): # Organization Member Requests class OrganizationMemberAddRequest(OrgMemberAddRequest): organization_id: str - max_budget_in_organization: Optional[float] = ( - None # Users max budget within the organization - ) + max_budget_in_organization: Optional[ + float + ] = None # Users max budget within the organization class OrganizationMemberDeleteRequest(MemberDeleteRequest): @@ -4050,9 +4032,9 @@ class ProviderBudgetResponse(LiteLLMPydanticObjectBase): Maps provider names to their budget configs. """ - providers: Dict[str, ProviderBudgetResponseObject] = ( - {} - ) # Dictionary mapping provider names to their budget configurations + providers: Dict[ + str, ProviderBudgetResponseObject + ] = {} # Dictionary mapping provider names to their budget configurations class ProxyStateVariables(TypedDict): @@ -4214,9 +4196,9 @@ class LiteLLM_JWTAuth(LiteLLMPydanticObjectBase): enforce_rbac: bool = False roles_jwt_field: Optional[str] = None # v2 on role mappings role_mappings: Optional[List[RoleMapping]] = None - object_id_jwt_field: Optional[str] = ( - None # can be either user / team, inferred from the role mapping - ) + object_id_jwt_field: Optional[ + str + ] = None # can be either user / team, inferred from the role mapping scope_mappings: Optional[List[ScopeMapping]] = None enforce_scope_based_access: bool = False enforce_team_based_model_access: bool = False diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 68bde8434a6..56958a88f6d 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -196,9 +196,7 @@ def _is_model_cost_zero( return True -def _is_cost_explicitly_configured( - model: str, llm_router: "Router" -) -> bool: +def _is_cost_explicitly_configured(model: str, llm_router: "Router") -> bool: """ Check if any deployment in the model group has cost fields explicitly set in its litellm.model_cost entry. @@ -215,10 +213,7 @@ def _is_cost_explicitly_configured( if model_id is None: continue raw_entry = litellm.model_cost.get(model_id, {}) - if ( - "input_cost_per_token" in raw_entry - or "output_cost_per_token" in raw_entry - ): + if "input_cost_per_token" in raw_entry or "output_cost_per_token" in raw_entry: return True return False @@ -596,17 +591,12 @@ async def common_checks( # noqa: PLR0915 user_object=user_object, route=route, request_body=request_body ) - token_team = getattr(valid_token, "team_id", None) - token_type: Literal["ui", "api"] = ( - "ui" if token_team is not None and token_team == "litellm-dashboard" else "api" - ) - _is_route_allowed = _is_allowed_route( + _is_route_allowed = _is_api_route_allowed( route=route, - token_type=token_type, - user_obj=user_object, request=request, request_data=request_body, valid_token=valid_token, + user_obj=user_object, ) # 11. [OPTIONAL] Vector store checks - is the object allowed to access the vector store @@ -629,31 +619,6 @@ async def common_checks( # noqa: PLR0915 return True -def _is_ui_route( - route: str, - user_obj: Optional[LiteLLM_UserTable] = None, -) -> bool: - """ - - Check if the route is a UI used route - """ - # this token is only used for managing the ui - allowed_routes = LiteLLMRoutes.ui_routes.value - # check if the current route startswith any of the allowed routes - if ( - route is not None - and isinstance(route, str) - and any(route.startswith(allowed_route) for allowed_route in allowed_routes) - ): - # Do something if the current route starts with any of the allowed routes - return True - elif any( - RouteChecks._route_matches_pattern(route=route, pattern=allowed_route) - for allowed_route in allowed_routes - ): - return True - return False - - def _get_user_role( user_obj: Optional[LiteLLM_UserTable], ) -> Optional[LitellmUserRoles]: @@ -717,30 +682,6 @@ def _is_user_proxy_admin(user_obj: Optional[LiteLLM_UserTable]): return False -def _is_allowed_route( - route: str, - token_type: Literal["ui", "api"], - request: Request, - request_data: dict, - valid_token: Optional[UserAPIKeyAuth], - user_obj: Optional[LiteLLM_UserTable] = None, -) -> bool: - """ - - Route b/w ui token check and normal token check - """ - - if token_type == "ui" and _is_ui_route(route=route, user_obj=user_obj): - return True - else: - return _is_api_route_allowed( - route=route, - request=request, - request_data=request_data, - valid_token=valid_token, - user_obj=user_obj, - ) - - def _allowed_routes_check(user_route: str, allowed_routes: list) -> bool: """ Return if a user is allowed to access route. Helper function for `allowed_routes_check`. diff --git a/tests/proxy_unit_tests/test_jwt.py b/tests/proxy_unit_tests/test_jwt.py index a5be1a3a42d..73f956a6147 100644 --- a/tests/proxy_unit_tests/test_jwt.py +++ b/tests/proxy_unit_tests/test_jwt.py @@ -715,7 +715,7 @@ async def aaaatest_user_token_output( assert team_result.user_id == user_id -@pytest.mark.parametrize("admin_allowed_routes", [None, ["ui_routes"]]) +@pytest.mark.parametrize("admin_allowed_routes", [None, ["info_routes"]]) @pytest.mark.parametrize("audience", [None, "litellm-proxy"]) @pytest.mark.asyncio async def test_allowed_routes_admin( @@ -934,10 +934,7 @@ async def mock_user_object(*args, **kwargs): user_id = kwargs.get("user_id") user_email = kwargs.get("user_email") return LiteLLM_UserTable( - spend=0, - user_id=user_id, - max_budget=None, - user_email=user_email + spend=0, user_id=user_id, max_budget=None, user_email=user_email ) @@ -1170,15 +1167,13 @@ async def test_end_user_jwt_auth(monkeypatch): # use generated key to auth in from litellm import Router from litellm.types.router import RouterGeneralSettings - + # Create a router with pass_through_all_models enabled router = Router( model_list=[], - router_general_settings=RouterGeneralSettings( - pass_through_all_models=True - ), + router_general_settings=RouterGeneralSettings(pass_through_all_models=True), ) - + setattr(litellm.proxy.proxy_server, "premium_user", True) setattr( litellm.proxy.proxy_server, @@ -1196,7 +1191,7 @@ async def test_end_user_jwt_auth(monkeypatch): cost_tracking() result = await user_api_key_auth(request=request, api_key=bearer_token) - + # Assert that end_user_id is correctly extracted from JWT token's 'sub' field assert result.end_user_id == "81b3e52a-67a6-4efb-9645-70527e101479" @@ -1228,7 +1223,9 @@ async def test_end_user_jwt_auth(monkeypatch): ), ) - with patch("litellm.acompletion", new=AsyncMock(return_value=mock_response)) as mock_completion: + with patch( + "litellm.acompletion", new=AsyncMock(return_value=mock_response) + ) as mock_completion: resp = await chat_completion( request=request, fastapi_response=temp_response, @@ -1243,10 +1240,13 @@ async def test_end_user_jwt_auth(monkeypatch): # Verify the completion was called with correct end_user_id mock_completion.assert_called_once() call_kwargs = mock_completion.call_args.kwargs - + # end_user_id is passed in metadata as 'user_api_key_end_user_id' metadata = call_kwargs.get("metadata", {}) - assert metadata.get("user_api_key_end_user_id") == "81b3e52a-67a6-4efb-9645-70527e101479" + assert ( + metadata.get("user_api_key_end_user_id") + == "81b3e52a-67a6-4efb-9645-70527e101479" + ) def test_can_rbac_role_call_route(): @@ -1278,13 +1278,13 @@ def test_user_api_key_auth_jwt_hashing(): """ from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.auth.handle_jwt import JWTHandler - + # Test with a JWT token (3 parts separated by dots) jwt_token = "test-jwt-token-header.payload.signature" - + # Create UserAPIKeyAuth instance with JWT user_auth = UserAPIKeyAuth(api_key=jwt_token) - + # Verify that the API key is hashed with "hashed-jwt-" prefix # critical - the raw JWT token should not be in the api_key or token assert user_auth.api_key.startswith("hashed-jwt-") @@ -1292,19 +1292,18 @@ def test_user_api_key_auth_jwt_hashing(): assert jwt_token not in user_auth.api_key assert jwt_token not in user_auth.token - # Test with a regular API key (should not be hashed) regular_api_key = "sk-1234567890abcdef" user_auth_regular = UserAPIKeyAuth(api_key=regular_api_key) - + # Verify that regular API key is hashed normally (without "hashed-jwt-" prefix) assert not user_auth_regular.api_key.startswith("hashed-jwt-") assert not user_auth_regular.token.startswith("hashed-jwt-") - + # Test with a non-JWT, non-sk string (should not be hashed) non_jwt_key = "some-random-key" user_auth_non_jwt = UserAPIKeyAuth(api_key=non_jwt_key) - + # Verify that non-JWT key is not hashed assert user_auth_non_jwt.api_key == non_jwt_key assert user_auth_non_jwt.token == non_jwt_key @@ -1315,19 +1314,19 @@ def test_jwt_handler_is_jwt_static_method(): Test that JWTHandler.is_jwt is a static method and works correctly """ from litellm.proxy.auth.handle_jwt import JWTHandler - + # Test with valid JWT format valid_jwt = "test-jwt-token-header.payload.signature" assert JWTHandler.is_jwt(valid_jwt) == True - + # Test with invalid JWT format (only 2 parts) invalid_jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ" assert JWTHandler.is_jwt(invalid_jwt) == False - + # Test with regular API key regular_key = "sk-1234567890abcdef" assert JWTHandler.is_jwt(regular_key) == False - + # Test with empty string assert JWTHandler.is_jwt("") == False @@ -1461,7 +1460,13 @@ async def test_auth_jwt_es256_jwk_path(monkeypatch): now = int(time.time()) token = jwt.encode( - {"sub": "alice", "aud": "litellm-proxy", "iss": "http://example", "iat": now, "exp": now + 300}, + { + "sub": "alice", + "aud": "litellm-proxy", + "iss": "http://example", + "iat": now, + "exp": now + 300, + }, ec_priv_pem, algorithm="ES256", headers={"kid": "ec1"}, @@ -1508,7 +1513,13 @@ async def test_auth_jwt_rs256_regression(monkeypatch): now = int(time.time()) token = jwt.encode( - {"sub": "bob", "aud": "litellm-proxy", "iss": "http://example", "iat": now, "exp": now + 300}, + { + "sub": "bob", + "aud": "litellm-proxy", + "iss": "http://example", + "iat": now, + "exp": now + 300, + }, rsa_priv_pem, algorithm="RS256", headers={"kid": "rsa1"}, @@ -1540,7 +1551,13 @@ async def test_auth_jwt_mismatched_key_fails(monkeypatch): ) now = int(time.time()) token = jwt.encode( - {"sub": "mallory", "aud": "litellm-proxy", "iss": "http://example", "iat": now, "exp": now + 300}, + { + "sub": "mallory", + "aud": "litellm-proxy", + "iss": "http://example", + "iat": now, + "exp": now + 300, + }, ec_priv_pem, algorithm="ES256", headers={"kid": "ec1"}, @@ -1566,4 +1583,4 @@ async def test_auth_jwt_mismatched_key_fails(monkeypatch): with patch.object(h, "get_public_key", new=AsyncMock(return_value=rsa_jwk)): with pytest.raises(Exception) as exc: await h.auth_jwt(token) - assert "Validation fails" in str(exc.value) \ No newline at end of file + assert "Validation fails" in str(exc.value) 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 1a6e2eda9a1..75f0d5e3195 100644 --- a/tests/proxy_unit_tests/test_user_api_key_auth.py +++ b/tests/proxy_unit_tests/test_user_api_key_auth.py @@ -359,27 +359,38 @@ async def test_auth_with_allowed_routes(route, should_raise_error): @pytest.mark.parametrize( - "route, user_role, expected_result", + "route, user_role, should_be_allowed", [ - # Proxy Admin checks + # Admin can access everything + ("/config/update", "proxy_admin", True), ("/global/spend/logs", "proxy_admin", True), - ("/key/delete", "proxy_admin", False), - ("/key/generate", "proxy_admin", False), - ("/key/regenerate", "proxy_admin", False), - # Internal User checks - allowed routes + ("/global/activity/cache_hits", "proxy_admin", True), + # Internal User - allowed read-only routes ("/global/spend/logs", "internal_user", True), - ("/key/delete", "internal_user", False), - ("/key/generate", "internal_user", False), - ("/key/82akk800000000jjsk/regenerate", "internal_user", False), - # Internal User Viewer - ("/key/generate", "internal_user_viewer", False), - # Internal User checks - disallowed routes + ("/spend/logs/ui", "internal_user", True), + ("/global/activity/cache_hits", "internal_user", True), + ("/health/services", "internal_user", True), + # Internal User - BLOCKED from admin routes (security fix) + ("/config/update", "internal_user", False), + ("/config/pass_through_endpoint", "internal_user", False), + ("/config/field/update", "internal_user", False), ("/organization/member_add", "internal_user", False), + # Internal User Viewer - allowed spend routes only + ("/spend/logs/ui", "internal_user_viewer", True), + ("/global/spend/all_tag_names", "internal_user_viewer", True), + # Internal User Viewer - blocked from admin routes + ("/config/update", "internal_user_viewer", False), + ("/key/generate", "internal_user_viewer", False), ], ) -def test_is_ui_route_allowed(route, user_role, expected_result): - from litellm.proxy.auth.auth_checks import _is_ui_route - from litellm.proxy._types import LiteLLM_UserTable +def test_ui_token_route_access(route, user_role, should_be_allowed): + """ + Verify that UI tokens (team_id=litellm-dashboard) go through the same + RBAC checks as API tokens. Non-admin dashboard users must not be able + to access admin-only routes like /config/update. + """ + from litellm.proxy.auth.auth_checks import _is_api_route_allowed + from litellm.proxy._types import LiteLLM_UserTable, UserAPIKeyAuth user_obj = LiteLLM_UserTable( user_id="3b803c0e-666e-4e99-bd5c-6e534c07e297", @@ -395,18 +406,36 @@ def test_is_ui_route_allowed(route, user_role, expected_result): organization_memberships=[], ) - received_args: dict = { - "route": route, - "user_obj": user_obj, - } - try: - assert _is_ui_route(**received_args) == expected_result - except Exception as e: - # If expected result is False, we expect an error - if expected_result is False: - pass - else: - raise e + valid_token = UserAPIKeyAuth( + user_id="3b803c0e-666e-4e99-bd5c-6e534c07e297", + team_id="litellm-dashboard", + user_role=user_role, + ) + + from starlette.datastructures import URL + from fastapi import Request + + request = Request(scope={"type": "http"}) + request._url = URL(url=route) + + if should_be_allowed: + result = _is_api_route_allowed( + route=route, + request=request, + request_data={}, + valid_token=valid_token, + user_obj=user_obj, + ) + assert result is True + else: + with pytest.raises(Exception): + _is_api_route_allowed( + route=route, + request=request, + request_data={}, + valid_token=valid_token, + user_obj=user_obj, + ) @pytest.mark.parametrize( @@ -684,7 +713,7 @@ async def test_soft_budget_alert(): def test_is_allowed_route(): - from litellm.proxy.auth.auth_checks import _is_allowed_route + from litellm.proxy.auth.auth_checks import _is_api_route_allowed from litellm.proxy._types import UserAPIKeyAuth import datetime @@ -692,7 +721,6 @@ def test_is_allowed_route(): args = { "route": "/embeddings", - "token_type": "api", "request": request, "request_data": {"input": ["hello world"], "model": "embedding-small"}, "valid_token": UserAPIKeyAuth( @@ -752,7 +780,7 @@ def test_is_allowed_route(): "user_obj": None, } - assert _is_allowed_route(**args) + assert _is_api_route_allowed(**args) @pytest.mark.parametrize( @@ -836,7 +864,6 @@ async def test_user_api_key_auth_websocket(): with patch( "litellm.proxy.auth.user_api_key_auth.user_api_key_auth", autospec=True ) as mock_user_api_key_auth: - # Make the call to the WebSocket function await user_api_key_auth_websocket(mock_websocket) @@ -845,10 +872,14 @@ async def test_user_api_key_auth_websocket(): # Get the request object that was passed to user_api_key_auth request_arg = mock_user_api_key_auth.call_args.kwargs["request"] - + # Verify that the request has headers set - assert hasattr(request_arg, "headers"), "Request object should have headers attribute" - assert "authorization" in request_arg.headers, "Request headers should contain authorization" + assert hasattr( + request_arg, "headers" + ), "Request object should have headers attribute" + assert ( + "authorization" in request_arg.headers + ), "Request headers should contain authorization" assert request_arg.headers["authorization"] == "Bearer some_api_key" assert ( @@ -1036,7 +1067,10 @@ async def test_jwt_non_admin_team_route_access(monkeypatch): # Create request request = Request( - scope={"type": "http", "headers": [(b"authorization", b"Bearer fake.jwt.token")]} + scope={ + "type": "http", + "headers": [(b"authorization", b"Bearer fake.jwt.token")], + } ) request._url = URL(url="/team/new") @@ -1101,14 +1135,14 @@ async def test_x_litellm_api_key(): ignored_key = "aj12445" # Create request with headers as bytes - request = Request( - scope={ - "type": "http" - } - ) + request = Request(scope={"type": "http"}) request._url = URL(url="/chat/completions") - valid_token = await user_api_key_auth(request=request, api_key="Bearer " + ignored_key, custom_litellm_key_header=master_key) + valid_token = await user_api_key_auth( + request=request, + api_key="Bearer " + ignored_key, + custom_litellm_key_header=master_key, + ) assert valid_token.token == hash_token(master_key) @@ -1123,7 +1157,9 @@ async def test_user_api_key_from_query_param(): from litellm.proxy.proxy_server import hash_token, user_api_key_cache user_key = "sk-query-1234" - user_api_key_cache.set_cache(key=hash_token(user_key), value=UserAPIKeyAuth(token=hash_token(user_key))) + user_api_key_cache.set_cache( + key=hash_token(user_key), value=UserAPIKeyAuth(token=hash_token(user_key)) + ) setattr(litellm.proxy.proxy_server, "user_api_key_cache", user_api_key_cache) setattr(litellm.proxy.proxy_server, "master_key", "sk-1234") @@ -1136,7 +1172,9 @@ async def test_user_api_key_from_query_param(): "query_string": f"alt=sse&key={user_key}".encode(), } ) - request._url = URL(url=f"/v1beta/models/gemini:streamGenerateContent?alt=sse&key={user_key}") + request._url = URL( + url=f"/v1beta/models/gemini:streamGenerateContent?alt=sse&key={user_key}" + ) async def return_body(): return b"{}" @@ -1145,4 +1183,3 @@ async def test_user_api_key_from_query_param(): valid_token = await user_api_key_auth(request=request, api_key="") assert valid_token.token == hash_token(user_key) - From d4288b4ff48d8e134813bd7da5816251f8b3939e Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 9 Apr 2026 21:45:34 -0700 Subject: [PATCH 042/130] ci: fix LITELLM_LICENSE interpolation in e2e_ui_testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove LITELLM_LICENSE from the run step's environment block — YAML environment maps may pass the literal string "${LITELLM_LICENSE}" instead of interpolating the project env var, overriding it with a value that fails license validation. The project-level env var is inherited automatically by the proxy process. --- .circleci/config.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 810727b0110..2b0a6924cce 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3201,7 +3201,6 @@ jobs: name: Start LiteLLM proxy environment: LITELLM_MASTER_KEY: "sk-1234" - LITELLM_LICENSE: ${LITELLM_LICENSE} MOCK_LLM_URL: "http://127.0.0.1:8090/v1" DISABLE_SCHEMA_UPDATE: "true" SERVER_ROOT_PATH: "" From fb527ae25020494eb5ad14e90e8c849c1202751e Mon Sep 17 00:00:00 2001 From: joereyna Date: Thu, 9 Apr 2026 13:18:35 -0700 Subject: [PATCH 043/130] fix(test): mock headers in test_completion_fine_tuned_model --- tests/local_testing/test_amazing_vertex_completion.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/local_testing/test_amazing_vertex_completion.py b/tests/local_testing/test_amazing_vertex_completion.py index a1684e23769..98bb40f3613 100644 --- a/tests/local_testing/test_amazing_vertex_completion.py +++ b/tests/local_testing/test_amazing_vertex_completion.py @@ -2291,6 +2291,8 @@ def test_prompt_factory_nested(): async def test_completion_fine_tuned_model(): load_vertex_ai_credentials() mock_response = AsyncMock() + mock_response.headers = {} + mock_response.status_code = 200 def return_val(): return { @@ -2326,7 +2328,6 @@ async def test_completion_fine_tuned_model(): } mock_response.json = return_val - mock_response.status_code = 200 expected_payload = { "contents": [ From f8ae6427363cf3c1c5fd2b1d03d9162ffb68ef00 Mon Sep 17 00:00:00 2001 From: joereyna Date: Thu, 9 Apr 2026 15:28:34 -0700 Subject: [PATCH 044/130] format vertex test file --- tests/local_testing/test_amazing_vertex_completion.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/local_testing/test_amazing_vertex_completion.py b/tests/local_testing/test_amazing_vertex_completion.py index 98bb40f3613..001b9464006 100644 --- a/tests/local_testing/test_amazing_vertex_completion.py +++ b/tests/local_testing/test_amazing_vertex_completion.py @@ -178,7 +178,6 @@ async def test_get_response(): async def test_aavertex_ai_anthropic_async(): # load_vertex_ai_credentials() try: - model = "claude-3-5-sonnet@20240620" vertex_ai_project = "pathrise-convert-1606954137718" @@ -351,7 +350,6 @@ def test_avertex_ai_stream(): @pytest.mark.flaky(retries=3, delay=1) @pytest.mark.asyncio async def test_async_vertexai_response_basic(): - load_vertex_ai_credentials() try: user_message = "Hello, how are you?" @@ -1382,7 +1380,6 @@ async def test_gemini_pro_json_schema_args_sent_httpx( ] ) elif resp is not None: - assert resp.model == model.split("/")[1] From 8dc5ab39f00beb604ee82cbc91d5e8d6053aaa81 Mon Sep 17 00:00:00 2001 From: Chetan Soni Date: Thu, 9 Apr 2026 12:42:42 -0700 Subject: [PATCH 045/130] feat(mcp): add per-user OAuth token storage for interactive MCP flows --- litellm/constants.py | 9 + litellm/proxy/_experimental/mcp_server/db.py | 145 ++++- .../mcp_server/discoverable_endpoints.py | 195 ++++++- .../mcp_server/mcp_server_manager.py | 31 ++ .../mcp_server/oauth2_token_cache.py | 108 ++++ .../proxy/_experimental/mcp_server/server.py | 120 +++- .../types/mcp_server/mcp_server_manager.py | 9 + tests/mcp_tests/test_per_user_oauth_cache.py | 527 ++++++++++++++++++ .../mcp_tools/OAuthFormFields.test.tsx | 208 +++++++ .../components/mcp_tools/OAuthFormFields.tsx | 46 +- .../mcp_tools/create_mcp_server.test.tsx | 141 +++++ .../mcp_tools/create_mcp_server.tsx | 14 + .../mcp_tools/mcp_server_edit.test.tsx | 249 +++++++++ .../components/mcp_tools/mcp_server_edit.tsx | 73 ++- .../src/components/mcp_tools/types.tsx | 4 + 15 files changed, 1851 insertions(+), 28 deletions(-) create mode 100644 tests/mcp_tests/test_per_user_oauth_cache.py create mode 100644 ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.test.tsx diff --git a/litellm/constants.py b/litellm/constants.py index a7d86ddb16b..337cb1243fb 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -135,6 +135,15 @@ MCP_OAUTH2_TOKEN_CACHE_DEFAULT_TTL = int( MCP_NPM_CACHE_DIR = os.getenv("MCP_NPM_CACHE_DIR", "/tmp/.npm_mcp_cache") MCP_OAUTH2_TOKEN_CACHE_MIN_TTL = int(os.getenv("MCP_OAUTH2_TOKEN_CACHE_MIN_TTL", "10")) +# Per-user OAuth token Redis cache (for server-side token storage) +MCP_PER_USER_TOKEN_REDIS_KEY_PREFIX = "mcp:per_user_token" +MCP_PER_USER_TOKEN_DEFAULT_TTL = int( + os.getenv("MCP_PER_USER_TOKEN_DEFAULT_TTL", "43200") # 12 hours +) +MCP_PER_USER_TOKEN_EXPIRY_BUFFER_SECONDS = int( + os.getenv("MCP_PER_USER_TOKEN_EXPIRY_BUFFER_SECONDS", "60") +) + # MCP timeout defaults (seconds). Override via env vars for slow/custom MCP servers. MCP_CLIENT_TIMEOUT = float(os.getenv("LITELLM_MCP_CLIENT_TIMEOUT", "60.0")) MCP_TOOL_LISTING_TIMEOUT = float(os.getenv("LITELLM_MCP_TOOL_LISTING_TIMEOUT", "30.0")) diff --git a/litellm/proxy/_experimental/mcp_server/db.py b/litellm/proxy/_experimental/mcp_server/db.py index fbef33c32ed..e9bd41bb951 100644 --- a/litellm/proxy/_experimental/mcp_server/db.py +++ b/litellm/proxy/_experimental/mcp_server/db.py @@ -21,7 +21,9 @@ from litellm.proxy.common_utils.encrypt_decrypt_utils import ( decrypt_value_helper, encrypt_value_helper, ) +from litellm.llms.custom_httpx.http_handler import get_async_httpx_client from litellm.proxy.utils import PrismaClient +from litellm.types.llms.custom_http import httpxSpecialProvider from litellm.types.mcp import MCPCredentials @@ -576,6 +578,7 @@ async def store_user_oauth_credential( refresh_token: Optional[str] = None, expires_in: Optional[int] = None, scopes: Optional[List[str]] = None, + skip_byok_guard: bool = False, ) -> None: """Persist an OAuth2 access token for a user+server pair. @@ -604,21 +607,26 @@ async def store_user_oauth_credential( # Guard against silently overwriting a BYOK credential with an OAuth token. # BYOK credentials lack a "type" field (or use a non-"oauth2" type). - existing = await prisma_client.db.litellm_mcpusercredentials.find_unique( - where={"user_id_server_id": {"user_id": user_id, "server_id": server_id}} - ) - if existing is not None: - _byok_error = ValueError( - f"A non-OAuth2 credential already exists for user {user_id} " - f"and server {server_id}. Refusing to overwrite." + # Skip the guard when the caller knows the row is already an OAuth2 credential + # (e.g. during token refresh), saving an extra DB round-trip. + if not skip_byok_guard: + existing = await prisma_client.db.litellm_mcpusercredentials.find_unique( + where={"user_id_server_id": {"user_id": user_id, "server_id": server_id}} ) - try: - raw = json.loads(base64.urlsafe_b64decode(existing.credential_b64).decode()) - except Exception: - # Credential is not base64+JSON — it's a plain-text BYOK key. - raise _byok_error - if raw.get("type") != "oauth2": - raise _byok_error + if existing is not None: + _byok_error = ValueError( + f"A non-OAuth2 credential already exists for user {user_id} " + f"and server {server_id}. Refusing to overwrite." + ) + try: + raw = json.loads( + base64.urlsafe_b64decode(existing.credential_b64).decode() + ) + except Exception: + # Credential is not base64+JSON — it's a plain-text BYOK key. + raise _byok_error + if raw.get("type") != "oauth2": + raise _byok_error encoded = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode() await prisma_client.db.litellm_mcpusercredentials.upsert( @@ -697,6 +705,115 @@ async def list_user_oauth_credentials( return results +async def refresh_user_oauth_token( + prisma_client: PrismaClient, + user_id: str, + server: Any, + cred: Dict[str, Any], +) -> Optional[Dict[str, Any]]: + """Attempt to refresh a per-user OAuth2 token using its stored refresh_token. + + POSTs to ``server.token_url`` with ``grant_type=refresh_token``. + + On success: persists the new credential via ``store_user_oauth_credential`` + and returns the updated payload dict. + On failure (network error, invalid_grant, missing refresh_token, …): logs a + warning and returns ``None`` — the caller is responsible for clearing the + stale credential and triggering re-authentication. + """ + refresh_token: Optional[str] = cred.get("refresh_token") + token_url: Optional[str] = getattr(server, "token_url", None) + server_id: str = getattr(server, "server_id", "") + client_id: Optional[str] = getattr(server, "client_id", None) + client_secret: Optional[str] = getattr(server, "client_secret", None) + + if not refresh_token: + verbose_proxy_logger.debug( + "refresh_user_oauth_token: no refresh_token stored for user=%s server=%s", + user_id, + server_id, + ) + return None + if not token_url: + verbose_proxy_logger.debug( + "refresh_user_oauth_token: server=%s has no token_url configured", + server_id, + ) + return None + + token_data: Dict[str, str] = { + "grant_type": "refresh_token", + "refresh_token": refresh_token, + } + if client_id: + token_data["client_id"] = client_id + if client_secret: + token_data["client_secret"] = client_secret + + try: + async_client = get_async_httpx_client( + llm_provider=httpxSpecialProvider.Oauth2Check + ) + response = await async_client.post( + token_url, + headers={"Accept": "application/json"}, + data=token_data, + ) + response.raise_for_status() + body: Dict[str, Any] = response.json() + except Exception as exc: + verbose_proxy_logger.warning( + "refresh_user_oauth_token: refresh request failed for user=%s server=%s: %s", + user_id, + server_id, + exc, + ) + return None + + access_token: Optional[str] = body.get("access_token") + if not access_token: + verbose_proxy_logger.warning( + "refresh_user_oauth_token: token response missing access_token for " + "user=%s server=%s", + user_id, + server_id, + ) + return None + + expires_in: Optional[int] = None + raw_expires = body.get("expires_in") + try: + expires_in = int(raw_expires) if raw_expires is not None else None + except (TypeError, ValueError): + pass + + # Rotate refresh token when the provider returns a new one + new_refresh_token: Optional[str] = body.get("refresh_token") or refresh_token + + raw_scope = body.get("scope") + scopes: Optional[List[str]] = ( + raw_scope.split() if isinstance(raw_scope, str) and raw_scope else None + ) or cred.get("scopes") + + await store_user_oauth_credential( + prisma_client=prisma_client, + user_id=user_id, + server_id=server_id, + access_token=access_token, + refresh_token=new_refresh_token, + expires_in=expires_in, + scopes=scopes, + skip_byok_guard=True, # Row is already OAuth2; skip the extra find_unique check + ) + + verbose_proxy_logger.info( + "refresh_user_oauth_token: refreshed token for user=%s server=%s", + user_id, + server_id, + ) + return await get_user_oauth_credential(prisma_client, user_id, server_id) + + async def approve_mcp_server( prisma_client: PrismaClient, server_id: str, diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py index 07309eb57f2..d0d61986322 100644 --- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -1,10 +1,11 @@ import json -from typing import Optional +from typing import Any, Dict, Optional from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse from fastapi import APIRouter, Form, HTTPException, Request from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from litellm._logging import verbose_logger from litellm.llms.custom_httpx.http_handler import ( get_async_httpx_client, httpxSpecialProvider, @@ -147,6 +148,160 @@ def _resolve_oauth2_server_for_root_endpoints( return None +def _validate_token_response( + token_response: Dict[str, Any], + validation_rules: Dict[str, Any], + server_id: str, +) -> None: + """Raise HTTPException 403 if any validation rule doesn't match the token response. + + Supports dot-notation for nested fields (e.g. ``"team.enterprise_id"`` checks + ``token_response["team"]["enterprise_id"]``). Top-level keys are tried first, + then dot-split traversal. All comparisons are string-coerced so that numeric + values in the response (e.g. ``"org_id": 12345``) match string rules + (``"org_id": "12345"``). + """ + for key, expected in validation_rules.items(): + actual: Any = token_response.get(key) + # Try dot-notation traversal when top-level lookup returns None + if actual is None and "." in key: + obj: Any = token_response + for part in key.split("."): + if isinstance(obj, dict): + obj = obj.get(part) + else: + obj = None + break + actual = obj + # Treat absent fields as a distinct failure from a mismatched value + if actual is None: + raise HTTPException( + status_code=403, + detail={ + "error": "token_validation_failed", + "server_id": server_id, + "field": key, + "message": ( + f"OAuth token rejected: required field '{key}' is absent" + ), + }, + ) + if str(actual) != str(expected): + raise HTTPException( + status_code=403, + detail={ + "error": "token_validation_failed", + "server_id": server_id, + "field": key, + "message": ( + f"OAuth token rejected: '{key}' = '{actual}', " + f"expected '{expected}'" + ), + }, + ) + + +async def _extract_user_id_from_request(request: Request) -> Optional[str]: + """Best-effort extraction of LiteLLM user_id from the request's Authorization header. + + Called at the OAuth token endpoint so that per-user tokens can be stored + server-side. Uses a read-only cache lookup to avoid re-running the full + auth pipeline (which has side effects such as rate-limit increments and + spend logging). Returns ``None`` if no cached credential is found. + """ + auth_header = request.headers.get("Authorization") or request.headers.get( + "authorization" + ) + if not auth_header: + return None + lower = auth_header.lower() + if not lower.startswith("bearer "): + return None + token = auth_header[7:].strip() + try: + from litellm.proxy._types import hash_token # noqa: PLC0415 + from litellm.proxy.proxy_server import user_api_key_cache # noqa: PLC0415 + + cached = await user_api_key_cache.async_get_cache(hash_token(token)) + return getattr(cached, "user_id", None) + except Exception: + return None + + +async def _store_per_user_token_server_side( + server: MCPServer, + user_id: str, + token_response: Dict[str, Any], +) -> None: + """Persist the OAuth token server-side and warm the Redis cache. + + Called from the token endpoint after a successful code exchange or refresh. + Errors are logged but NOT re-raised — the token is always returned to the + client even when server-side storage fails. + """ + from litellm.proxy._experimental.mcp_server.oauth2_token_cache import ( # noqa: PLC0415 + _compute_per_user_token_ttl, + mcp_per_user_token_cache, + ) + from litellm.proxy.utils import get_prisma_client_or_throw # noqa: PLC0415 + + access_token: Optional[str] = token_response.get("access_token") + if not access_token: + return + + raw_expires = token_response.get("expires_in") + try: + expires_in: Optional[int] = int(raw_expires) if raw_expires is not None else None + except (TypeError, ValueError): + expires_in = None + + refresh_token: Optional[str] = token_response.get("refresh_token") or None + raw_scope = token_response.get("scope") + scopes: Optional[list] = ( + raw_scope.split() if isinstance(raw_scope, str) and raw_scope else None + ) + + try: + prisma_client = get_prisma_client_or_throw( + "Database not connected. Cannot store per-user OAuth token." + ) + from litellm.proxy._experimental.mcp_server.db import ( # noqa: PLC0415 + store_user_oauth_credential, + ) + + await store_user_oauth_credential( + prisma_client=prisma_client, + user_id=user_id, + server_id=server.server_id, + access_token=access_token, + refresh_token=refresh_token, + expires_in=expires_in, + scopes=scopes, + ) + verbose_logger.info( + "_store_per_user_token_server_side: stored token for user=%s server=%s", + user_id, + server.server_id, + ) + except Exception as exc: + verbose_logger.warning( + "_store_per_user_token_server_side: DB storage failed for user=%s server=%s: %s", + user_id, + server.server_id, + exc, + ) + return # Don't warm Redis if DB write failed + + # Warm the Redis cache so the first subsequent MCP call is a cache hit + ttl = _compute_per_user_token_ttl(server, expires_in) + await mcp_per_user_token_cache.set( + user_id=user_id, + server_id=server.server_id, + access_token=access_token, + ttl=ttl, + ) + + async def authorize_with_server( request: Request, mcp_server: MCPServer, @@ -266,6 +421,44 @@ async def exchange_token_with_server( token_response = response.json() access_token = token_response["access_token"] + # Validate token response against server-configured rules before any storage. + # This rejects tokens from wrong Slack workspaces, Atlassian orgs, etc. + if mcp_server.token_validation and isinstance(mcp_server.token_validation, dict): + _validate_token_response( + token_response=token_response, + validation_rules=mcp_server.token_validation, + server_id=mcp_server.server_id, + ) + + # Store server-side when the server is configured for per-user OAuth and + # the calling client has provided a valid LiteLLM identity. + # Errors are non-fatal: the token is still returned to the client. + if mcp_server.needs_user_oauth_token: + user_id = await _extract_user_id_from_request(request) + if user_id: + try: + await _store_per_user_token_server_side( + server=mcp_server, + user_id=user_id, + token_response=token_response, + ) + except Exception as exc: + verbose_logger.warning( + "exchange_token_with_server: server-side storage failed " + "for user=%s server=%s: %s", + user_id, + mcp_server.server_id, + exc, + ) + else: + verbose_logger.debug( + "exchange_token_with_server: no LiteLLM user_id found in request; " + "per-user token for server=%s will not be stored server-side. " + "The client should call POST /mcp/server/{id}/oauth-user-credential " + "to store it manually.", + mcp_server.server_id, + ) + result = { "access_token": access_token, "token_type": token_response.get("token_type", "Bearer"), diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 402e12d9356..8d3831e75fb 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -2455,6 +2455,37 @@ class MCPServerManager: ) tasks.append(during_hook_task) + # For per-user OAuth servers: if the client didn't supply a token in + # oauth2_headers, look up the stored token from Redis / DB. This is the + # call_tool equivalent of _get_user_oauth_extra_headers_from_db used in + # list_tools. + if ( + mcp_server.needs_user_oauth_token + and not oauth2_headers + and user_api_key_auth is not None + ): + user_id = getattr(user_api_key_auth, "user_id", None) + if user_id: + try: + from litellm.proxy._experimental.mcp_server.server import ( # noqa: PLC0415 + _get_user_oauth_extra_headers_from_db, + ) + + stored_headers = await _get_user_oauth_extra_headers_from_db( + server=mcp_server, + user_api_key_auth=user_api_key_auth, + ) + if stored_headers: + oauth2_headers = stored_headers + except Exception as _lookup_exc: + verbose_logger.debug( + "call_tool: per-user token lookup failed for " + "user=%s server=%s: %s", + user_id, + mcp_server.server_id, + _lookup_exc, + ) + # For OpenAPI servers, call the tool handler directly instead of via MCP client if mcp_server.spec_path: verbose_logger.debug( diff --git a/litellm/proxy/_experimental/mcp_server/oauth2_token_cache.py b/litellm/proxy/_experimental/mcp_server/oauth2_token_cache.py index 84a2e94467b..476e215666e 100644 --- a/litellm/proxy/_experimental/mcp_server/oauth2_token_cache.py +++ b/litellm/proxy/_experimental/mcp_server/oauth2_token_cache.py @@ -17,8 +17,15 @@ from litellm.constants import ( MCP_OAUTH2_TOKEN_CACHE_MAX_SIZE, MCP_OAUTH2_TOKEN_CACHE_MIN_TTL, MCP_OAUTH2_TOKEN_EXPIRY_BUFFER_SECONDS, + MCP_PER_USER_TOKEN_DEFAULT_TTL, + MCP_PER_USER_TOKEN_EXPIRY_BUFFER_SECONDS, + MCP_PER_USER_TOKEN_REDIS_KEY_PREFIX, ) from litellm.llms.custom_httpx.http_handler import get_async_httpx_client +from litellm.proxy.common_utils.encrypt_decrypt_utils import ( + decrypt_value_helper, + encrypt_value_helper, +) from litellm.types.llms.custom_http import httpxSpecialProvider if TYPE_CHECKING: @@ -152,6 +159,107 @@ class MCPOAuth2TokenCache(InMemoryCache): mcp_oauth2_token_cache = MCPOAuth2TokenCache() +def _compute_per_user_token_ttl(server: "MCPServer", expires_in: Optional[int]) -> int: + """Compute Redis TTL for a per-user token. + + Uses server.token_storage_ttl_seconds when configured; otherwise derives + TTL from expires_in minus the expiry buffer; falls back to the default TTL. + """ + if server.token_storage_ttl_seconds is not None: + return max(server.token_storage_ttl_seconds, 1) + if expires_in is not None: + return max( + expires_in - MCP_PER_USER_TOKEN_EXPIRY_BUFFER_SECONDS, + 1, + ) + return MCP_PER_USER_TOKEN_DEFAULT_TTL + + +class MCPPerUserTokenCache: + """Redis-backed cache for per-user OAuth2 access tokens. + + Uses LiteLLM's existing ``user_api_key_cache`` (DualCache with optional + Redis backend). Tokens are NaCl-encrypted with ``encrypt_value_helper`` + before storage so they are safe at rest in Redis. + + Redis key format: ``mcp:per_user_token:{user_id}:{server_id}`` + Redis value: ``encrypt_value_helper(access_token)`` — URL-safe base64 + """ + + def _cache_key(self, user_id: str, server_id: str) -> str: + return f"{MCP_PER_USER_TOKEN_REDIS_KEY_PREFIX}:{user_id}:{server_id}" + + async def get(self, user_id: str, server_id: str) -> Optional[str]: + """Return the plaintext access_token, or None on miss/error.""" + try: + from litellm.proxy.proxy_server import user_api_key_cache # noqa: PLC0415 + + key = self._cache_key(user_id, server_id) + encrypted = await user_api_key_cache.async_get_cache(key) + if encrypted is None: + return None + plaintext = decrypt_value_helper( + encrypted, + key="mcp_per_user_token", + exception_type="debug", + ) + return plaintext or None + except Exception as exc: + verbose_logger.debug( + "MCPPerUserTokenCache.get failed for user=%s server=%s: %s", + user_id, + server_id, + exc, + ) + return None + + async def set( + self, + user_id: str, + server_id: str, + access_token: str, + ttl: int, + ) -> None: + """Store NaCl-encrypted access_token in Redis with the given TTL.""" + try: + from litellm.proxy.proxy_server import user_api_key_cache # noqa: PLC0415 + + key = self._cache_key(user_id, server_id) + encrypted = encrypt_value_helper(access_token) + await user_api_key_cache.async_set_cache(key, encrypted, ttl=ttl) + verbose_logger.debug( + "MCPPerUserTokenCache.set: cached token for user=%s server=%s ttl=%ds", + user_id, + server_id, + ttl, + ) + except Exception as exc: + verbose_logger.debug( + "MCPPerUserTokenCache.set failed for user=%s server=%s: %s", + user_id, + server_id, + exc, + ) + + async def delete(self, user_id: str, server_id: str) -> None: + """Invalidate the cached token (removes from both in-memory and Redis layers).""" + try: + from litellm.proxy.proxy_server import user_api_key_cache # noqa: PLC0415 + + key = self._cache_key(user_id, server_id) + await user_api_key_cache.async_delete_cache(key) + except Exception as exc: + verbose_logger.debug( + "MCPPerUserTokenCache.delete failed for user=%s server=%s: %s", + user_id, + server_id, + exc, + ) + + +mcp_per_user_token_cache = MCPPerUserTokenCache() + + async def resolve_mcp_auth( server: "MCPServer", mcp_auth_header: Optional[Union[str, Dict[str, str]]] = None, diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 7fc28b68e9c..99578d006e1 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -896,11 +896,17 @@ if MCP_AVAILABLE: user_api_key_auth: Optional[UserAPIKeyAuth], prefetched_creds: Optional[Dict[str, Dict[str, Any]]] = None, ) -> Optional[Dict[str, str]]: - """Look up stored OAuth2 token for (user, server) from DB and return as extra_headers dict. + """Look up stored OAuth2 token for (user, server) and return as extra_headers dict. + + Lookup order: + 1. Redis cache (fast path, NaCl-decrypted) — skipped when prefetched_creds supplied + 2. prefetched_creds dict (pre-fetched batch DB query) or fresh DB query + 3. Auto-refresh when the stored token is expired and a refresh_token exists Args: prefetched_creds: Optional dict keyed by server_id with credential payloads. - When provided, avoids a per-server DB round-trip. + When provided, the Redis and individual DB lookups are + skipped in favour of the pre-fetched batch result. """ if server.auth_type != MCPAuth.oauth2: return None @@ -914,8 +920,27 @@ if MCP_AVAILABLE: from litellm.proxy._experimental.mcp_server.db import ( # noqa: PLC0415 get_user_oauth_credential, is_oauth_credential_expired, + refresh_user_oauth_token, + ) + from litellm.proxy._experimental.mcp_server.oauth2_token_cache import ( # noqa: PLC0415 + _compute_per_user_token_ttl, + mcp_per_user_token_cache, ) + # ── Fast path: Redis cache ──────────────────────────────────────── + # Only used when prefetched_creds is not supplied (individual lookup). + if prefetched_creds is None: + cached_token = await mcp_per_user_token_cache.get(user_id, server_id) + if cached_token is not None: + verbose_logger.debug( + "_get_user_oauth_extra_headers_from_db: Redis hit for " + "user=%s server=%s", + user_id, + server_id, + ) + return {"Authorization": f"Bearer {cached_token}"} + + # ── Slow path: DB lookup ────────────────────────────────────────── if prefetched_creds is not None: cred = prefetched_creds.get(server_id) else: @@ -929,18 +954,83 @@ if MCP_AVAILABLE: cred = await get_user_oauth_credential( prisma_client, user_id, server_id ) - if cred and cred.get("access_token"): - if is_oauth_credential_expired(cred): - verbose_logger.debug( - f"_get_user_oauth_extra_headers_from_db: token expired for " - f"user={user_id} server={server_id}" - ) + + if not cred or not cred.get("access_token"): + return None + + if is_oauth_credential_expired(cred): + verbose_logger.debug( + "_get_user_oauth_extra_headers_from_db: token expired for " + "user=%s server=%s — attempting refresh", + user_id, + server_id, + ) + # Attempt token refresh; requires a DB client (not available from prefetch) + if cred.get("refresh_token"): + try: + from litellm.proxy.utils import ( # noqa: PLC0415 + get_prisma_client_or_throw, + ) + + prisma_client = get_prisma_client_or_throw( + "Database not connected. Cannot refresh OAuth token." + ) + cred = await refresh_user_oauth_token( + prisma_client=prisma_client, + user_id=user_id, + server=server, + cred=cred, + ) + except Exception as refresh_exc: + verbose_logger.warning( + "_get_user_oauth_extra_headers_from_db: refresh failed " + "for user=%s server=%s: %s", + user_id, + server_id, + refresh_exc, + ) + cred = None + + if not cred or not cred.get("access_token"): + # Clear stale Redis/cache entry so we don't serve it again. + # Do this for both the individual and prefetch paths so the + # next request doesn't get a stale cache hit. + await mcp_per_user_token_cache.delete(user_id, server_id) return None - return {"Authorization": f"Bearer {cred['access_token']}"} + + access_token: str = cred["access_token"] + + # Warm (or re-warm) the Redis cache from the DB result. + # Always write regardless of whether expires_at is present — tokens + # without an expiry are still valid and should be cached using the + # server/default TTL so subsequent requests are fast. + if prefetched_creds is None: + raw_expires = None + expires_at = cred.get("expires_at") + if expires_at: + from datetime import datetime, timezone # noqa: PLC0415 + + try: + exp_dt = datetime.fromisoformat(expires_at) + if exp_dt.tzinfo is None: + exp_dt = exp_dt.replace(tzinfo=timezone.utc) + remaining = int( + (exp_dt - datetime.now(timezone.utc)).total_seconds() + ) + raw_expires = max(remaining, 0) if remaining > 0 else None + except (ValueError, TypeError): + pass + ttl = _compute_per_user_token_ttl(server, raw_expires) + await mcp_per_user_token_cache.set(user_id, server_id, access_token, ttl) + + return {"Authorization": f"Bearer {access_token}"} except Exception as e: verbose_logger.warning( - f"_get_user_oauth_extra_headers_from_db: failed to retrieve credential for " - f"user={user_id} server={server_id}: {e}" + "_get_user_oauth_extra_headers_from_db: failed to retrieve credential for " + "user=%s server=%s: %s", + user_id, + server_id, + e, ) return None @@ -2504,6 +2594,14 @@ if MCP_AVAILABLE: server_name, client_ip=_client_ip ) if server and server.auth_type == MCPAuth.oauth2 and not oauth2_headers: + # For servers that store per-user tokens server-side, skip the + # pre-emptive 401 — the call_tool / list_tools dispatch will look + # up the stored token from Redis / DB and only fail at the MCP + # protocol level if none is found, giving the client a proper + # tool-execution error rather than an HTTP 401. + if server.needs_user_oauth_token: + continue + request = StarletteRequest(scope) base_url = get_request_base_url(request) diff --git a/litellm/types/mcp_server/mcp_server_manager.py b/litellm/types/mcp_server/mcp_server_manager.py index db7657a0174..a7d0968c0ef 100644 --- a/litellm/types/mcp_server/mcp_server_manager.py +++ b/litellm/types/mcp_server/mcp_server_manager.py @@ -71,6 +71,15 @@ class MCPServer(BaseModel): # OAuth2 flow type. Defaults to None (interactive / authorization_code). # Set to "client_credentials" to enable M2M token fetching. oauth2_flow: Optional[Literal["client_credentials", "authorization_code"]] = None + # Per-user OAuth server-side storage config. + # token_validation: key-value pairs that must match fields in the OAuth token + # response (supports dot-notation for nested fields, e.g. "team.enterprise_id"). + # Tokens that fail validation are rejected before storage. + token_validation: Optional[Dict[str, Any]] = None + # Optional TTL override (seconds) for the Redis per-user token cache. + # Defaults to the token's expires_in minus the expiry buffer, or + # MCP_PER_USER_TOKEN_DEFAULT_TTL when expires_in is absent. + token_storage_ttl_seconds: Optional[int] = None model_config = ConfigDict(arbitrary_types_allowed=True) @property diff --git a/tests/mcp_tests/test_per_user_oauth_cache.py b/tests/mcp_tests/test_per_user_oauth_cache.py new file mode 100644 index 00000000000..36c26a5a505 --- /dev/null +++ b/tests/mcp_tests/test_per_user_oauth_cache.py @@ -0,0 +1,527 @@ +""" +Unit tests for per-user MCP OAuth token storage: +- MCPPerUserTokenCache (NaCl-encrypted Redis cache) +- _validate_token_response (token validation rules) +- _compute_per_user_token_ttl (TTL computation) +- refresh_user_oauth_token (token refresh flow) +""" + +import sys +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, Optional +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +# Stub out modules that aren't available in the unit-test environment +# so we can import the targets without a full proxy stack. +for _mod in ("orjson",): + if _mod not in sys.modules: + sys.modules[_mod] = MagicMock() + +from litellm.proxy._experimental.mcp_server.oauth2_token_cache import ( # noqa: E402 + MCPPerUserTokenCache, + _compute_per_user_token_ttl, + mcp_per_user_token_cache, +) +from litellm.types.mcp import MCPAuth, MCPTransport # noqa: E402 +from litellm.types.mcp_server.mcp_server_manager import MCPServer # noqa: E402 + + +def _import_validate(): + """Lazy import to avoid pulling orjson at collection time.""" + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + _validate_token_response, + ) + + return _validate_token_response + + +# ── Fixtures ───────────────────────────────────────────────────────────────── + + +def _make_server(**kwargs) -> MCPServer: + defaults: Dict[str, Any] = { + "server_id": "slack-test", + "name": "Slack", + "server_name": "slack", + "url": "https://slack-mcp.example.com/mcp", + "transport": MCPTransport.http, + "auth_type": MCPAuth.oauth2, + "client_id": "SLACK_CLIENT_ID", + "client_secret": "SLACK_CLIENT_SECRET", + "token_url": "https://slack.com/api/oauth.v2.access", + "authorization_url": "https://slack.com/oauth/v2/authorize", + } + defaults.update(kwargs) + return MCPServer(**defaults) + + +# ── _validate_token_response ────────────────────────────────────────────────── + + +class TestValidateTokenResponse: + def test_passes_when_all_rules_match(self): + _validate_token_response = _import_validate() + token_response = { + "access_token": "xoxb-123", + "enterprise_id": "E04XXXXXX", + "team": {"id": "T123", "name": "Acme"}, + } + # Should not raise + _validate_token_response( + token_response=token_response, + validation_rules={"enterprise_id": "E04XXXXXX"}, + server_id="slack-test", + ) + + def test_raises_on_mismatch(self): + from fastapi import HTTPException + + _validate_token_response = _import_validate() + token_response = {"access_token": "xoxb-123", "enterprise_id": "E99999999"} + with pytest.raises(HTTPException) as exc_info: + _validate_token_response( + token_response=token_response, + validation_rules={"enterprise_id": "E04XXXXXX"}, + server_id="slack-test", + ) + assert exc_info.value.status_code == 403 + detail = exc_info.value.detail + assert detail["error"] == "token_validation_failed" + assert detail["field"] == "enterprise_id" + + def test_raises_when_field_absent(self): + from fastapi import HTTPException + + _validate_token_response = _import_validate() + token_response = {"access_token": "xoxb-123"} + with pytest.raises(HTTPException) as exc_info: + _validate_token_response( + token_response=token_response, + validation_rules={"enterprise_id": "E04XXXXXX"}, + server_id="slack-test", + ) + assert exc_info.value.status_code == 403 + # Absent field should produce a distinct "absent" message, not str(None) + assert "absent" in exc_info.value.detail["message"] + + def test_absent_field_does_not_match_string_none(self): + """str(None)='None' must NOT match the string rule value 'None'.""" + from fastapi import HTTPException + + _validate_token_response = _import_validate() + token_response = {"access_token": "tok"} # enterprise_id absent + # Even if admin writes validation_rules={"enterprise_id": "None"}, absent + # field should raise, not pass. + with pytest.raises(HTTPException) as exc_info: + _validate_token_response( + token_response=token_response, + validation_rules={"enterprise_id": "None"}, + server_id="slack-test", + ) + assert exc_info.value.status_code == 403 + assert "absent" in exc_info.value.detail["message"] + + def test_dot_notation_nested_field(self): + _validate_token_response = _import_validate() + token_response = { + "access_token": "xoxb-123", + "team": {"enterprise_id": "E04XXXXXX"}, + } + # Should not raise — dot-notation traverses nested dict + _validate_token_response( + token_response=token_response, + validation_rules={"team.enterprise_id": "E04XXXXXX"}, + server_id="slack-test", + ) + + def test_dot_notation_mismatch(self): + from fastapi import HTTPException + + _validate_token_response = _import_validate() + token_response = { + "access_token": "xoxb-123", + "team": {"enterprise_id": "WRONG"}, + } + with pytest.raises(HTTPException) as exc_info: + _validate_token_response( + token_response=token_response, + validation_rules={"team.enterprise_id": "E04XXXXXX"}, + server_id="slack-test", + ) + assert exc_info.value.status_code == 403 + assert exc_info.value.detail["field"] == "team.enterprise_id" + + def test_numeric_value_string_coercion(self): + """Numeric values in token response should match string rules.""" + _validate_token_response = _import_validate() + token_response = {"access_token": "tok", "org_id": 12345} + # Should not raise — str(12345) == "12345" + _validate_token_response( + token_response=token_response, + validation_rules={"org_id": "12345"}, + server_id="test", + ) + + def test_multiple_rules_all_must_match(self): + from fastapi import HTTPException + + _validate_token_response = _import_validate() + token_response = { + "access_token": "tok", + "enterprise_id": "E04XXXXXX", + "cloud_id": "WRONG_CLOUD", + } + with pytest.raises(HTTPException): + _validate_token_response( + token_response=token_response, + validation_rules={ + "enterprise_id": "E04XXXXXX", + "cloud_id": "abc-123", + }, + server_id="atlassian", + ) + + +# ── _compute_per_user_token_ttl ────────────────────────────────────────────── + + +class TestComputePerUserTokenTtl: + def test_uses_server_override_when_set(self): + server = _make_server(token_storage_ttl_seconds=7200) + assert _compute_per_user_token_ttl(server, expires_in=99999) == 7200 + + def test_uses_expires_in_minus_buffer(self): + server = _make_server() + # Default buffer is 60s + ttl = _compute_per_user_token_ttl(server, expires_in=3600) + assert ttl == 3600 - 60 + + def test_minimum_ttl_is_1(self): + server = _make_server() + # expires_in smaller than buffer → clamp to 1 + ttl = _compute_per_user_token_ttl(server, expires_in=30) + assert ttl == 1 + + def test_default_ttl_when_expires_in_none(self): + from litellm.constants import MCP_PER_USER_TOKEN_DEFAULT_TTL + + server = _make_server() + ttl = _compute_per_user_token_ttl(server, expires_in=None) + assert ttl == MCP_PER_USER_TOKEN_DEFAULT_TTL + + +# ── MCPPerUserTokenCache ────────────────────────────────────────────────────── + + +class TestMCPPerUserTokenCache: + """Tests for Redis-backed per-user token cache. + + Patches ``user_api_key_cache`` to avoid needing a real Redis instance. + Patches ``encrypt_value_helper`` / ``decrypt_value_helper`` to verify + encryption is applied before Redis writes and decryption after reads. + """ + + @pytest.fixture + def cache(self): + return MCPPerUserTokenCache() + + @pytest.fixture + def mock_dual_cache(self): + dc = MagicMock() + dc.async_get_cache = AsyncMock(return_value=None) + dc.async_set_cache = AsyncMock() + return dc + + @pytest.mark.asyncio + async def test_get_returns_none_on_miss(self, cache, mock_dual_cache): + with patch( + "litellm.proxy._experimental.mcp_server.oauth2_token_cache.decrypt_value_helper" + ) as mock_decrypt, patch( + "litellm.proxy.proxy_server.user_api_key_cache", mock_dual_cache + ): + mock_dual_cache.async_get_cache.return_value = None + result = await cache.get("alice", "slack-test") + assert result is None + mock_decrypt.assert_not_called() + + @pytest.mark.asyncio + async def test_get_decrypts_cached_value(self, cache, mock_dual_cache): + fake_encrypted = "encrypted_blob_abc123" + fake_plaintext = "xoxb-slack-token" + with patch( + "litellm.proxy._experimental.mcp_server.oauth2_token_cache.decrypt_value_helper", + return_value=fake_plaintext, + ) as mock_decrypt, patch( + "litellm.proxy.proxy_server.user_api_key_cache", mock_dual_cache + ): + mock_dual_cache.async_get_cache.return_value = fake_encrypted + result = await cache.get("alice", "slack-test") + + assert result == fake_plaintext + mock_decrypt.assert_called_once_with( + fake_encrypted, + key="mcp_per_user_token", + exception_type="debug", + ) + + @pytest.mark.asyncio + async def test_set_encrypts_before_storing(self, cache, mock_dual_cache): + fake_encrypted = "encrypted_blob_xyz" + with patch( + "litellm.proxy._experimental.mcp_server.oauth2_token_cache.encrypt_value_helper", + return_value=fake_encrypted, + ) as mock_encrypt, patch( + "litellm.proxy.proxy_server.user_api_key_cache", mock_dual_cache + ): + await cache.set("alice", "slack-test", "xoxb-token", ttl=3540) + + mock_encrypt.assert_called_once_with("xoxb-token") + mock_dual_cache.async_set_cache.assert_called_once() + call_kwargs = mock_dual_cache.async_set_cache.call_args + assert call_kwargs[0][1] == fake_encrypted # encrypted value stored + assert call_kwargs[1]["ttl"] == 3540 + + @pytest.mark.asyncio + async def test_set_uses_correct_cache_key(self, cache, mock_dual_cache): + with patch( + "litellm.proxy._experimental.mcp_server.oauth2_token_cache.encrypt_value_helper", + return_value="enc", + ), patch( + "litellm.proxy.proxy_server.user_api_key_cache", mock_dual_cache + ): + await cache.set("bob", "github-server", "ghp_token", ttl=3600) + + key_used = mock_dual_cache.async_set_cache.call_args[0][0] + assert key_used == "mcp:per_user_token:bob:github-server" + + @pytest.mark.asyncio + async def test_delete_calls_async_delete_cache(self, cache, mock_dual_cache): + mock_dual_cache.async_delete_cache = AsyncMock() + with patch( + "litellm.proxy.proxy_server.user_api_key_cache", mock_dual_cache + ): + await cache.delete("alice", "slack-test") + + mock_dual_cache.async_delete_cache.assert_called_once_with( + "mcp:per_user_token:alice:slack-test" + ) + mock_dual_cache.async_set_cache.assert_not_called() + + @pytest.mark.asyncio + async def test_get_returns_none_on_decrypt_failure(self, cache, mock_dual_cache): + """Cache misses and decrypt errors should both return None without raising.""" + with patch( + "litellm.proxy._experimental.mcp_server.oauth2_token_cache.decrypt_value_helper", + return_value=None, # decrypt returns None on failure + ), patch( + "litellm.proxy.proxy_server.user_api_key_cache", mock_dual_cache + ): + mock_dual_cache.async_get_cache.return_value = "bad_encrypted_data" + result = await cache.get("alice", "slack-test") + + assert result is None + + @pytest.mark.asyncio + async def test_set_is_noop_on_cache_error(self, cache, mock_dual_cache): + """Errors in the cache layer must not propagate to the caller.""" + mock_dual_cache.async_set_cache.side_effect = RuntimeError("Redis down") + with patch( + "litellm.proxy._experimental.mcp_server.oauth2_token_cache.encrypt_value_helper", + return_value="enc", + ), patch( + "litellm.proxy.proxy_server.user_api_key_cache", mock_dual_cache + ): + # Should not raise + await cache.set("alice", "slack-test", "token", ttl=3600) + + +# ── refresh_user_oauth_token ────────────────────────────────────────────────── + + +class TestRefreshUserOauthToken: + """Tests for the DB-level token refresh helper.""" + + @pytest.fixture + def server(self): + return _make_server() + + @pytest.fixture + def cred(self): + return { + "type": "oauth2", + "access_token": "OLD_TOKEN", + "refresh_token": "REFRESH_TOKEN_123", + "expires_at": ( + datetime.now(timezone.utc) - timedelta(hours=1) + ).isoformat(), + } + + @pytest.mark.asyncio + async def test_returns_none_when_no_refresh_token(self, server): + from litellm.proxy._experimental.mcp_server.db import refresh_user_oauth_token + + cred = {"type": "oauth2", "access_token": "OLD"} # no refresh_token + result = await refresh_user_oauth_token( + prisma_client=MagicMock(), + user_id="alice", + server=server, + cred=cred, + ) + assert result is None + + @pytest.mark.asyncio + async def test_returns_none_when_no_token_url(self, cred): + from litellm.proxy._experimental.mcp_server.db import refresh_user_oauth_token + + server = _make_server(token_url=None) + result = await refresh_user_oauth_token( + prisma_client=MagicMock(), + user_id="alice", + server=server, + cred=cred, + ) + assert result is None + + @pytest.mark.asyncio + async def test_returns_none_on_http_error(self, server, cred): + from litellm.proxy._experimental.mcp_server.db import refresh_user_oauth_token + + mock_client = AsyncMock() + mock_client.post.side_effect = Exception("Connection refused") + + with patch( + "litellm.proxy._experimental.mcp_server.db.get_async_httpx_client", + return_value=mock_client, + ): + result = await refresh_user_oauth_token( + prisma_client=MagicMock(), + user_id="alice", + server=server, + cred=cred, + ) + assert result is None + + @pytest.mark.asyncio + async def test_stores_and_returns_new_credential(self, server, cred): + from litellm.proxy._experimental.mcp_server.db import refresh_user_oauth_token + + new_token_response = MagicMock() + new_token_response.json.return_value = { + "access_token": "NEW_TOKEN", + "expires_in": 3600, + "refresh_token": "NEW_REFRESH", + "scope": "channels:read chat:write", + } + new_token_response.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.post.return_value = new_token_response + + stored_cred = { + "type": "oauth2", + "access_token": "NEW_TOKEN", + "refresh_token": "NEW_REFRESH", + } + mock_prisma = AsyncMock() + + with patch( + "litellm.proxy._experimental.mcp_server.db.get_async_httpx_client", + return_value=mock_client, + ), patch( + "litellm.proxy._experimental.mcp_server.db.store_user_oauth_credential", + new_callable=AsyncMock, + ) as mock_store, patch( + "litellm.proxy._experimental.mcp_server.db.get_user_oauth_credential", + new_callable=AsyncMock, + return_value=stored_cred, + ): + result = await refresh_user_oauth_token( + prisma_client=mock_prisma, + user_id="alice", + server=server, + cred=cred, + ) + + assert result == stored_cred + mock_store.assert_called_once() + call_kwargs = mock_store.call_args[1] + assert call_kwargs["access_token"] == "NEW_TOKEN" + assert call_kwargs["refresh_token"] == "NEW_REFRESH" + assert call_kwargs["expires_in"] == 3600 + assert call_kwargs["scopes"] == ["channels:read", "chat:write"] + # Refresh path must skip the BYOK guard (row is already OAuth2) + assert call_kwargs.get("skip_byok_guard") is True + + @pytest.mark.asyncio + async def test_falls_back_to_old_refresh_token_when_not_rotated( + self, server, cred + ): + """When provider doesn't return a new refresh_token, keep the old one.""" + from litellm.proxy._experimental.mcp_server.db import refresh_user_oauth_token + + new_token_response = MagicMock() + new_token_response.json.return_value = { + "access_token": "NEW_TOKEN", + "expires_in": 3600, + # No refresh_token in response + } + new_token_response.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.post.return_value = new_token_response + + with patch( + "litellm.proxy._experimental.mcp_server.db.get_async_httpx_client", + return_value=mock_client, + ), patch( + "litellm.proxy._experimental.mcp_server.db.store_user_oauth_credential", + new_callable=AsyncMock, + ) as mock_store, patch( + "litellm.proxy._experimental.mcp_server.db.get_user_oauth_credential", + new_callable=AsyncMock, + return_value={"type": "oauth2", "access_token": "NEW_TOKEN"}, + ): + await refresh_user_oauth_token( + prisma_client=AsyncMock(), + user_id="alice", + server=server, + cred=cred, + ) + + call_kwargs = mock_store.call_args[1] + # Old refresh_token preserved when provider doesn't rotate + assert call_kwargs["refresh_token"] == "REFRESH_TOKEN_123" + + +# ── MCPServer new fields ────────────────────────────────────────────────────── + + +class TestMCPServerNewFields: + def test_token_validation_default_none(self): + server = _make_server() + assert server.token_validation is None + + def test_token_validation_set(self): + server = _make_server(token_validation={"enterprise_id": "E04XXXXXX"}) + assert server.token_validation == {"enterprise_id": "E04XXXXXX"} + + def test_token_storage_ttl_default_none(self): + server = _make_server() + assert server.token_storage_ttl_seconds is None + + def test_token_storage_ttl_set(self): + server = _make_server(token_storage_ttl_seconds=7200) + assert server.token_storage_ttl_seconds == 7200 + + def test_needs_user_oauth_token_true_for_oauth2_without_m2m(self): + server = _make_server(auth_type=MCPAuth.oauth2) + assert server.needs_user_oauth_token is True + + def test_needs_user_oauth_token_false_for_m2m(self): + server = _make_server( + auth_type=MCPAuth.oauth2, + oauth2_flow="client_credentials", + ) + assert server.needs_user_oauth_token is False diff --git a/ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.test.tsx new file mode 100644 index 00000000000..888f3066252 --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.test.tsx @@ -0,0 +1,208 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor, act, fireEvent } from "@testing-library/react"; +import { Form } from "antd"; +import OAuthFormFields from "./OAuthFormFields"; + +// ── helpers ────────────────────────────────────────────────────────────────── + +/** Minimal Ant Form wrapper so Form.Item registers correctly. */ +const WithForm: React.FC<{ children: React.ReactNode; onFinish?: (values: any) => void }> = ({ + children, + onFinish, +}) => { + const [form] = Form.useForm(); + return ( + + {children} + + + ); +}; + +// ── tests ───────────────────────────────────────────────────────────────────── + +describe("OAuthFormFields", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ── visibility by flow type ───────────────────────────────────────────────── + + describe("interactive mode (isM2M=false)", () => { + it("renders Token Validation Rules field", () => { + render( + + + , + ); + expect(screen.getByText("Token Validation Rules (optional)")).toBeInTheDocument(); + }); + + it("renders Token Storage TTL field", () => { + render( + + + , + ); + expect(screen.getByText("Token Storage TTL (seconds, optional)")).toBeInTheDocument(); + }); + + it("renders standard interactive fields alongside the new fields", () => { + render( + + + , + ); + expect(screen.getByText("Authorization URL (optional)")).toBeInTheDocument(); + expect(screen.getByText("Registration URL (optional)")).toBeInTheDocument(); + expect(screen.getByText("Token Validation Rules (optional)")).toBeInTheDocument(); + expect(screen.getByText("Token Storage TTL (seconds, optional)")).toBeInTheDocument(); + }); + }); + + describe("M2M mode (isM2M=true)", () => { + it("does NOT render Token Validation Rules field", () => { + render( + + + , + ); + expect(screen.queryByText("Token Validation Rules (optional)")).not.toBeInTheDocument(); + }); + + it("does NOT render Token Storage TTL field", () => { + render( + + + , + ); + expect(screen.queryByText("Token Storage TTL (seconds, optional)")).not.toBeInTheDocument(); + }); + + it("still renders M2M-specific fields", () => { + render( + + + , + ); + expect(screen.getByText("Client ID")).toBeInTheDocument(); + expect(screen.getByText("Token URL")).toBeInTheDocument(); + }); + }); + + // ── token_validation_json inline JSON validator ────────────────────────────── + + describe("token_validation_json validation", () => { + it("accepts empty value without error", async () => { + const onFinish = vi.fn(); + render( + + + , + ); + + // Leave the textarea empty and submit + const submitBtn = screen.getByRole("button", { name: "Submit" }); + await act(async () => { + fireEvent.click(submitBtn); + }); + + await waitFor(() => { + expect(screen.queryByText("Must be valid JSON")).not.toBeInTheDocument(); + }); + }); + + it("accepts a valid JSON object without error", async () => { + render( + + + , + ); + + const textarea = document.getElementById("token_validation_json") as HTMLTextAreaElement; + await act(async () => { + fireEvent.change(textarea, { target: { value: '{"organization": "my-org"}' } }); + }); + + const submitBtn = screen.getByRole("button", { name: "Submit" }); + await act(async () => { + fireEvent.click(submitBtn); + }); + + await waitFor(() => { + expect(screen.queryByText("Must be valid JSON")).not.toBeInTheDocument(); + }); + }); + + it("shows 'Must be valid JSON' error for malformed JSON", async () => { + render( + + + , + ); + + const textarea = document.getElementById("token_validation_json") as HTMLTextAreaElement; + await act(async () => { + fireEvent.change(textarea, { target: { value: "not-valid-json{" } }); + }); + + const submitBtn = screen.getByRole("button", { name: "Submit" }); + await act(async () => { + fireEvent.click(submitBtn); + }); + + await waitFor(() => { + expect(screen.getByText("Must be valid JSON")).toBeInTheDocument(); + }); + }); + + it("shows error for a plain string value (not a JSON object)", async () => { + render( + + + , + ); + + const textarea = document.getElementById("token_validation_json") as HTMLTextAreaElement; + await act(async () => { + // A bare string is valid JSON but we still want to accept it; only truly + // unparseable text should fail. Bare "hello" is actually invalid JSON + // (no quotes), so it should fail. + fireEvent.change(textarea, { target: { value: "hello" } }); + }); + + const submitBtn = screen.getByRole("button", { name: "Submit" }); + await act(async () => { + fireEvent.click(submitBtn); + }); + + await waitFor(() => { + expect(screen.getByText("Must be valid JSON")).toBeInTheDocument(); + }); + }); + + it("whitespace-only value is treated as empty and passes validation", async () => { + const onFinish = vi.fn(); + render( + + + , + ); + + const textarea = document.getElementById("token_validation_json") as HTMLTextAreaElement; + await act(async () => { + fireEvent.change(textarea, { target: { value: " " } }); + }); + + const submitBtn = screen.getByRole("button", { name: "Submit" }); + await act(async () => { + fireEvent.click(submitBtn); + }); + + await waitFor(() => { + expect(screen.queryByText("Must be valid JSON")).not.toBeInTheDocument(); + }); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.tsx b/ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.tsx index 85487a8a479..4a808ca489d 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Form, Select, Tooltip } from "antd"; +import { Form, Input, InputNumber, Select, Tooltip } from "antd"; import { InfoCircleOutlined } from "@ant-design/icons"; import { Button, TextInput } from "@tremor/react"; import { OAUTH_FLOW } from "./types"; @@ -151,6 +151,50 @@ const OAuthFormFields: React.FC = ({ > + + } + name="token_validation_json" + rules={[ + { + validator: (_: any, value: string) => { + if (!value || value.trim() === "") return Promise.resolve(); + try { + JSON.parse(value); + return Promise.resolve(); + } catch { + return Promise.reject(new Error("Must be valid JSON")); + } + }, + }, + ]} + > + + + + } + name="token_storage_ttl_seconds" + > + + {oauthFlow && (

diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx index d49c49446bb..c92956b430f 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx @@ -353,6 +353,147 @@ describe("CreateMCPServer", () => { ); }); + describe("when OAuth interactive auth is selected", () => { + /** Select HTTP transport + OAuth auth, then wait for the OAuth form to appear. */ + async function setupOAuthInteractive() { + render(); + await selectAntOption("Transport Type", "Streamable HTTP"); + + await waitFor(() => { + expect(screen.getByPlaceholderText("https://your-mcp-server.com")).toBeInTheDocument(); + }); + + await selectAntOption("Authentication", "OAuth"); + + // Wait for OAuthFormFields to render (OAuth Flow Type selector is the sentinel) + await waitFor(() => { + expect(screen.getByText("OAuth Flow Type")).toBeInTheDocument(); + }); + + // OAuthFormFields defaults to INTERACTIVE, so the new fields should appear + await waitFor(() => { + expect(screen.getByText("Token Validation Rules (optional)")).toBeInTheDocument(); + expect(screen.getByText("Token Storage TTL (seconds, optional)")).toBeInTheDocument(); + }); + } + + it("shows Token Validation Rules and Token Storage TTL fields", async () => { + await setupOAuthInteractive(); + // Asserted in setupOAuthInteractive + }); + + it("includes token_validation in payload when token_validation_json is filled with valid JSON", async () => { + vi.mocked(networking.createMCPServer).mockResolvedValue({ + server_id: "new-server-oauth", + server_name: "OAuth_Server", + alias: "OAuth_Server", + url: "https://example.com/mcp", + transport: "http", + auth_type: "oauth2", + created_at: "2024-01-01T00:00:00Z", + created_by: "user-1", + updated_at: "2024-01-01T00:00:00Z", + updated_by: "user-1", + }); + + await setupOAuthInteractive(); + + // Fill required form fields + const nameInput = document.getElementById("server_name") as HTMLInputElement; + await act(async () => { + fireEvent.change(nameInput, { target: { value: "OAuth_Server" } }); + }); + const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com"); + await act(async () => { + fireEvent.change(urlInput, { target: { value: "https://example.com/mcp" } }); + }); + + // Fill in the token_validation_json textarea + const textarea = document.getElementById("token_validation_json") as HTMLTextAreaElement; + await act(async () => { + fireEvent.change(textarea, { target: { value: '{"organization": "my-org", "team.id": "42"}' } }); + }); + + const submitButton = screen.getByRole("button", { name: "Add MCP Server" }); + await act(async () => { + fireEvent.click(submitButton); + }); + + await waitFor(() => { + expect(networking.createMCPServer).toHaveBeenCalledTimes(1); + }); + + const [, payload] = vi.mocked(networking.createMCPServer).mock.calls[0]; + expect(payload.token_validation).toEqual({ organization: "my-org", "team.id": "42" }); + }); + + it("omits token_validation from payload when token_validation_json is empty", async () => { + vi.mocked(networking.createMCPServer).mockResolvedValue({ + server_id: "new-server-oauth", + server_name: "OAuth_Server", + alias: "OAuth_Server", + url: "https://example.com/mcp", + transport: "http", + auth_type: "oauth2", + created_at: "2024-01-01T00:00:00Z", + created_by: "user-1", + updated_at: "2024-01-01T00:00:00Z", + updated_by: "user-1", + }); + + await setupOAuthInteractive(); + + const nameInput = document.getElementById("server_name") as HTMLInputElement; + await act(async () => { + fireEvent.change(nameInput, { target: { value: "OAuth_Server" } }); + }); + const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com"); + await act(async () => { + fireEvent.change(urlInput, { target: { value: "https://example.com/mcp" } }); + }); + + // Leave token_validation_json empty + const submitButton = screen.getByRole("button", { name: "Add MCP Server" }); + await act(async () => { + fireEvent.click(submitButton); + }); + + await waitFor(() => { + expect(networking.createMCPServer).toHaveBeenCalledTimes(1); + }); + + const [, payload] = vi.mocked(networking.createMCPServer).mock.calls[0]; + expect(payload.token_validation).toBeUndefined(); + }); + + it("does not submit and shows validation error for invalid JSON in token_validation_json", async () => { + await setupOAuthInteractive(); + + const textarea = document.getElementById("token_validation_json") as HTMLTextAreaElement; + await act(async () => { + fireEvent.change(textarea, { target: { value: "not-valid-json{" } }); + }); + + const nameInput = document.getElementById("server_name") as HTMLInputElement; + await act(async () => { + fireEvent.change(nameInput, { target: { value: "OAuth_Server" } }); + }); + + const submitButton = screen.getByRole("button", { name: "Add MCP Server" }); + await act(async () => { + fireEvent.click(submitButton); + }); + + // Either the inline form validation message or the notification fires — + // both indicate the submit was blocked. + await waitFor(() => { + const inlineError = screen.queryByText("Must be valid JSON"); + const notCalled = !vi.mocked(networking.createMCPServer).mock.calls.length; + expect(inlineError !== null || notCalled).toBe(true); + }); + }); + }); + describe("when modal is cancelled", () => { it("should call setModalVisible(false) when cancel is clicked", async () => { render(); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx index 4c824fcee0b..45556bc18b1 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx @@ -284,6 +284,7 @@ const CreateMCPServer: React.FC = ({ credentials: credentialValues, allow_all_keys: allowAllKeysRaw, available_on_public_internet: availableOnPublicInternetRaw, + token_validation_json: rawTokenValidationJson, ...restValues } = values; @@ -356,6 +357,18 @@ const CreateMCPServer: React.FC = ({ restValues.transport = "http"; } + // Parse token_validation JSON if provided + let tokenValidation: Record | null = null; + if (rawTokenValidationJson && rawTokenValidationJson.trim() !== "") { + try { + tokenValidation = JSON.parse(rawTokenValidationJson); + } catch { + NotificationsManager.fromBackend("Invalid JSON in Token Validation Rules"); + setIsLoading(false); + return; + } + } + // Prepare the payload with cost configuration and allowed tools const payload: Record = { ...restValues, @@ -376,6 +389,7 @@ const CreateMCPServer: React.FC = ({ allow_all_keys: Boolean(allowAllKeysRaw), available_on_public_internet: Boolean(availableOnPublicInternetRaw), static_headers: staticHeaders, + ...(tokenValidation !== null && { token_validation: tokenValidation }), }; payload.static_headers = staticHeaders; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx index e33e2fff491..aba2a3d9222 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx @@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor, fireEvent, act } from "@testing-library/react"; import MCPServerEdit from "./mcp_server_edit"; import * as networking from "../networking"; +import NotificationsManager from "../molecules/notifications_manager"; vi.mock("../networking", () => ({ updateMCPServer: vi.fn(), @@ -37,6 +38,29 @@ vi.mock("./mcp_tool_configuration", () => ({ default: () =>

, })); +// ── fixtures ────────────────────────────────────────────────────────────────── + +const interactiveOAuthServer = { + server_id: "oauth_server_1", + server_name: "OAuthServer", + alias: "oauth_server", // underscores: hyphens fail validateMCPServerName + description: "Interactive OAuth MCP server", + transport: "http", + url: "https://example.com/mcp", + auth_type: "oauth2", + // No token_url → edit form defaults to INTERACTIVE flow + token_url: null, + authorization_url: null, + registration_url: null, + created_at: "2024-01-01T00:00:00Z", + created_by: "user-1", + updated_at: "2024-01-01T00:00:00Z", + updated_by: "user-1", + mcp_access_groups: [], +}; + +// ── test suites ─────────────────────────────────────────────────────────────── + describe("MCPServerEdit (stdio)", () => { beforeEach(() => { vi.clearAllMocks(); @@ -152,3 +176,228 @@ describe("MCPServerEdit (stdio)", () => { expect(payload.env).toEqual({ CIRCLECI_TOKEN: "new-token", CIRCLECI_BASE_URL: "https://circleci.com" }); }); }); + +describe("MCPServerEdit (interactive OAuth)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders Token Validation Rules and Token Storage TTL fields for interactive OAuth server", async () => { + render( + , + ); + + await waitFor(() => { + expect(screen.getByText("Token Validation Rules (optional)")).toBeInTheDocument(); + expect(screen.getByText("Token Storage TTL (seconds, optional)")).toBeInTheDocument(); + }); + }); + + // Note: The M2M flow hiding logic is tested via OAuthFormFields.test.tsx (isM2M prop directly), + // since Form.useWatch doesn't synchronously reflect initialValues in jsdom. + + it("pre-populates token_validation_json from existing server token_validation", async () => { + const tokenValidation = { organization: "my-org", "team.id": "123" }; + + render( + , + ); + + await waitFor(() => { + const textarea = document.getElementById("token_validation_json") as HTMLTextAreaElement; + expect(textarea).not.toBeNull(); + const parsed = JSON.parse(textarea.value); + expect(parsed).toEqual(tokenValidation); + }); + }); + + it("includes token_validation in update payload when token_validation_json is filled", async () => { + const onSuccess = vi.fn(); + vi.mocked(networking.updateMCPServer).mockResolvedValue({ + ...interactiveOAuthServer, + token_validation: { organization: "my-org" }, + }); + + render( + , + ); + + // Wait for the form to mount and the token_validation_json field to appear + await waitFor(() => { + expect(screen.getByText("Token Validation Rules (optional)")).toBeInTheDocument(); + }); + + const textarea = document.getElementById("token_validation_json") as HTMLTextAreaElement; + await act(async () => { + fireEvent.change(textarea, { target: { value: '{"organization": "my-org"}' } }); + }); + + const saveButtons = screen.getAllByRole("button", { name: "Save Changes" }); + await act(async () => { + fireEvent.click(saveButtons[0]); + }); + + await waitFor(() => { + expect(networking.updateMCPServer).toHaveBeenCalledTimes(1); + }); + + const [, payload] = vi.mocked(networking.updateMCPServer).mock.calls[0]; + expect(payload.token_validation).toEqual({ organization: "my-org" }); + }); + + it("does not include token_validation in payload when field is empty and server had none", async () => { + vi.mocked(networking.updateMCPServer).mockResolvedValue(interactiveOAuthServer); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText("Token Validation Rules (optional)")).toBeInTheDocument(); + }); + + // Leave token_validation_json empty + const saveButtons = screen.getAllByRole("button", { name: "Save Changes" }); + await act(async () => { + fireEvent.click(saveButtons[0]); + }); + + await waitFor(() => { + expect(networking.updateMCPServer).toHaveBeenCalledTimes(1); + }); + + const [, payload] = vi.mocked(networking.updateMCPServer).mock.calls[0]; + expect(payload.token_validation).toBeUndefined(); + }); + + it("sends token_validation: null to clear an existing value when textarea is cleared", async () => { + vi.mocked(networking.updateMCPServer).mockResolvedValue({ + ...interactiveOAuthServer, + token_validation: null, + }); + + render( + , + ); + + await waitFor(() => { + const textarea = document.getElementById("token_validation_json") as HTMLTextAreaElement; + expect(textarea?.value).toContain("old-org"); + }); + + // Clear the textarea + const textarea = document.getElementById("token_validation_json") as HTMLTextAreaElement; + await act(async () => { + fireEvent.change(textarea, { target: { value: "" } }); + }); + + const saveButtons = screen.getAllByRole("button", { name: "Save Changes" }); + await act(async () => { + fireEvent.click(saveButtons[0]); + }); + + await waitFor(() => { + expect(networking.updateMCPServer).toHaveBeenCalledTimes(1); + }); + + const [, payload] = vi.mocked(networking.updateMCPServer).mock.calls[0]; + // null signals the backend to clear the existing validation rules + expect(payload.token_validation).toBeNull(); + }); + + it("shows inline validation error and does not submit on invalid JSON in token_validation_json", async () => { + render( + , + ); + + await waitFor(() => { + expect(screen.getByText("Token Validation Rules (optional)")).toBeInTheDocument(); + }); + + const textarea = document.getElementById("token_validation_json") as HTMLTextAreaElement; + await act(async () => { + fireEvent.change(textarea, { target: { value: "{ bad json" } }); + }); + + const saveButtons = screen.getAllByRole("button", { name: "Save Changes" }); + await act(async () => { + fireEvent.click(saveButtons[0]); + }); + + // The Form.Item inline validator intercepts invalid JSON before handleSave runs, + // so the inline error message appears and updateMCPServer is never called. + await waitFor(() => { + expect(screen.getByText("Must be valid JSON")).toBeInTheDocument(); + }); + expect(networking.updateMCPServer).not.toHaveBeenCalled(); + }); + + it("includes token_storage_ttl_seconds in payload when set", async () => { + vi.mocked(networking.updateMCPServer).mockResolvedValue({ + ...interactiveOAuthServer, + token_storage_ttl_seconds: 7200, + }); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText("Token Storage TTL (seconds, optional)")).toBeInTheDocument(); + }); + + const saveButtons = screen.getAllByRole("button", { name: "Save Changes" }); + await act(async () => { + fireEvent.click(saveButtons[0]); + }); + + await waitFor(() => { + expect(networking.updateMCPServer).toHaveBeenCalledTimes(1); + }); + + const [, payload] = vi.mocked(networking.updateMCPServer).mock.calls[0]; + expect(payload.token_storage_ttl_seconds).toBe(7200); + }); +}); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx index e81d2f3960e..1a3e30cb15d 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from "react"; -import { Form, Select, Button as AntdButton, Tooltip, Input } from "antd"; +import { Form, Select, Button as AntdButton, Tooltip, Input, InputNumber } from "antd"; import { InfoCircleOutlined } from "@ant-design/icons"; import { Button, TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react"; import { AUTH_TYPE, OAUTH_FLOW, MCPServer, MCPServerCostInfo, TRANSPORT } from "./types"; @@ -190,6 +190,9 @@ const MCPServerEdit: React.FC = ({ transport: effectiveTransport, static_headers: initialStaticHeaders, oauth_flow_type: mcpServer.token_url ? OAUTH_FLOW.M2M : OAUTH_FLOW.INTERACTIVE, + token_validation_json: mcpServer.token_validation + ? JSON.stringify(mcpServer.token_validation, null, 2) + : undefined, }), [mcpServer, effectiveTransport, initialStaticHeaders, initialEnvJson], ); @@ -400,6 +403,7 @@ const MCPServerEdit: React.FC = ({ args: rawArgs, allow_all_keys: allowAllKeysRaw, available_on_public_internet: availableOnPublicInternetRaw, + token_validation_json: rawTokenValidationJson, ...restValues } = values; @@ -522,6 +526,17 @@ const MCPServerEdit: React.FC = ({ restValues.transport = "http"; } + // Parse token_validation JSON if provided + let tokenValidation: Record | null = null; + if (rawTokenValidationJson && rawTokenValidationJson.trim() !== "") { + try { + tokenValidation = JSON.parse(rawTokenValidationJson); + } catch { + NotificationsManager.fromBackend("Invalid JSON in Token Validation Rules"); + return; + } + } + // Prepare the payload with cost configuration and permission fields const mcpInfoServerName = restValues.server_name || @@ -556,6 +571,10 @@ const MCPServerEdit: React.FC = ({ static_headers: staticHeaders, allow_all_keys: Boolean(allowAllKeysRaw ?? mcpServer.allow_all_keys), available_on_public_internet: Boolean(availableOnPublicInternetRaw ?? mcpServer.available_on_public_internet), + // Include token_validation when it is set (non-null) or when clearing an existing value + ...(tokenValidation !== null || mcpServer.token_validation + ? { token_validation: tokenValidation } + : {}), }; const includeCredentials = restValues.auth_type && AUTH_TYPES_REQUIRING_CREDENTIALS.includes(restValues.auth_type); @@ -863,6 +882,58 @@ const MCPServerEdit: React.FC = ({ className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500" /> + {!isM2MFlow && ( + <> + + Token Validation Rules (optional) + + + + + } + name="token_validation_json" + rules={[ + { + validator: (_: any, value: string) => { + if (!value || value.trim() === "") return Promise.resolve(); + try { + JSON.parse(value); + return Promise.resolve(); + } catch { + return Promise.reject(new Error("Must be valid JSON")); + } + }, + }, + ]} + > + + + + Token Storage TTL (seconds, optional) + + + + + } + name="token_storage_ttl_seconds" + > + + + + )}

Use OAuth to fetch a fresh access token and temporarily save it in the session as the authentication value.