From 69a6c25a5ff0c04799b597c602e2853f011d7819 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Mon, 1 Dec 2025 12:28:53 -0800 Subject: [PATCH 01/37] Add user alias to user table --- .../src/components/view_users/columns.tsx | 6 + .../src/components/view_users/table.test.tsx | 187 ++++++------------ .../src/components/view_users/types.ts | 1 + 3 files changed, 68 insertions(+), 126 deletions(-) diff --git a/ui/litellm-dashboard/src/components/view_users/columns.tsx b/ui/litellm-dashboard/src/components/view_users/columns.tsx index 20df4fc246e..48895c69def 100644 --- a/ui/litellm-dashboard/src/components/view_users/columns.tsx +++ b/ui/litellm-dashboard/src/components/view_users/columns.tsx @@ -46,6 +46,12 @@ export const columns = ( enableSorting: true, cell: ({ row }) => {possibleUIRoles?.[row.original.user_role]?.ui_label || "-"}, }, + { + header: "User Alias", + accessorKey: "user_alias", + enableSorting: false, + cell: ({ row }) => {row.original.user_alias || "-"}, + }, { header: "Spend (USD)", accessorKey: "spend", diff --git a/ui/litellm-dashboard/src/components/view_users/table.test.tsx b/ui/litellm-dashboard/src/components/view_users/table.test.tsx index 8ef887932cc..c688d5749d0 100644 --- a/ui/litellm-dashboard/src/components/view_users/table.test.tsx +++ b/ui/litellm-dashboard/src/components/view_users/table.test.tsx @@ -1,63 +1,52 @@ import { act, fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; - import { UserDataTable } from "./table"; +const defaultFilters = { + email: "", + user_id: "", + user_role: "", + sso_user_id: "", + team: "", + model: "", + min_spend: null, + max_spend: null, + sort_by: "", + sort_order: "asc" as const, +}; + +const getDefaultProps = () => ({ + data: [] as any[], + columns: [] as any[], + accessToken: null, + userRole: "Admin", + possibleUIRoles: null as Record> | null, + filters: defaultFilters, + updateFilters: vi.fn(), + initialFilters: defaultFilters, + teams: [] as any[], + handleEdit: vi.fn(), + handleDelete: vi.fn(), + handleResetPassword: vi.fn(), + userListResponse: { users: [], total: 0, page: 1, page_size: 25, total_pages: 1 }, + currentPage: 1, + handlePageChange: vi.fn(), +}); + describe("UserDataTable", () => { it("should render the UserDataTable component", () => { - const filters = { - email: "", - user_id: "", - user_role: "", - sso_user_id: "", - team: "", - model: "", - min_spend: null, - max_spend: null, - sort_by: "", - sort_order: "asc" as const, - }; - - const updateFilters = vi.fn(); - - render( - , - ); + render(); expect(screen.getByText("Filters")).toBeInTheDocument(); }); it("should call onSortChange when clicking a sortable header", () => { const filters = { - email: "", - user_id: "", - user_role: "", - sso_user_id: "", - team: "", - model: "", - min_spend: null, - max_spend: null, + ...defaultFilters, sort_by: "created_at", sort_order: "desc" as const, }; - const updateFilters = vi.fn(); const onSortChange = vi.fn(); const possibleUIRoles = { @@ -67,21 +56,10 @@ describe("UserDataTable", () => { render( , @@ -96,41 +74,7 @@ describe("UserDataTable", () => { }); it("should show skeleton loaders when isLoading is true", () => { - const filters = { - email: "", - user_id: "", - user_role: "", - sso_user_id: "", - team: "", - model: "", - min_spend: null, - max_spend: null, - sort_by: "", - sort_order: "asc" as const, - }; - - const updateFilters = vi.fn(); - - render( - , - ); + render(); expect(screen.queryByText(/Showing/i)).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /Previous/i })).not.toBeInTheDocument(); @@ -138,44 +82,35 @@ describe("UserDataTable", () => { }); it("should show actual content when isLoading is false", () => { - const filters = { - email: "", - user_id: "", - user_role: "", - sso_user_id: "", - team: "", - model: "", - min_spend: null, - max_spend: null, - sort_by: "", - sort_order: "asc" as const, - }; - - const updateFilters = vi.fn(); - - render( - , - ); + render(); expect(screen.getByText(/Showing/i)).toBeInTheDocument(); expect(screen.getByRole("button", { name: /Previous/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /Next/i })).toBeInTheDocument(); }); + + it("should render all column headers", () => { + const possibleUIRoles = { + admin: { ui_label: "Admin" }, + user: { ui_label: "User" }, + }; + + render(); + + [ + "User ID", + "Email", + "Global Proxy Role", + "User Alias", + "Spend (USD)", + "Budget (USD)", + "SSO ID", + "API Keys", + "Created At", + "Updated At", + "Actions", + ].forEach((header) => { + expect(screen.getByRole("columnheader", { name: header })).toBeInTheDocument(); + }); + }); }); diff --git a/ui/litellm-dashboard/src/components/view_users/types.ts b/ui/litellm-dashboard/src/components/view_users/types.ts index d976d46ebc1..d674db5c7db 100644 --- a/ui/litellm-dashboard/src/components/view_users/types.ts +++ b/ui/litellm-dashboard/src/components/view_users/types.ts @@ -1,6 +1,7 @@ export interface UserInfo { user_id: string; user_email: string; + user_alias: string | null; user_role: string; spend: number; max_budget: number | null; From 21baa354cc17b6f706f3bf93c9c8ba024a72bdb0 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Mon, 1 Dec 2025 13:13:39 -0800 Subject: [PATCH 02/37] Standardize API Key vs Virtual Key in UI --- .../src/components/activity_metrics.tsx | 7 ++- .../components/bulk_create_users_button.tsx | 8 +-- .../src/components/cache_dashboard.tsx | 12 ++--- .../PassThroughSecuritySection.tsx | 16 ++---- .../src/components/dashboard_default_team.tsx | 3 +- .../src/components/entity_usage.tsx | 2 +- .../src/components/make_agent_public_form.tsx | 16 ++---- .../src/components/make_mcp_public_form.tsx | 49 ++++++++----------- .../src/components/make_model_public_form.tsx | 4 +- .../src/components/mcp_tools/mcp_connect.tsx | 10 ++-- .../src/components/new_usage.test.tsx | 2 +- .../src/components/new_usage.tsx | 2 +- .../organisms/create_key_button.tsx | 14 +++--- .../organisms/regenerate_key_modal.tsx | 10 ++-- .../components/playground/chat_ui/ChatUI.tsx | 6 +-- .../playground/compareUI/CompareUI.tsx | 6 +-- .../llm_calls/anthropic_messages.tsx | 2 +- .../playground/llm_calls/embeddings_api.tsx | 2 +- .../playground/llm_calls/responses_api.tsx | 2 +- .../KeyInfoView.handleKeyUpdate.test.tsx | 2 +- .../components/templates/key_info_view.tsx | 8 +-- .../components/templates/view_key_table.tsx | 20 ++++---- ui/litellm-dashboard/src/components/usage.tsx | 2 +- .../src/components/view_users/columns.tsx | 4 +- .../components/view_users/user_info_view.tsx | 10 ++-- 25 files changed, 99 insertions(+), 120 deletions(-) diff --git a/ui/litellm-dashboard/src/components/activity_metrics.tsx b/ui/litellm-dashboard/src/components/activity_metrics.tsx index 1878e1364d0..a791ece9bb7 100644 --- a/ui/litellm-dashboard/src/components/activity_metrics.tsx +++ b/ui/litellm-dashboard/src/components/activity_metrics.tsx @@ -38,10 +38,9 @@ const ModelSection = ({ modelName, metrics }: { modelName: string; metrics: Mode - {/* Top API Keys Section */} {metrics.top_api_keys && metrics.top_api_keys.length > 0 && ( - Top API Keys by Spend + Top Virtual Keys by Spend
{metrics.top_api_keys.map((keyData, index) => ( @@ -384,12 +383,12 @@ export const processActivityData = ( }); }); - // Process API key breakdowns for each metric (skip if key is 'api_keys' to avoid duplication) + // Process Virtual Key breakdowns for each metric (skip if key is 'api_keys' to avoid duplication) if (key !== "api_keys") { Object.entries(modelMetrics).forEach(([model, _]) => { const apiKeyBreakdown: Record = {}; - // Aggregate API key data across all days + // Aggregate Virtual Key data across all days dailyActivity.results.forEach((day) => { const modelData = day.breakdown[key]?.[model]; if (modelData && "api_key_breakdown" in modelData) { diff --git a/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx b/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx index d34b14ceabf..a8046d146a8 100644 --- a/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx +++ b/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx @@ -569,7 +569,7 @@ const BulkCreateUsersButton: React.FC = ({
  • Download our CSV template
  • Add your users' information to the spreadsheet
  • Save the file and upload it here
  • -
  • After creation, download the results file containing the API keys for each user
  • +
  • After creation, download the results file containing the Virtual Keys for each user
  • @@ -809,9 +809,9 @@ const BulkCreateUsersButton: React.FC = ({
    User creation complete - Next step: Download the credentials file containing API - keys and invitation links. Users will need these API keys to make LLM requests through - LiteLLM. + Next step: Download the credentials file containing + Virtual Keys and invitation links. Users will need these Virtual Keys to make LLM requests + through LiteLLM.
    diff --git a/ui/litellm-dashboard/src/components/cache_dashboard.tsx b/ui/litellm-dashboard/src/components/cache_dashboard.tsx index a1c0cb0a664..38c0f1a8f41 100644 --- a/ui/litellm-dashboard/src/components/cache_dashboard.tsx +++ b/ui/litellm-dashboard/src/components/cache_dashboard.tsx @@ -293,7 +293,11 @@ const CacheDashboard: React.FC = ({ accessToken, token, userRole - + {uniqueApiKeys.map((key) => ( {key} @@ -388,11 +392,7 @@ const CacheDashboard: React.FC = ({ accessToken, token, userRole /> - + diff --git a/ui/litellm-dashboard/src/components/common_components/PassThroughSecuritySection.tsx b/ui/litellm-dashboard/src/components/common_components/PassThroughSecuritySection.tsx index c42094abb55..c63770d3c85 100644 --- a/ui/litellm-dashboard/src/components/common_components/PassThroughSecuritySection.tsx +++ b/ui/litellm-dashboard/src/components/common_components/PassThroughSecuritySection.tsx @@ -21,7 +21,7 @@ const PassThroughSecuritySection: React.FC = ({ Security - When enabled, requests to this endpoint will require a valid LiteLLM API key + When enabled, requests to this endpoint will require a valid LiteLLM Virtual Key {premiumUser ? ( @@ -35,22 +35,13 @@ const PassThroughSecuritySection: React.FC = ({ ) : (
    - + Authentication (Premium)
    Setting authentication for pass-through endpoints is a LiteLLM Enterprise feature. Get a trial key{" "} - + here . @@ -63,4 +54,3 @@ const PassThroughSecuritySection: React.FC = ({ }; export default PassThroughSecuritySection; - diff --git a/ui/litellm-dashboard/src/components/dashboard_default_team.tsx b/ui/litellm-dashboard/src/components/dashboard_default_team.tsx index 36805b8912a..6506e1a60a5 100644 --- a/ui/litellm-dashboard/src/components/dashboard_default_team.tsx +++ b/ui/litellm-dashboard/src/components/dashboard_default_team.tsx @@ -69,7 +69,8 @@ const DashboardTeam: React.FC = ({ Select Team - If you belong to multiple teams, this setting controls which team is used by default when creating new API Keys. + If you belong to multiple teams, this setting controls which team is used by default when creating new Virtual + Keys. Default Team: If no team_id is set for a key, it will be grouped under here. diff --git a/ui/litellm-dashboard/src/components/entity_usage.tsx b/ui/litellm-dashboard/src/components/entity_usage.tsx index a5789b7dbac..501eac7124b 100644 --- a/ui/litellm-dashboard/src/components/entity_usage.tsx +++ b/ui/litellm-dashboard/src/components/entity_usage.tsx @@ -550,7 +550,7 @@ const EntityUsage: React.FC = ({ {/* Top API Keys */} - Top API Keys + Top Virtual Keys = ({ setLoading(true); try { const agentIdsToMakePublic = Array.from(selectedAgents); - + // Make batch API call for all agents await makeAgentsPublicCall(accessToken, agentIdsToMakePublic); @@ -127,8 +127,8 @@ const MakeAgentPublicForm: React.FC = ({
    - Select the agents you want to be visible on the public model hub. Users will still require a valid API key to - use these agents. + Select the agents you want to be visible on the public model hub. Users will still require a valid Virtual Key + to use these agents.
    @@ -141,10 +141,7 @@ const MakeAgentPublicForm: React.FC = ({ agentHubData.map((agent) => { const agentId = agent.agent_id || agent.name; return ( -
    +
    handleAgentSelection(agentId, e.target.checked)} @@ -217,9 +214,7 @@ const MakeAgentPublicForm: React.FC = ({ )}
    - {agent?.description && ( - {agent.description} - )} + {agent?.description && {agent.description}}
    ); @@ -296,4 +291,3 @@ const MakeAgentPublicForm: React.FC = ({ }; export default MakeAgentPublicForm; - diff --git a/ui/litellm-dashboard/src/components/make_mcp_public_form.tsx b/ui/litellm-dashboard/src/components/make_mcp_public_form.tsx index 29f866f8bc6..f7bba175800 100644 --- a/ui/litellm-dashboard/src/components/make_mcp_public_form.tsx +++ b/ui/litellm-dashboard/src/components/make_mcp_public_form.tsx @@ -76,7 +76,7 @@ const MakeMCPPublicForm: React.FC = ({ const publicServerIds = mcpHubData .filter((server) => server.mcp_info?.is_public === true) .map((server) => server.server_id); - + // Preselect servers that are already public setSelectedServers(new Set(publicServerIds)); } @@ -91,7 +91,7 @@ const MakeMCPPublicForm: React.FC = ({ setLoading(true); try { const serverIdsToMakePublic = Array.from(selectedServers); - + // Make batch API call for all servers await makeMCPPublicCall(accessToken, serverIdsToMakePublic); @@ -128,8 +128,8 @@ const MakeMCPPublicForm: React.FC = ({
    - Select the MCP servers you want to be visible on the public model hub. Users will still require a valid API key to - use these servers. + Select the MCP servers you want to be visible on the public model hub. Users will still require a valid + Virtual Key to use these servers.
    @@ -161,22 +161,20 @@ const MakeMCPPublicForm: React.FC = ({ {server.transport} - {server.status || "unknown"}
    - - {server.description || server.url} - + {server.description || server.url} {server.allowed_tools && server.allowed_tools.length > 0 && (
    {server.allowed_tools.slice(0, 3).map((tool, idx) => ( @@ -236,14 +234,14 @@ const MakeMCPPublicForm: React.FC = ({ {server.transport} - {server.status || "unknown"} @@ -251,12 +249,8 @@ const MakeMCPPublicForm: React.FC = ({ )}
    - {server?.description && ( - {server.description} - )} - {server?.url && ( - {server.url} - )} + {server?.description && {server.description}} + {server?.url && {server.url}}
    ); @@ -267,8 +261,8 @@ const MakeMCPPublicForm: React.FC = ({
    - Total: {selectedServers.size} MCP server{selectedServers.size !== 1 ? "s" : ""} will be made - public + Total: {selectedServers.size} MCP server{selectedServers.size !== 1 ? "s" : ""} will be + made public
    @@ -333,4 +327,3 @@ const MakeMCPPublicForm: React.FC = ({ }; export default MakeMCPPublicForm; - diff --git a/ui/litellm-dashboard/src/components/make_model_public_form.tsx b/ui/litellm-dashboard/src/components/make_model_public_form.tsx index e67d60fb33b..750bdc24eeb 100644 --- a/ui/litellm-dashboard/src/components/make_model_public_form.tsx +++ b/ui/litellm-dashboard/src/components/make_model_public_form.tsx @@ -152,8 +152,8 @@ const MakeModelPublicForm: React.FC = ({ - Select the models you want to be visible on the public model hub. Users will still require a valid API key to - use these models. + Select the models you want to be visible on the public model hub. Users will still require a valid Virtual Key + to use these models. {/* Filters */} diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_connect.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_connect.tsx index 4b4f1ab676b..5a012c1fc5c 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_connect.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_connect.tsx @@ -220,12 +220,12 @@ const MCPConnect: React.FC = ({ currentServerAccessGroups = [] } - title="API Key Setup" - description="Configure your LiteLLM Proxy API key for authentication" + title="Virtual Key Setup" + description="Configure your LiteLLM Proxy Virtual Key for authentication" >
    - Get your API key from your LiteLLM Proxy dashboard or contact your administrator + Get your Virtual Key from your LiteLLM Proxy dashboard or contact your administrator
    @@ -249,7 +249,7 @@ const MCPConnect: React.FC = ({ currentServerAccessGroups = [] = ({ currentServerAccessGroups = [] "server_url": "${proxyBaseUrl}/mcp", "require_approval": "never", "headers": { - "x-litellm-api-key": "Bearer YOUR_LITELLM_API_KEY", + "x-litellm-api-key": "Bearer YOUR_LITELLM_VIRTUAL_KEY", "x-mcp-servers": ["Zapier_MCP,dev"] } } diff --git a/ui/litellm-dashboard/src/components/new_usage.test.tsx b/ui/litellm-dashboard/src/components/new_usage.test.tsx index a4969124f1f..a06045137d7 100644 --- a/ui/litellm-dashboard/src/components/new_usage.test.tsx +++ b/ui/litellm-dashboard/src/components/new_usage.test.tsx @@ -239,7 +239,7 @@ describe("NewUsage", () => { // Check for chart titles expect(screen.getByText("Daily Spend")).toBeInTheDocument(); - expect(screen.getByText("Top API Keys")).toBeInTheDocument(); + expect(screen.getByText("Top Virtual Keys")).toBeInTheDocument(); }); it("should switch between tabs correctly", async () => { diff --git a/ui/litellm-dashboard/src/components/new_usage.tsx b/ui/litellm-dashboard/src/components/new_usage.tsx index 4794a7f091d..a8d30885493 100644 --- a/ui/litellm-dashboard/src/components/new_usage.tsx +++ b/ui/litellm-dashboard/src/components/new_usage.tsx @@ -580,7 +580,7 @@ const NewUsagePage: React.FC = ({ {/* Top API Keys */} - Top API Keys + Top Virtual Keys = ({ setApiKey(response["key"]); setSoftBudget(response["soft_budget"]); - NotificationsManager.success("API Key Created"); + NotificationsManager.success("Virtual Key Created"); form.resetFields(); localStorage.removeItem("userData" + userID); } catch (error) { @@ -415,7 +415,7 @@ const CreateKey: React.FC = ({ }; const handleCopy = () => { - NotificationsManager.success("API Key copied to clipboard"); + NotificationsManager.success("Virtual Key copied to clipboard"); }; useEffect(() => { @@ -505,7 +505,7 @@ const CreateKey: React.FC = ({ label={ Owned By{" "} - + @@ -594,8 +594,8 @@ const CreateKey: React.FC = ({ {isFormDisabled && (
    - Please select a team to continue configuring your API key. If you do not see any teams, please contact - your Proxy Admin to either provide you with access to models or to add you to a team. + Please select a team to continue configuring your Virtual Key. If you do not see any teams, please + contact your Proxy Admin to either provide you with access to models or to add you to a team.
    )} @@ -1277,7 +1277,7 @@ const CreateKey: React.FC = ({ {apiKey != null ? (
    - API Key: + Virtual Key:
    = ({
    - + {/*
    - New API Key: + New Virtual Key:
    {regeneratedKey}
    NotificationManager.success("API Key copied to clipboard")} + onCopy={() => NotificationManager.success("Virtual Key copied to clipboard")} > - + 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 90c43df0dc4..c2924c048c3 100644 --- a/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx +++ b/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx @@ -691,7 +691,7 @@ const ChatUI: React.FC = ({ const effectiveApiKey = apiKeySource === "session" ? accessToken : apiKey; if (!effectiveApiKey) { - NotificationsManager.fromBackend("Please provide an API key or select Current UI Session"); + NotificationsManager.fromBackend("Please provide a Virtual Key or select Current UI Session"); return; } @@ -1003,7 +1003,7 @@ const ChatUI: React.FC = ({
    - API Key Source + Virtual Key Source setApiKeySource(value as "session" | "custom")} @@ -567,7 +567,7 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }: setCustomApiKey(event.target.value)} - placeholder="Enter API key" + placeholder="Enter Virtual Key" className="w-56" /> )} diff --git a/ui/litellm-dashboard/src/components/playground/llm_calls/anthropic_messages.tsx b/ui/litellm-dashboard/src/components/playground/llm_calls/anthropic_messages.tsx index 47941bce2ca..3f8c90424c6 100644 --- a/ui/litellm-dashboard/src/components/playground/llm_calls/anthropic_messages.tsx +++ b/ui/litellm-dashboard/src/components/playground/llm_calls/anthropic_messages.tsx @@ -20,7 +20,7 @@ export async function makeAnthropicMessagesRequest( selectedMCPTools?: string[], ) { if (!accessToken) { - throw new Error("API key is required"); + throw new Error("Virtual Key is required"); } const isLocal = process.env.NODE_ENV === "development"; diff --git a/ui/litellm-dashboard/src/components/playground/llm_calls/embeddings_api.tsx b/ui/litellm-dashboard/src/components/playground/llm_calls/embeddings_api.tsx index 832d29bb852..d0939c00437 100644 --- a/ui/litellm-dashboard/src/components/playground/llm_calls/embeddings_api.tsx +++ b/ui/litellm-dashboard/src/components/playground/llm_calls/embeddings_api.tsx @@ -9,7 +9,7 @@ export async function makeOpenAIEmbeddingsRequest( tags?: string[], ) { if (!accessToken) { - throw new Error("API key is required"); + throw new Error("Virtual Key is required"); } // Base URL should be the current base_url diff --git a/ui/litellm-dashboard/src/components/playground/llm_calls/responses_api.tsx b/ui/litellm-dashboard/src/components/playground/llm_calls/responses_api.tsx index 8461f8e20a0..46b0621a0b1 100644 --- a/ui/litellm-dashboard/src/components/playground/llm_calls/responses_api.tsx +++ b/ui/litellm-dashboard/src/components/playground/llm_calls/responses_api.tsx @@ -24,7 +24,7 @@ export async function makeOpenAIResponsesRequest( onMCPEvent?: (event: MCPEvent) => void, ) { if (!accessToken) { - throw new Error("API key is required"); + throw new Error("Virtual Key is required"); } // Base URL should be the current base_url 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 f58e392a58f..9897bb4d47a 100644 --- a/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx @@ -245,7 +245,7 @@ import KeyInfoView from "./key_info_view"; const baseKeyData = { token_id: "tok_123", token: "tok_123", - key_alias: "My API Key", + key_alias: "My Virtual Key", key_name: "sk-xxxx", created_at: new Date().toISOString(), updated_at: new Date().toISOString(), 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 ec2b294d9de..dbbb195a1f8 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -297,7 +297,7 @@ export default function KeyInfoView({ - {currentKeyData.key_alias || "API Key"} + {currentKeyData.key_alias || "Virtual Key"}
    @@ -381,7 +381,7 @@ export default function KeyInfoView({ {/* Delete Confirmation Modal */} {isDeleteModalOpen && (() => { - const keyName = currentKeyData?.key_alias || currentKeyData?.token_id || "API Key"; + const keyName = currentKeyData?.key_alias || currentKeyData?.token_id || "Virtual Key"; const isValid = deleteConfirmInput === keyName; return (
    @@ -415,7 +415,7 @@ export default function KeyInfoView({

    - Warning: You are about to delete this API key. + Warning: You are about to delete this Virtual Key.

    This action is irreversible and will immediately revoke access for any applications using this @@ -423,7 +423,7 @@ export default function KeyInfoView({

    -

    Are you sure you want to delete this API key?

    +

    Are you sure you want to delete this Virtual Key?

    - Warning: You are about to delete this API key. + Warning: You are about to delete this Virtual Key.

    This action is irreversible and will immediately revoke access for any applications using this @@ -374,7 +374,7 @@ const ViewKeyTable: React.FC = ({

    -

    Are you sure you want to delete this API key?

    +

    Are you sure you want to delete this Virtual Key?

    @@ -417,7 +417,7 @@ const ViewKeyTable: React.FC = ({ {/* Regenerate Key Form Modal */} { setRegenerateDialogVisible(false); @@ -516,7 +516,7 @@ const ViewKeyTable: React.FC = ({ {selectedToken?.key_alias || "No alias set"}
    - New API Key: + New Virtual Key:
    = ({
    NotificationManager.success({ description: "API Key copied to clipboard" })} + onCopy={() => NotificationManager.success({ description: "Virtual Key copied to clipboard" })} > - + diff --git a/ui/litellm-dashboard/src/components/usage.tsx b/ui/litellm-dashboard/src/components/usage.tsx index 88b1e3c3fb7..0900a0a9cc1 100644 --- a/ui/litellm-dashboard/src/components/usage.tsx +++ b/ui/litellm-dashboard/src/components/usage.tsx @@ -615,7 +615,7 @@ const UsagePage: React.FC = ({ accessToken, token, userRole, use - Top API Keys + Top Virtual Keys ( {row.original.key_count > 0 ? ( - {row.original.key_count} Keys + {row.original.key_count} {row.original.key_count === 1 ? "Key" : "Keys"} ) : ( diff --git a/ui/litellm-dashboard/src/components/view_users/user_info_view.tsx b/ui/litellm-dashboard/src/components/view_users/user_info_view.tsx index 456f07d1882..2caae7d861f 100644 --- a/ui/litellm-dashboard/src/components/view_users/user_info_view.tsx +++ b/ui/litellm-dashboard/src/components/view_users/user_info_view.tsx @@ -320,9 +320,11 @@ export default function UserInfoView({ - API Keys + Virtual Keys
    - {userData.keys?.length || 0} keys + + {userData.keys?.length || 0} {userData.keys?.length === 1 ? "Key" : "Keys"} +
    @@ -467,7 +469,7 @@ export default function UserInfoView({
    - API Keys + Virtual Keys
    {userData.keys?.length && userData.keys?.length > 0 ? ( userData.keys.map((key, index) => ( @@ -476,7 +478,7 @@ export default function UserInfoView({ )) ) : ( - No API keys + No Virtual Keys )}
    From 831ad45c4df36eeb401d718157ae391151e693ab Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 2 Dec 2025 18:18:08 +0530 Subject: [PATCH 03/37] Add ragflow support --- docs/my-website/docs/providers/ragflow.md | 244 ++++++++++++ docs/my-website/sidebars.js | 1 + litellm/__init__.py | 1 + litellm/constants.py | 1 + .../get_llm_provider_logic.py | 10 + litellm/llms/ragflow/__init__.py | 8 + litellm/llms/ragflow/chat/__init__.py | 4 + litellm/llms/ragflow/chat/transformation.py | 264 ++++++++++++ litellm/main.py | 30 ++ ...odel_prices_and_context_window_backup.json | 360 +++++++++++++++-- litellm/types/utils.py | 1 + litellm/utils.py | 2 + .../llms/ragflow/chat/__init__.py | 4 + .../chat/test_ragflow_chat_transformation.py | 376 ++++++++++++++++++ 14 files changed, 1283 insertions(+), 23 deletions(-) create mode 100644 docs/my-website/docs/providers/ragflow.md create mode 100644 litellm/llms/ragflow/__init__.py create mode 100644 litellm/llms/ragflow/chat/__init__.py create mode 100644 litellm/llms/ragflow/chat/transformation.py create mode 100644 tests/test_litellm/llms/ragflow/chat/__init__.py create mode 100644 tests/test_litellm/llms/ragflow/chat/test_ragflow_chat_transformation.py diff --git a/docs/my-website/docs/providers/ragflow.md b/docs/my-website/docs/providers/ragflow.md new file mode 100644 index 00000000000..73223bd07b5 --- /dev/null +++ b/docs/my-website/docs/providers/ragflow.md @@ -0,0 +1,244 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# RAGFlow + +Litellm supports Ragflow's chat completions APIs + +## Supported Features + +- ✅ Chat completions +- ✅ Streaming responses +- ✅ Both chat and agent endpoints +- ✅ Multiple credential sources (params, env vars, litellm_params) +- ✅ OpenAI-compatible API format + + +## API Key + +```python +# env variable +os.environ['RAGFLOW_API_KEY'] +``` + +## API Base + +```python +# env variable +os.environ['RAGFLOW_API_BASE'] +``` + +## Overview + +RAGFlow provides OpenAI-compatible APIs with unique path structures that include chat and agent IDs: + +- **Chat endpoint**: `/api/v1/chats_openai/{chat_id}/chat/completions` +- **Agent endpoint**: `/api/v1/agents_openai/{agent_id}/chat/completions` + +The model name format embeds the endpoint type and ID: +- Chat: `ragflow/chat/{chat_id}/{model_name}` +- Agent: `ragflow/agent/{agent_id}/{model_name}` + + +## Sample Usage - Chat Endpoint + +```python +from litellm import completion +import os + +os.environ['RAGFLOW_API_KEY'] = "your-ragflow-api-key" +os.environ['RAGFLOW_API_BASE'] = "http://localhost:9380" # or your hosted URL + +response = completion( + model="ragflow/chat/my-chat-id/gpt-4o-mini", + messages=[{"role": "user", "content": "How does the deep doc understanding work?"}] +) +print(response) +``` + +## Sample Usage - Agent Endpoint + +```python +from litellm import completion +import os + +os.environ['RAGFLOW_API_KEY'] = "your-ragflow-api-key" +os.environ['RAGFLOW_API_BASE'] = "http://localhost:9380" # or your hosted URL + +response = completion( + model="ragflow/agent/my-agent-id/gpt-4o-mini", + messages=[{"role": "user", "content": "What are the key features?"}] +) +print(response) +``` + +## Sample Usage - With Parameters + +You can also pass `api_key` and `api_base` directly as parameters: + +```python +from litellm import completion + +response = completion( + model="ragflow/chat/my-chat-id/gpt-4o-mini", + messages=[{"role": "user", "content": "Hello!"}], + api_key="your-ragflow-api-key", + api_base="http://localhost:9380" +) +print(response) +``` + +## Sample Usage - Streaming + +```python +from litellm import completion +import os + +os.environ['RAGFLOW_API_KEY'] = "your-ragflow-api-key" +os.environ['RAGFLOW_API_BASE'] = "http://localhost:9380" + +response = completion( + model="ragflow/agent/my-agent-id/gpt-4o-mini", + messages=[{"role": "user", "content": "Explain RAGFlow"}], + stream=True +) + +for chunk in response: + print(chunk) +``` + +## Model Name Format + +The model name must follow one of these formats: + +### Chat Endpoint +``` +ragflow/chat/{chat_id}/{model_name} +``` + +Example: `ragflow/chat/my-chat-id/gpt-4o-mini` + +### Agent Endpoint +``` +ragflow/agent/{agent_id}/{model_name} +``` + +Example: `ragflow/agent/my-agent-id/gpt-4o-mini` + +Where: +- `{chat_id}` or `{agent_id}` is the ID of your chat or agent in RAGFlow +- `{model_name}` is the actual model name (e.g., `gpt-4o-mini`, `gpt-4o`, etc.) + +## Configuration Sources + +LiteLLM supports multiple ways to provide credentials, checked in this order: + +1. **Function parameters**: `api_key="..."`, `api_base="..."` +2. **litellm_params**: `litellm_params={"api_key": "...", "api_base": "..."}` +3. **Environment variables**: `RAGFLOW_API_KEY`, `RAGFLOW_API_BASE` +4. **Global litellm settings**: `litellm.api_key`, `litellm.api_base` + +## Usage - LiteLLM Proxy Server + +### 1. Save key in your environment + +```bash +export RAGFLOW_API_KEY="your-ragflow-api-key" +export RAGFLOW_API_BASE="http://localhost:9380" +``` + +### 2. Start the proxy + + + + +```yaml +model_list: + - model_name: ragflow-chat-gpt4 + litellm_params: + model: ragflow/chat/my-chat-id/gpt-4o-mini + api_key: os.environ/RAGFLOW_API_KEY + api_base: os.environ/RAGFLOW_API_BASE + - model_name: ragflow-agent-gpt4 + litellm_params: + model: ragflow/agent/my-agent-id/gpt-4o-mini + api_key: os.environ/RAGFLOW_API_KEY + api_base: os.environ/RAGFLOW_API_BASE +``` + + + + +```bash +$ litellm --config /path/to/config.yaml + +# Server running on http://0.0.0.0:4000 +``` + + + + +### 3. Test it + + + + +```bash +curl http://0.0.0.0:4000/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer sk-1234" \ + -d '{ + "model": "ragflow-chat-gpt4", + "messages": [ + {"role": "user", "content": "How does RAGFlow work?"} + ] + }' +``` + + + + +```python +from openai import OpenAI + +client = OpenAI( + api_key="sk-1234", # Your LiteLLM proxy key + base_url="http://0.0.0.0:4000" +) + +response = client.chat.completions.create( + model="ragflow-chat-gpt4", + messages=[ + {"role": "user", "content": "How does RAGFlow work?"} + ] +) +print(response) +``` + + + + +## API Base URL Handling + +The `api_base` parameter can be provided with or without `/v1` suffix. LiteLLM will automatically handle it: + +- `http://localhost:9380` → `http://localhost:9380/api/v1/chats_openai/{chat_id}/chat/completions` +- `http://localhost:9380/v1` → `http://localhost:9380/api/v1/chats_openai/{chat_id}/chat/completions` +- `http://localhost:9380/api/v1` → `http://localhost:9380/api/v1/chats_openai/{chat_id}/chat/completions` + +All three formats will work correctly. + +## Error Handling + +If you encounter errors: + +1. **Invalid model format**: Ensure your model name follows `ragflow/{chat|agent}/{id}/{model_name}` format +2. **Missing api_base**: Provide `api_base` via parameter, environment variable, or litellm_params +3. **Connection errors**: Verify your RAGFlow server is running and accessible at the provided `api_base` + +:::info + +For more information about passing provider-specific parameters, [go here](../completion/provider_specific_params.md) + +::: + diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index e467711b59d..55638512ef2 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -625,6 +625,7 @@ const sidebars = { "providers/petals", "providers/publicai", "providers/predibase", + "providers/ragflow", "providers/recraft", "providers/replicate", { diff --git a/litellm/__init__.py b/litellm/__init__.py index 007eff892c8..595a0132099 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -1387,6 +1387,7 @@ from .llms.docker_model_runner.chat.transformation import DockerModelRunnerChatC from .llms.v0.chat.transformation import V0ChatConfig from .llms.oci.chat.transformation import OCIChatConfig from .llms.morph.chat.transformation import MorphChatConfig +from .llms.ragflow.chat.transformation import RAGFlowConfig from .llms.lambda_ai.chat.transformation import LambdaAIChatConfig from .llms.hyperbolic.chat.transformation import HyperbolicChatConfig from .llms.vercel_ai_gateway.chat.transformation import VercelAIGatewayConfig diff --git a/litellm/constants.py b/litellm/constants.py index e3de7368c8a..e252c86777c 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -586,6 +586,7 @@ openai_compatible_providers: List = [ "cometapi", "clarifai", "docker_model_runner", + "ragflow", ] openai_text_completion_compatible_providers: List = ( [ # providers that support `/v1/completions` diff --git a/litellm/litellm_core_utils/get_llm_provider_logic.py b/litellm/litellm_core_utils/get_llm_provider_logic.py index b10011befcd..a90d16dba49 100644 --- a/litellm/litellm_core_utils/get_llm_provider_logic.py +++ b/litellm/litellm_core_utils/get_llm_provider_logic.py @@ -840,6 +840,16 @@ def _get_openai_compatible_provider_info( # noqa: PLR0915 ) = litellm.ClarifaiConfig()._get_openai_compatible_provider_info( api_base, api_key ) + elif custom_llm_provider == "ragflow": + full_model = f"ragflow/{model}" + ( + api_base, + dynamic_api_key, + _, + ) = litellm.RAGFlowConfig()._get_openai_compatible_provider_info( + full_model, api_base, api_key, "ragflow" + ) + model = full_model if api_base is not None and not isinstance(api_base, str): raise Exception("api base needs to be a string. api_base={}".format(api_base)) diff --git a/litellm/llms/ragflow/__init__.py b/litellm/llms/ragflow/__init__.py new file mode 100644 index 00000000000..17d12bed31c --- /dev/null +++ b/litellm/llms/ragflow/__init__.py @@ -0,0 +1,8 @@ +""" +RAGFlow provider for LiteLLM. + +RAGFlow provides OpenAI-compatible APIs with unique path structures: +- Chat endpoint: /api/v1/chats_openai/{chat_id}/chat/completions +- Agent endpoint: /api/v1/agents_openai/{agent_id}/chat/completions +""" + diff --git a/litellm/llms/ragflow/chat/__init__.py b/litellm/llms/ragflow/chat/__init__.py new file mode 100644 index 00000000000..0e0f47d07b6 --- /dev/null +++ b/litellm/llms/ragflow/chat/__init__.py @@ -0,0 +1,4 @@ +""" +RAGFlow chat completion configuration. +""" + diff --git a/litellm/llms/ragflow/chat/transformation.py b/litellm/llms/ragflow/chat/transformation.py new file mode 100644 index 00000000000..d33a1593be8 --- /dev/null +++ b/litellm/llms/ragflow/chat/transformation.py @@ -0,0 +1,264 @@ +""" +RAGFlow provider configuration for OpenAI-compatible API. + +RAGFlow provides OpenAI-compatible APIs with unique path structures: +- Chat endpoint: /api/v1/chats_openai/{chat_id}/chat/completions +- Agent endpoint: /api/v1/agents_openai/{agent_id}/chat/completions + +Model name format: +- Chat: ragflow/chat/{chat_id}/{model_name} +- Agent: ragflow/agent/{agent_id}/{model_name} +""" + +from typing import Any, List, Optional, Tuple + +import litellm +from litellm.llms.openai.openai import OpenAIConfig +from litellm.secret_managers.main import get_secret, get_secret_str +from litellm.types.llms.openai import AllMessageValues + + +class RAGFlowConfig(OpenAIConfig): + """ + Configuration for RAGFlow OpenAI-compatible API. + + Handles both chat and agent endpoints by parsing the model name format: + - ragflow/chat/{chat_id}/{model_name} for chat endpoints + - ragflow/agent/{agent_id}/{model_name} for agent endpoints + """ + + def _parse_ragflow_model(self, model: str) -> Tuple[str, str, str]: + """ + Parse RAGFlow model name format: ragflow/{endpoint_type}/{id}/{model_name} + + Args: + model: Model name in format ragflow/chat/{chat_id}/{model} or ragflow/agent/{agent_id}/{model} + + Returns: + Tuple of (endpoint_type, id, model_name) + + Raises: + ValueError: If model format is invalid + """ + parts = model.split("/") + if len(parts) < 4: + raise ValueError( + f"Invalid RAGFlow model format: {model}. " + f"Expected format: ragflow/chat/{{chat_id}}/{{model}} or ragflow/agent/{{agent_id}}/{{model}}" + ) + + if parts[0] != "ragflow": + raise ValueError( + f"Invalid RAGFlow model format: {model}. Must start with 'ragflow/'" + ) + + endpoint_type = parts[1] + if endpoint_type not in ["chat", "agent"]: + raise ValueError( + f"Invalid RAGFlow endpoint type: {endpoint_type}. Must be 'chat' or 'agent'" + ) + + entity_id = parts[2] + model_name = "/".join(parts[3:]) # Handle model names that might contain slashes + + return endpoint_type, entity_id, model_name + + def get_complete_url( + self, + api_base: Optional[str], + api_key: Optional[str], + model: str, + optional_params: dict, + litellm_params: dict, + stream: Optional[bool] = None, + ) -> str: + """ + Get the complete URL for the RAGFlow API call. + + Constructs URL based on endpoint type: + - Chat: /api/v1/chats_openai/{chat_id}/chat/completions + - Agent: /api/v1/agents_openai/{agent_id}/chat/completions + + Args: + api_base: Base API URL (e.g., http://ragflow-server:port or http://ragflow-server:port/v1) + api_key: API key (not used in URL construction) + model: Model name in format ragflow/{endpoint_type}/{id}/{model} + optional_params: Optional parameters + litellm_params: LiteLLM parameters (may contain api_base) + stream: Whether streaming is enabled + + Returns: + Complete URL for the API call + """ + # Get api_base from multiple sources: input param, litellm_params, environment, or global litellm setting + if litellm_params and hasattr(litellm_params, 'api_base') and litellm_params.api_base: + api_base = api_base or litellm_params.api_base + + api_base = ( + api_base + or litellm.api_base + or get_secret("RAGFLOW_API_BASE") + or get_secret_str("RAGFLOW_API_BASE") + ) + + if api_base is None: + raise ValueError("api_base is required for RAGFlow provider. Set it via api_base parameter, RAGFLOW_API_BASE environment variable, or litellm.api_base") + + # Parse model name to extract endpoint type and ID + endpoint_type, entity_id, _ = self._parse_ragflow_model(model) + + # Remove trailing slash from api_base if present + api_base = api_base.rstrip("/") + + # Strip /v1 or /api/v1 from api_base if present, since we'll add the full path + # Check /api/v1 first because /api/v1 ends with /v1 + if api_base.endswith("/api/v1"): + api_base = api_base[:-7] # Remove /api/v1 + elif api_base.endswith("/v1"): + api_base = api_base[:-3] # Remove /v1 + + # Construct the RAGFlow-specific path + if endpoint_type == "chat": + path = f"/api/v1/chats_openai/{entity_id}/chat/completions" + else: # agent + path = f"/api/v1/agents_openai/{entity_id}/chat/completions" + + # Ensure path starts with / + if not path.startswith("/"): + path = "/" + path + + return f"{api_base}{path}" + + def _get_openai_compatible_provider_info( + self, + model: str, + api_base: Optional[str], + api_key: Optional[str], + custom_llm_provider: str, + ) -> Tuple[Optional[str], Optional[str], str]: + """ + Get OpenAI-compatible provider information for RAGFlow. + + Args: + model: Model name (will be parsed to extract actual model name) + api_base: Base API URL (from input params) + api_key: API key (from input params) + custom_llm_provider: Custom LLM provider name + + Returns: + Tuple of (api_base, api_key, custom_llm_provider) + """ + # Parse model to extract the actual model name + # The model name will be stored in litellm_params for use in requests + _, _, actual_model = self._parse_ragflow_model(model) + + # Get api_base from multiple sources: input param, environment, or global litellm setting + dynamic_api_base = ( + api_base + or litellm.api_base + or get_secret("RAGFLOW_API_BASE") + or get_secret_str("RAGFLOW_API_BASE") + ) + + # Get api_key from multiple sources: input param, environment, or global litellm setting + dynamic_api_key = ( + api_key + or litellm.api_key + or get_secret_str("RAGFLOW_API_KEY") + ) + + return dynamic_api_base, dynamic_api_key, custom_llm_provider + + def validate_environment( + self, + headers: dict, + model: str, + messages: List[AllMessageValues], + optional_params: dict, + litellm_params: dict, + api_key: Optional[str] = None, + api_base: Optional[str] = None, + ) -> dict: + """ + Validate environment and set up headers for RAGFlow API. + + Args: + headers: Request headers + model: Model name + messages: Chat messages + optional_params: Optional parameters + litellm_params: LiteLLM parameters (may contain api_key) + api_key: API key (from input params) + api_base: Base API URL + + Returns: + Updated headers dictionary + """ + # Use api_key from litellm_params if available, otherwise fall back to other sources + if litellm_params and hasattr(litellm_params, 'api_key') and litellm_params.api_key: + api_key = api_key or litellm_params.api_key + + # Get api_key from multiple sources: input param, litellm_params, environment, or global litellm setting + api_key = ( + api_key + or litellm.api_key + or get_secret_str("RAGFLOW_API_KEY") + ) + + if api_key is not None: + headers["Authorization"] = f"Bearer {api_key}" + + # Ensure Content-Type is set to application/json + if "content-type" not in headers and "Content-Type" not in headers: + headers["Content-Type"] = "application/json" + + # Parse model to extract actual model name and store it + # The actual model name should be used in the request body + try: + _, _, actual_model = self._parse_ragflow_model(model) + # Store the actual model name in litellm_params for use in transform_request + litellm_params["_ragflow_actual_model"] = actual_model + except ValueError: + # If parsing fails, use the original model name + pass + + return headers + + def transform_request( + self, + model: str, + messages: List[AllMessageValues], + optional_params: dict, + litellm_params: dict, + headers: dict, + ) -> dict: + """ + Transform request for RAGFlow API. + + Uses the actual model name extracted from the RAGFlow model format. + + Args: + model: Model name in RAGFlow format + messages: Chat messages + optional_params: Optional parameters + litellm_params: LiteLLM parameters (may contain _ragflow_actual_model) + headers: Request headers + + Returns: + Transformed request dictionary + """ + # Get the actual model name from litellm_params if available + actual_model = litellm_params.get("_ragflow_actual_model") + if actual_model is None: + # Fallback: try to parse the model name + try: + _, _, actual_model = self._parse_ragflow_model(model) + except ValueError: + # If parsing fails, use the original model name + actual_model = model + + # Use parent's transform_request with the actual model name + return super().transform_request( + actual_model, messages, optional_params, litellm_params, headers + ) + diff --git a/litellm/main.py b/litellm/main.py index a09a9453017..fba2fc9b9a6 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -1989,6 +1989,36 @@ def completion( # type: ignore # noqa: PLR0915 ) raise e + elif custom_llm_provider == "ragflow": + ## COMPLETION CALL - RAGFlow uses HTTP handler to support custom URL paths + try: + response = base_llm_http_handler.completion( + model=model, + messages=messages, + headers=headers, + model_response=model_response, + api_key=api_key, + api_base=api_base, + acompletion=acompletion, + logging_obj=logging, + optional_params=optional_params, + litellm_params=litellm_params, + shared_session=shared_session, + timeout=timeout, + client=client, + custom_llm_provider=custom_llm_provider, + encoding=encoding, + stream=stream, + provider_config=provider_config, + ) + except Exception as e: + logging.post_call( + input=messages, + api_key=api_key, + original_response=str(e), + additional_args={"headers": headers}, + ) + raise e elif custom_llm_provider == "xai": ## COMPLETION CALL try: diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 9fdc1704f41..f28e9b1290f 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -6717,6 +6717,33 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 159 }, + "claude-opus-4-5": { + "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_1hr": 1e-05, + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 5e-06, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 2.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "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, + "tool_use_system_prompt_tokens": 159 + }, "claude-sonnet-4-20250514": { "deprecation_date": "2026-05-14", "cache_creation_input_token_cost": 3.75e-06, @@ -7824,26 +7851,298 @@ "source": "https://www.databricks.com/product/pricing/foundation-model-serving" }, "databricks/databricks-claude-3-7-sonnet": { - "input_cost_per_token": 2.5e-06, - "input_dbu_cost_per_token": 3.571e-05, + "input_cost_per_token": 2.9999900000000002e-06, + "input_dbu_cost_per_token": 4.2857e-05, "litellm_provider": "databricks", "max_input_tokens": 200000, "max_output_tokens": 128000, "max_tokens": 200000, "metadata": { - "notes": "Input/output cost per token is dbu cost * $0.070, based on databricks Claude 3.7 conversion. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." }, "mode": "chat", - "output_cost_per_token": 1.7857e-05, - "output_db_cost_per_token": 0.000214286, - "source": "https://www.databricks.com/product/pricing/foundation-model-serving", + "output_cost_per_token": 1.5000020000000002e-05, + "output_dbu_cost_per_token": 0.000214286, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", "supports_assistant_prefill": true, "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true }, + "databricks/databricks-claude-haiku-4-5": { + "input_cost_per_token": 1.00002e-06, + "input_dbu_cost_per_token": 1.4286e-05, + "litellm_provider": "databricks", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 200000, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 5.00003e-06, + "output_dbu_cost_per_token": 7.1429e-05, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "databricks/databricks-claude-opus-4": { + "input_cost_per_token": 1.5000020000000002e-05, + "input_dbu_cost_per_token": 0.000214286, + "litellm_provider": "databricks", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 200000, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 7.500003000000001e-05, + "output_dbu_cost_per_token": 0.001071429, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "databricks/databricks-claude-opus-4-1": { + "input_cost_per_token": 1.5000020000000002e-05, + "input_dbu_cost_per_token": 0.000214286, + "litellm_provider": "databricks", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 200000, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 7.500003000000001e-05, + "output_dbu_cost_per_token": 0.001071429, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "databricks/databricks-claude-opus-4-5": { + "input_cost_per_token": 5.00003e-06, + "input_dbu_cost_per_token": 7.1429e-05, + "litellm_provider": "databricks", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 200000, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 2.5000010000000002e-05, + "output_dbu_cost_per_token": 0.000357143, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "databricks/databricks-claude-sonnet-4": { + "input_cost_per_token": 2.9999900000000002e-06, + "input_dbu_cost_per_token": 4.2857e-05, + "litellm_provider": "databricks", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 200000, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 1.5000020000000002e-05, + "output_dbu_cost_per_token": 0.000214286, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "databricks/databricks-claude-sonnet-4-1": { + "input_cost_per_token": 2.9999900000000002e-06, + "input_dbu_cost_per_token": 4.2857e-05, + "litellm_provider": "databricks", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 200000, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 1.5000020000000002e-05, + "output_dbu_cost_per_token": 0.000214286, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "databricks/databricks-claude-sonnet-4-5": { + "input_cost_per_token": 2.9999900000000002e-06, + "input_dbu_cost_per_token": 4.2857e-05, + "litellm_provider": "databricks", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 200000, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 1.5000020000000002e-05, + "output_dbu_cost_per_token": 0.000214286, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "databricks/databricks-gemini-2-5-flash": { + "input_cost_per_token": 3.0001999999999996e-07, + "input_dbu_cost_per_token": 4.285999999999999e-06, + "litellm_provider": "databricks", + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_tokens": 1048576, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 2.49998e-06, + "output_dbu_cost_per_token": 3.5714e-05, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supports_function_calling": true, + "supports_tool_choice": true + }, + "databricks/databricks-gemini-2-5-pro": { + "input_cost_per_token": 1.24999e-06, + "input_dbu_cost_per_token": 1.7857e-05, + "litellm_provider": "databricks", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 1048576, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 9.999990000000002e-06, + "output_dbu_cost_per_token": 0.000142857, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving", + "supports_function_calling": true, + "supports_tool_choice": true + }, + "databricks/databricks-gemma-3-12b": { + "input_cost_per_token": 1.5000999999999998e-07, + "input_dbu_cost_per_token": 2.1429999999999996e-06, + "litellm_provider": "databricks", + "max_input_tokens": 128000, + "max_output_tokens": 32000, + "max_tokens": 128000, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 5.0001e-07, + "output_dbu_cost_per_token": 7.143e-06, + "source": "https://www.databricks.com/product/pricing/foundation-model-serving" + }, + "databricks/databricks-gpt-5": { + "input_cost_per_token": 1.24999e-06, + "input_dbu_cost_per_token": 1.7857e-05, + "litellm_provider": "databricks", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 400000, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 9.999990000000002e-06, + "output_dbu_cost_per_token": 0.000142857, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving" + }, + "databricks/databricks-gpt-5-1": { + "input_cost_per_token": 1.24999e-06, + "input_dbu_cost_per_token": 1.7857e-05, + "litellm_provider": "databricks", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 400000, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 9.999990000000002e-06, + "output_dbu_cost_per_token": 0.000142857, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving" + }, + "databricks/databricks-gpt-5-mini": { + "input_cost_per_token": 2.4997000000000006e-07, + "input_dbu_cost_per_token": 3.571e-06, + "litellm_provider": "databricks", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 400000, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 1.9999700000000004e-06, + "output_dbu_cost_per_token": 2.8571e-05, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving" + }, + "databricks/databricks-gpt-5-nano": { + "input_cost_per_token": 4.998e-08, + "input_dbu_cost_per_token": 7.14e-07, + "litellm_provider": "databricks", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 400000, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 3.9998000000000007e-07, + "output_dbu_cost_per_token": 5.714000000000001e-06, + "source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving" + }, + "databricks/databricks-gpt-oss-120b": { + "input_cost_per_token": 1.5000999999999998e-07, + "input_dbu_cost_per_token": 2.1429999999999996e-06, + "litellm_provider": "databricks", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 5.9997e-07, + "output_dbu_cost_per_token": 8.571e-06, + "source": "https://www.databricks.com/product/pricing/foundation-model-serving" + }, + "databricks/databricks-gpt-oss-20b": { + "input_cost_per_token": 7e-08, + "input_dbu_cost_per_token": 1e-06, + "litellm_provider": "databricks", + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "max_tokens": 131072, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 3.0001999999999996e-07, + "output_dbu_cost_per_token": 4.285999999999999e-06, + "source": "https://www.databricks.com/product/pricing/foundation-model-serving" + }, "databricks/databricks-gte-large-en": { - "input_cost_per_token": 1.2999e-07, + "input_cost_per_token": 1.2999000000000001e-07, "input_dbu_cost_per_token": 1.857e-06, "litellm_provider": "databricks", "max_input_tokens": 8192, @@ -7868,14 +8167,14 @@ "notes": "Input/output cost per token is dbu cost * $0.070, based on databricks Llama 3.1 70B conversion. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." }, "mode": "chat", - "output_cost_per_token": 1.5e-06, + "output_cost_per_token": 1.5000300000000002e-06, "output_dbu_cost_per_token": 2.1429e-05, "source": "https://www.databricks.com/product/pricing/foundation-model-serving", "supports_tool_choice": true }, "databricks/databricks-llama-4-maverick": { - "input_cost_per_token": 5e-06, - "input_dbu_cost_per_token": 7.143e-05, + "input_cost_per_token": 5.0001e-07, + "input_dbu_cost_per_token": 7.143e-06, "litellm_provider": "databricks", "max_input_tokens": 128000, "max_output_tokens": 128000, @@ -7884,13 +8183,13 @@ "notes": "Databricks documentation now provides both DBU costs (_dbu_cost_per_token) and dollar costs(_cost_per_token)." }, "mode": "chat", - "output_cost_per_token": 1.5e-05, - "output_dbu_cost_per_token": 0.00021429, + "output_cost_per_token": 1.5000300000000002e-06, + "output_dbu_cost_per_token": 2.1429e-05, "source": "https://www.databricks.com/product/pricing/foundation-model-serving", "supports_tool_choice": true }, "databricks/databricks-meta-llama-3-1-405b-instruct": { - "input_cost_per_token": 5e-06, + "input_cost_per_token": 5.00003e-06, "input_dbu_cost_per_token": 7.1429e-05, "litellm_provider": "databricks", "max_input_tokens": 128000, @@ -7900,14 +8199,29 @@ "notes": "Input/output cost per token is dbu cost * $0.070, based on databricks Llama 3.1 70B conversion. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." }, "mode": "chat", - "output_cost_per_token": 1.500002e-05, - "output_db_cost_per_token": 0.000214286, + "output_cost_per_token": 1.5000020000000002e-05, + "output_dbu_cost_per_token": 0.000214286, "source": "https://www.databricks.com/product/pricing/foundation-model-serving", "supports_tool_choice": true }, + "databricks/databricks-meta-llama-3-1-8b-instruct": { + "input_cost_per_token": 1.5000999999999998e-07, + "input_dbu_cost_per_token": 2.1429999999999996e-06, + "litellm_provider": "databricks", + "max_input_tokens": 200000, + "max_output_tokens": 128000, + "max_tokens": 200000, + "metadata": { + "notes": "Input/output cost per token is dbu cost * $0.070. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." + }, + "mode": "chat", + "output_cost_per_token": 4.5003000000000007e-07, + "output_dbu_cost_per_token": 6.429000000000001e-06, + "source": "https://www.databricks.com/product/pricing/foundation-model-serving" + }, "databricks/databricks-meta-llama-3-3-70b-instruct": { - "input_cost_per_token": 1.00002e-06, - "input_dbu_cost_per_token": 1.4286e-05, + "input_cost_per_token": 5.0001e-07, + "input_dbu_cost_per_token": 7.143e-06, "litellm_provider": "databricks", "max_input_tokens": 128000, "max_output_tokens": 128000, @@ -7916,8 +8230,8 @@ "notes": "Input/output cost per token is dbu cost * $0.070, based on databricks Llama 3.1 70B conversion. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." }, "mode": "chat", - "output_cost_per_token": 2.99999e-06, - "output_dbu_cost_per_token": 4.2857e-05, + "output_cost_per_token": 1.5000300000000002e-06, + "output_dbu_cost_per_token": 2.1429e-05, "source": "https://www.databricks.com/product/pricing/foundation-model-serving", "supports_tool_choice": true }, @@ -7932,7 +8246,7 @@ "notes": "Input/output cost per token is dbu cost * $0.070, based on databricks Llama 3.1 70B conversion. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." }, "mode": "chat", - "output_cost_per_token": 2.99999e-06, + "output_cost_per_token": 2.9999900000000002e-06, "output_dbu_cost_per_token": 4.2857e-05, "source": "https://www.databricks.com/product/pricing/foundation-model-serving", "supports_tool_choice": true @@ -7948,13 +8262,13 @@ "notes": "Input/output cost per token is dbu cost * $0.070, based on databricks Llama 3.1 70B conversion. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." }, "mode": "chat", - "output_cost_per_token": 9.9902e-07, + "output_cost_per_token": 1.00002e-06, "output_dbu_cost_per_token": 1.4286e-05, "source": "https://www.databricks.com/product/pricing/foundation-model-serving", "supports_tool_choice": true }, "databricks/databricks-mpt-30b-instruct": { - "input_cost_per_token": 9.9902e-07, + "input_cost_per_token": 1.00002e-06, "input_dbu_cost_per_token": 1.4286e-05, "litellm_provider": "databricks", "max_input_tokens": 8192, @@ -7964,7 +8278,7 @@ "notes": "Input/output cost per token is dbu cost * $0.070, based on databricks Llama 3.1 70B conversion. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." }, "mode": "chat", - "output_cost_per_token": 9.9902e-07, + "output_cost_per_token": 1.00002e-06, "output_dbu_cost_per_token": 1.4286e-05, "source": "https://www.databricks.com/product/pricing/foundation-model-serving", "supports_tool_choice": true diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 58267fdfea9..942192e2edf 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -2618,6 +2618,7 @@ class LlmProviders(str, Enum): DATABRICKS = "databricks" EMPOWER = "empower" GITHUB = "github" + RAGFLOW = "ragflow" COMPACTIFAI = "compactifai" DOCKER_MODEL_RUNNER = "docker_model_runner" CUSTOM = "custom" diff --git a/litellm/utils.py b/litellm/utils.py index 6c50afc5f49..eb671ce2b99 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -7110,6 +7110,8 @@ class ProviderConfigManager: return litellm.CompactifAIChatConfig() elif litellm.LlmProviders.GITHUB_COPILOT == provider: return litellm.GithubCopilotConfig() + elif litellm.LlmProviders.RAGFLOW == provider: + return litellm.RAGFlowConfig() elif ( litellm.LlmProviders.CUSTOM == provider or litellm.LlmProviders.CUSTOM_OPENAI == provider diff --git a/tests/test_litellm/llms/ragflow/chat/__init__.py b/tests/test_litellm/llms/ragflow/chat/__init__.py new file mode 100644 index 00000000000..4e074b84150 --- /dev/null +++ b/tests/test_litellm/llms/ragflow/chat/__init__.py @@ -0,0 +1,4 @@ +""" +RAGFlow chat transformation tests. +""" + diff --git a/tests/test_litellm/llms/ragflow/chat/test_ragflow_chat_transformation.py b/tests/test_litellm/llms/ragflow/chat/test_ragflow_chat_transformation.py new file mode 100644 index 00000000000..90f2504f94c --- /dev/null +++ b/tests/test_litellm/llms/ragflow/chat/test_ragflow_chat_transformation.py @@ -0,0 +1,376 @@ +""" +Test file for RAGFlow chat transformation functionality. + +Tests the model name parsing, URL construction, and request transformation +for RAGFlow's OpenAI-compatible API with custom path structures. +""" + +import os +import sys +from unittest.mock import Mock, patch + +import pytest + +# Add the project root to Python path +sys.path.insert(0, os.path.abspath("../../../../..")) + +import litellm +from litellm.llms.ragflow.chat.transformation import RAGFlowConfig +from litellm.types.llms.openai import AllMessageValues + + +class TestRAGFlowChatTransformation: + """Test suite for RAGFlow chat transformation functionality.""" + + def test_parse_ragflow_model_chat(self): + """Test parsing of chat model format.""" + config = RAGFlowConfig() + + model = "ragflow/chat/my-chat-id/gpt-4o-mini" + endpoint_type, entity_id, model_name = config._parse_ragflow_model(model) + + assert endpoint_type == "chat" + assert entity_id == "my-chat-id" + assert model_name == "gpt-4o-mini" + + def test_parse_ragflow_model_agent(self): + """Test parsing of agent model format.""" + config = RAGFlowConfig() + + model = "ragflow/agent/my-agent-id/gpt-4o-mini" + endpoint_type, entity_id, model_name = config._parse_ragflow_model(model) + + assert endpoint_type == "agent" + assert entity_id == "my-agent-id" + assert model_name == "gpt-4o-mini" + + def test_parse_ragflow_model_with_slashes_in_model_name(self): + """Test parsing when model name contains slashes.""" + config = RAGFlowConfig() + + model = "ragflow/chat/my-chat-id/openai/gpt-4o-mini" + endpoint_type, entity_id, model_name = config._parse_ragflow_model(model) + + assert endpoint_type == "chat" + assert entity_id == "my-chat-id" + assert model_name == "openai/gpt-4o-mini" + + def test_parse_ragflow_model_invalid_format(self): + """Test parsing with invalid model format.""" + config = RAGFlowConfig() + + with pytest.raises(ValueError, match="Invalid RAGFlow model format"): + config._parse_ragflow_model("ragflow/chat/model-name") + + with pytest.raises(ValueError, match="Invalid RAGFlow model format"): + config._parse_ragflow_model("invalid/chat/id/model") + + with pytest.raises(ValueError, match="Must start with 'ragflow/'"): + config._parse_ragflow_model("not-ragflow/chat/id/model") + + def test_parse_ragflow_model_invalid_endpoint_type(self): + """Test parsing with invalid endpoint type.""" + config = RAGFlowConfig() + + with pytest.raises(ValueError, match="Invalid RAGFlow endpoint type"): + config._parse_ragflow_model("ragflow/invalid/my-id/model") + + def test_get_complete_url_chat(self): + """Test URL construction for chat endpoint.""" + config = RAGFlowConfig() + + model = "ragflow/chat/my-chat-id/gpt-4o-mini" + api_base = "http://localhost:9380" + + url = config.get_complete_url( + api_base=api_base, + api_key=None, + model=model, + optional_params={}, + litellm_params={}, + stream=False, + ) + + assert url == "http://localhost:9380/api/v1/chats_openai/my-chat-id/chat/completions" + + def test_get_complete_url_agent(self): + """Test URL construction for agent endpoint.""" + config = RAGFlowConfig() + + model = "ragflow/agent/my-agent-id/gpt-4o-mini" + api_base = "http://localhost:9380" + + url = config.get_complete_url( + api_base=api_base, + api_key=None, + model=model, + optional_params={}, + litellm_params={}, + stream=False, + ) + + assert url == "http://localhost:9380/api/v1/agents_openai/my-agent-id/chat/completions" + + def test_get_complete_url_strips_v1(self): + """Test URL construction when api_base ends with /v1.""" + config = RAGFlowConfig() + + model = "ragflow/chat/my-chat-id/gpt-4o-mini" + api_base = "http://localhost:9380/v1" + + url = config.get_complete_url( + api_base=api_base, + api_key=None, + model=model, + optional_params={}, + litellm_params={}, + stream=False, + ) + + assert url == "http://localhost:9380/api/v1/chats_openai/my-chat-id/chat/completions" + + def test_get_complete_url_strips_api_v1(self): + """Test URL construction when api_base ends with /api/v1.""" + config = RAGFlowConfig() + + model = "ragflow/agent/my-agent-id/gpt-4o-mini" + api_base = "http://localhost:9380/api/v1" + + url = config.get_complete_url( + api_base=api_base, + api_key=None, + model=model, + optional_params={}, + litellm_params={}, + stream=False, + ) + + assert url == "http://localhost:9380/api/v1/agents_openai/my-agent-id/chat/completions" + + def test_get_complete_url_from_litellm_params(self): + """Test URL construction with api_base from litellm_params.""" + config = RAGFlowConfig() + + model = "ragflow/chat/my-chat-id/gpt-4o-mini" + # Create a simple dict-like object for litellm_params + class LiteLLMParams: + def __init__(self): + self.api_base = "http://ragflow-server:9380" + + litellm_params = LiteLLMParams() + + url = config.get_complete_url( + api_base=None, + api_key=None, + model=model, + optional_params={}, + litellm_params=litellm_params, + stream=False, + ) + + assert url == "http://ragflow-server:9380/api/v1/chats_openai/my-chat-id/chat/completions" + + def test_get_complete_url_missing_api_base(self): + """Test URL construction when api_base is missing.""" + config = RAGFlowConfig() + + model = "ragflow/chat/my-chat-id/gpt-4o-mini" + + with pytest.raises(ValueError, match="api_base is required"): + config.get_complete_url( + api_base=None, + api_key=None, + model=model, + optional_params={}, + litellm_params={}, + stream=False, + ) + + @patch.dict(os.environ, {"RAGFLOW_API_BASE": "http://env-ragflow:9380"}) + def test_get_complete_url_from_environment(self): + """Test URL construction with api_base from environment variable.""" + config = RAGFlowConfig() + + model = "ragflow/agent/my-agent-id/gpt-4o-mini" + + url = config.get_complete_url( + api_base=None, + api_key=None, + model=model, + optional_params={}, + litellm_params={}, + stream=False, + ) + + assert url == "http://env-ragflow:9380/api/v1/agents_openai/my-agent-id/chat/completions" + + def test_validate_environment_sets_headers(self): + """Test that validate_environment sets proper headers.""" + config = RAGFlowConfig() + + headers = {} + model = "ragflow/chat/my-chat-id/gpt-4o-mini" + messages = [{"role": "user", "content": "Hello"}] + api_key = "test-api-key" + + result_headers = config.validate_environment( + headers=headers, + model=model, + messages=messages, + optional_params={}, + litellm_params={}, + api_key=api_key, + api_base="http://localhost:9380", + ) + + assert result_headers["Authorization"] == "Bearer test-api-key" + assert result_headers["Content-Type"] == "application/json" + + def test_validate_environment_stores_actual_model(self): + """Test that validate_environment stores actual model name.""" + config = RAGFlowConfig() + + headers = {} + model = "ragflow/chat/my-chat-id/gpt-4o-mini" + messages = [{"role": "user", "content": "Hello"}] + litellm_params = {} + + config.validate_environment( + headers=headers, + model=model, + messages=messages, + optional_params={}, + litellm_params=litellm_params, + api_key="test-key", + api_base="http://localhost:9380", + ) + + assert litellm_params["_ragflow_actual_model"] == "gpt-4o-mini" + + @patch.dict(os.environ, {"RAGFLOW_API_KEY": "env-api-key"}) + def test_validate_environment_from_environment(self): + """Test that validate_environment gets api_key from environment.""" + config = RAGFlowConfig() + + headers = {} + model = "ragflow/agent/my-agent-id/gpt-4o-mini" + messages = [{"role": "user", "content": "Hello"}] + + result_headers = config.validate_environment( + headers=headers, + model=model, + messages=messages, + optional_params={}, + litellm_params={}, + api_key=None, + api_base="http://localhost:9380", + ) + + assert result_headers["Authorization"] == "Bearer env-api-key" + + def test_validate_environment_from_litellm_params(self): + """Test that validate_environment gets api_key from litellm_params.""" + config = RAGFlowConfig() + + headers = {} + model = "ragflow/chat/my-chat-id/gpt-4o-mini" + messages = [{"role": "user", "content": "Hello"}] + # Create a simple object for litellm_params with api_key attribute + class LiteLLMParams: + def __init__(self): + self.api_key = "litellm-params-key" + def __setitem__(self, key, value): + setattr(self, key, value) + + litellm_params = LiteLLMParams() + + result_headers = config.validate_environment( + headers=headers, + model=model, + messages=messages, + optional_params={}, + litellm_params=litellm_params, + api_key=None, + api_base="http://localhost:9380", + ) + + assert result_headers["Authorization"] == "Bearer litellm-params-key" + + def test_transform_request_uses_actual_model(self): + """Test that transform_request uses the actual model name.""" + config = RAGFlowConfig() + + model = "ragflow/chat/my-chat-id/gpt-4o-mini" + messages = [{"role": "user", "content": "Hello"}] + litellm_params = {"_ragflow_actual_model": "gpt-4o-mini"} + + # Test the actual behavior by checking the model in the result + result = config.transform_request( + model=model, + messages=messages, + optional_params={}, + litellm_params=litellm_params, + headers={}, + ) + + # The result should contain the actual model name, not the full ragflow path + assert result["model"] == "gpt-4o-mini" + assert result["messages"] == messages + + def test_transform_request_fallback_parsing(self): + """Test that transform_request falls back to parsing if _ragflow_actual_model is missing.""" + config = RAGFlowConfig() + + model = "ragflow/agent/my-agent-id/gpt-4o-mini" + messages = [{"role": "user", "content": "Hello"}] + litellm_params = {} # Missing _ragflow_actual_model + + result = config.transform_request( + model=model, + messages=messages, + optional_params={}, + litellm_params=litellm_params, + headers={}, + ) + + # Should parse and use the actual model name + assert result["model"] == "gpt-4o-mini" + assert result["messages"] == messages + + def test_get_openai_compatible_provider_info(self): + """Test _get_openai_compatible_provider_info returns correct values.""" + config = RAGFlowConfig() + + model = "ragflow/chat/my-chat-id/gpt-4o-mini" + api_base = "http://localhost:9380" + api_key = "test-key" + + result_api_base, result_api_key, result_provider = config._get_openai_compatible_provider_info( + model=model, + api_base=api_base, + api_key=api_key, + custom_llm_provider="ragflow", + ) + + assert result_api_base == api_base + assert result_api_key == api_key + assert result_provider == "ragflow" + + @patch.dict(os.environ, {"RAGFLOW_API_BASE": "http://env-base:9380", "RAGFLOW_API_KEY": "env-key"}) + def test_get_openai_compatible_provider_info_from_env(self): + """Test _get_openai_compatible_provider_info gets values from environment.""" + config = RAGFlowConfig() + + model = "ragflow/agent/my-agent-id/gpt-4o-mini" + + result_api_base, result_api_key, result_provider = config._get_openai_compatible_provider_info( + model=model, + api_base=None, + api_key=None, + custom_llm_provider="ragflow", + ) + + assert result_api_base == "http://env-base:9380" + assert result_api_key == "env-key" + assert result_provider == "ragflow" + From 8c1290dcd2bcb12cba8fab8dfd4c8a94e0c85382 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 2 Dec 2025 11:26:29 -0800 Subject: [PATCH 04/37] Indent and import fix --- .../guardrail_hooks/bedrock_guardrails.py | 51 ++++++++++--------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py index f9c3caf4944..bd1e805361c 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py +++ b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py @@ -20,6 +20,7 @@ from typing import ( AsyncGenerator, List, Literal, + NamedTuple, Optional, Tuple, Union, @@ -1291,38 +1292,38 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): request_data=request_data, ) - bedrock_response = await self.make_bedrock_api_request( - source="INPUT", - messages=mock_messages, - request_data=request_data, + bedrock_response = await self.make_bedrock_api_request( + source="INPUT", + messages=mock_messages, + request_data=request_data, + ) + + if bedrock_response.get("action") == "BLOCKED": + raise Exception( + f"Content blocked by Bedrock guardrail: {bedrock_response.get('reason', 'Unknown reason')}" ) - if bedrock_response.get("action") == "BLOCKED": - raise Exception( - f"Content blocked by Bedrock guardrail: {bedrock_response.get('reason', 'Unknown reason')}" - ) - - # Apply any masking that was applied by the guardrail - masked_text = text - output_list = bedrock_response.get("output") - if output_list: - # If the guardrail returned modified content, use that - for output_item in output_list: + # Apply any masking that was applied by the guardrail + masked_text = text + output_list = bedrock_response.get("output") + if output_list: + # If the guardrail returned modified content, use that + for output_item in output_list: + text_content = output_item.get("text") + if text_content: + masked_text = str(text_content) + break + else: + outputs_list = bedrock_response.get("outputs") + if outputs_list: + # Fallback to outputs field if output is not available + for output_item in outputs_list: text_content = output_item.get("text") if text_content: masked_text = str(text_content) break - else: - outputs_list = bedrock_response.get("outputs") - if outputs_list: - # Fallback to outputs field if output is not available - for output_item in outputs_list: - text_content = output_item.get("text") - if text_content: - masked_text = str(text_content) - break - masked_texts.append(masked_text) + masked_texts.append(masked_text) verbose_proxy_logger.debug( "Bedrock Guardrail: Successfully applied guardrail" From 8b8f93d508219079d1327124de00df864d52c571 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 2 Dec 2025 12:24:00 -0800 Subject: [PATCH 05/37] Add Google Private API Endpoint to Vertex AI fields --- .../proxy/public_endpoints/provider_create_fields.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/litellm/proxy/public_endpoints/provider_create_fields.json b/litellm/proxy/public_endpoints/provider_create_fields.json index 8e08bd8d991..67f2afa1e71 100644 --- a/litellm/proxy/public_endpoints/provider_create_fields.json +++ b/litellm/proxy/public_endpoints/provider_create_fields.json @@ -2607,6 +2607,16 @@ "options": null, "default_value": null }, + { + "key": "api_base", + "label": "Google Private API Endpoint", + "placeholder": null, + "tooltip": null, + "required": false, + "field_type": "text", + "options": null, + "default_value": null + }, { "key": "vertex_credentials", "label": "Vertex Credentials", From db6c6eea89ebf4ae93f7d9030c1e3d098f09038a Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Tue, 2 Dec 2025 14:10:00 -0800 Subject: [PATCH 06/37] [Docs] Add guide on how to debug gateway error vs provider error (#17387) * add error diagnosis * docs error diagnosis --- docs/my-website/docs/proxy/error_diagnosis.md | 90 +++++++++++++++++++ docs/my-website/sidebars.js | 1 + 2 files changed, 91 insertions(+) create mode 100644 docs/my-website/docs/proxy/error_diagnosis.md diff --git a/docs/my-website/docs/proxy/error_diagnosis.md b/docs/my-website/docs/proxy/error_diagnosis.md new file mode 100644 index 00000000000..9629fc52b0c --- /dev/null +++ b/docs/my-website/docs/proxy/error_diagnosis.md @@ -0,0 +1,90 @@ +# Diagnosing Errors - Provider vs Gateway + +Having trouble diagnosing if an error is from the **LLM Provider** (OpenAI, Anthropic, etc.) or from the **LiteLLM AI Gateway** itself? Here's how to tell. + +## Quick Rule + +**If the error contains `Exception`, it's from the provider.** + +| Error Contains | Error Source | +|----------------|--------------| +| `AnthropicException` | Anthropic | +| `OpenAIException` | OpenAI | +| `AzureException` | Azure | +| `BedrockException` | AWS Bedrock | +| `VertexAIException` | Google Vertex AI | +| No provider name | LiteLLM AI Gateway | + +## Examples + +### Provider Error (from AWS Bedrock) + +``` +{ + "error": { + "message": "litellm.BadRequestError: BedrockException - {\"message\":\"The model returned the following errors: messages.1.content.0.type: Expected `thinking` or `redacted_thinking`, but found `text`.\"}", + "type": "invalid_request_error", + "param": null, + "code": "400" + } +} +``` + +This error is from **AWS Bedrock** (notice `BedrockException`). The Bedrock API is rejecting the request due to invalid message format - this is not a LiteLLM issue. + +### Provider Error (from OpenAI) + +``` +{ + "error": { + "message": "litellm.AuthenticationError: OpenAIException - Incorrect API key provided: . You can find your API key at https://platform.openai.com/account/api-keys.", + "type": "invalid_request_error", + "param": null, + "code": "invalid_api_key" + } +} +``` + +This error is from **OpenAI** (notice `OpenAIException`). The OpenAI API key configured in LiteLLM is invalid. + +### Provider Error (from Anthropic) + +``` +{ + "error": { + "message": "litellm.InternalServerError: AnthropicException - Overloaded. Handle with `litellm.InternalServerError`.", + "type": "internal_server_error", + "param": null, + "code": "500" + } +} +``` + +This error is from **Anthropic** (notice `AnthropicException`). The Anthropic API is overloaded - this is not a LiteLLM issue. + +### Gateway Error (from LiteLLM) + +``` +{ + "error": { + "message": "Invalid API Key. Please check your LiteLLM API key.", + "type": "auth_error", + "param": null, + "code": "401" + } +} +``` + +This error is from the **LiteLLM AI Gateway** (no provider name). Your LiteLLM virtual key is invalid. + +## What to do? + +| Error Source | Action | +|--------------|--------| +| Provider Error | Check the provider's status page, adjust rate limits, or retry later | +| Gateway Error | Check your LiteLLM configuration, API keys, or [open an issue](https://github.com/BerriAI/litellm/issues) | + +## See Also + +- [Debugging](/docs/proxy/debugging) - Enable debug logs to see detailed request/response info +- [Exception Mapping](/docs/exception_mapping) - Full list of LiteLLM exception types diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index e467711b59d..a4a2956ff6a 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -141,6 +141,7 @@ const sidebars = { "proxy/quick_start", "proxy/cli", "proxy/debugging", + "proxy/error_diagnosis", "proxy/deploy", "proxy/health", "proxy/master_key_rotations", From de4ff120ebe049cf86b07483d32c5c7b22c5190f Mon Sep 17 00:00:00 2001 From: Leslie Cheng Date: Tue, 2 Dec 2025 14:37:45 -0800 Subject: [PATCH 07/37] =?UTF-8?q?=F0=9F=90=9B=20Fix=20proxy=20caching=20be?= =?UTF-8?q?tween=20requests=20in=20aiohttp=20transport=20(#17122)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * write a regression test * impl fix * add test for host case * use the host as cache key --- .../llms/custom_httpx/aiohttp_transport.py | 67 ++++---- .../custom_httpx/test_aiohttp_transport.py | 143 ++++++++++++++---- 2 files changed, 137 insertions(+), 73 deletions(-) diff --git a/litellm/llms/custom_httpx/aiohttp_transport.py b/litellm/llms/custom_httpx/aiohttp_transport.py index 6997afafd8d..f845bf7cb90 100644 --- a/litellm/llms/custom_httpx/aiohttp_transport.py +++ b/litellm/llms/custom_httpx/aiohttp_transport.py @@ -82,9 +82,7 @@ class AiohttpResponseStream(httpx.AsyncByteStream): async def __aiter__(self) -> typing.AsyncIterator[bytes]: try: - async for chunk in self._aiohttp_response.content.iter_chunked( - self.CHUNK_SIZE - ): + async for chunk in self._aiohttp_response.content.iter_chunked(self.CHUNK_SIZE): yield chunk except ( aiohttp.ClientPayloadError, @@ -120,16 +118,13 @@ class AiohttpResponseStream(httpx.AsyncByteStream): class AiohttpTransport(httpx.AsyncBaseTransport): - def __init__( - self, client: Union[ClientSession, Callable[[], ClientSession]] - ) -> None: + def __init__(self, client: Union[ClientSession, Callable[[], ClientSession]]) -> None: self.client = client ######################################################### # Class variables for proxy settings ######################################################### - self.proxy: Optional[str] = None - self.checked_proxy_env_settings: bool = False + self.proxy_cache: Dict[str, Optional[str]] = {} async def aclose(self) -> None: if isinstance(self.client, ClientSession): @@ -184,11 +179,7 @@ class LiteLLMAiohttpTransport(AiohttpTransport): current_loop = asyncio.get_running_loop() # If session is from a different or closed loop, recreate it - if ( - session_loop is None - or session_loop != current_loop - or session_loop.is_closed() - ): + if session_loop is None or session_loop != current_loop or session_loop.is_closed(): # Close old session to prevent leaks old_session = self.client try: @@ -215,7 +206,7 @@ class LiteLLMAiohttpTransport(AiohttpTransport): self.client = ClientSession() return self.client - + async def _make_aiohttp_request( self, client_session: ClientSession, @@ -226,20 +217,20 @@ class LiteLLMAiohttpTransport(AiohttpTransport): ) -> ClientResponse: """ Helper function to make an aiohttp request with the given parameters. - + Args: client_session: The aiohttp ClientSession to use request: The httpx Request to send timeout: Timeout settings dict with 'connect', 'read', 'pool' keys proxy: Optional proxy URL sni_hostname: Optional SNI hostname for SSL - + Returns: ClientResponse from aiohttp """ from aiohttp import ClientTimeout from yarl import URL as YarlURL - + try: data = request.content except httpx.RequestNotRead: @@ -262,9 +253,9 @@ class LiteLLMAiohttpTransport(AiohttpTransport): proxy=proxy, server_hostname=sni_hostname, ).__aenter__() - + return response - + async def handle_async_request( self, request: httpx.Request, @@ -297,7 +288,7 @@ class LiteLLMAiohttpTransport(AiohttpTransport): else: self.client = ClientSession() client_session = self.client - + # Retry the request with the new session with map_aiohttp_exceptions(): response = await self._make_aiohttp_request( @@ -317,45 +308,41 @@ class LiteLLMAiohttpTransport(AiohttpTransport): content=AiohttpResponseStream(response), request=request, ) - async def _get_proxy_settings(self, request: httpx.Request): proxy = None - if not ( - litellm.disable_aiohttp_trust_env - or str_to_bool(os.getenv("DISABLE_AIOHTTP_TRUST_ENV", "False")) - ): + if not (litellm.disable_aiohttp_trust_env or str_to_bool(os.getenv("DISABLE_AIOHTTP_TRUST_ENV", "False"))): try: proxy = self._proxy_from_env(request.url) except Exception as e: # pragma: no cover - best effort verbose_logger.debug(f"Error reading proxy env: {e}") return proxy - def _proxy_from_env(self, url: httpx.URL) -> typing.Optional[str]: """ Return proxy URL from env for the given request URL Only check the proxy env settings once, this is a costly operation for CPU % usage - + .""" ######################################################### # Check if we've already checked the proxy env settings ######################################################### - if self.checked_proxy_env_settings is True: - return self.proxy - - ######################################################### - # set self.checked_proxy_env_settings to True - ######################################################### - self.checked_proxy_env_settings = True + proxy_cache_key = url.host + + if proxy_cache_key in self.proxy_cache: + return self.proxy_cache[proxy_cache_key] + proxies = urllib.request.getproxies() if urllib.request.proxy_bypass(url.host): - return None + proxy_url = None + else: + proxy = proxies.get(url.scheme) or proxies.get("all") + if proxy and "://" not in proxy: + proxy = f"http://{proxy}" + proxy_url = proxy - proxy = proxies.get(url.scheme) or proxies.get("all") - if proxy and "://" not in proxy: - proxy = f"http://{proxy}" - self.proxy = proxy - return self.proxy + self.proxy_cache[proxy_cache_key] = proxy_url + + return proxy_url diff --git a/tests/test_litellm/llms/custom_httpx/test_aiohttp_transport.py b/tests/test_litellm/llms/custom_httpx/test_aiohttp_transport.py index 1f1a36fd7ab..f0dac113645 100644 --- a/tests/test_litellm/llms/custom_httpx/test_aiohttp_transport.py +++ b/tests/test_litellm/llms/custom_httpx/test_aiohttp_transport.py @@ -1,6 +1,6 @@ +import asyncio import os import sys -from unittest.mock import AsyncMock, MagicMock, patch import aiohttp import aiohttp.client_exceptions @@ -8,14 +8,11 @@ import aiohttp.http_exceptions import httpx import pytest -sys.path.insert( - 0, os.path.abspath("../../../..") -) # Adds the parent directory to the system path +sys.path.insert(0, os.path.abspath("../../../..")) # Adds the parent directory to the system path from litellm.llms.custom_httpx.aiohttp_transport import ( AiohttpResponseStream, LiteLLMAiohttpTransport, - map_aiohttp_exceptions, ) @@ -32,9 +29,7 @@ class MockAiohttpResponse: ): self.status = status self.headers = headers or {} - self.content = MockContent( - content_chunks, exception_to_raise, exception_at_chunk - ) + self.content = MockContent(content_chunks, exception_to_raise, exception_at_chunk) async def __aexit__(self, exc_type, exc_val, exc_tb): pass @@ -74,7 +69,6 @@ async def test_aiohttp_response_stream_normal_flow(): @pytest.mark.asyncio async def test_transfer_encoding_error_no_httpx_read_error(): """Test that TransferEncodingError doesn't get converted to httpx.ReadError""" - import logging # Create a TransferEncodingError wrapped in ClientPayloadError (like in real scenarios) transfer_error = aiohttp.http_exceptions.TransferEncodingError( @@ -82,9 +76,7 @@ async def test_transfer_encoding_error_no_httpx_read_error(): ) # Wrap it in ClientPayloadError as aiohttp does - client_payload_error = aiohttp.ClientPayloadError( - "Response payload is not completed" - ) + client_payload_error = aiohttp.ClientPayloadError("Response payload is not completed") client_payload_error.__cause__ = transfer_error mock_response = MockAiohttpResponse( @@ -111,9 +103,7 @@ async def test_transfer_encoding_error_no_httpx_read_error(): async def test_client_payload_error_graceful_handling(): """Test that ClientPayloadError is handled gracefully without stacktrace""" # Create a ClientPayloadError directly - client_error = aiohttp.client_exceptions.ClientPayloadError( - "Response payload is not completed" - ) + client_error = aiohttp.client_exceptions.ClientPayloadError("Response payload is not completed") mock_response = MockAiohttpResponse( content_chunks=[b"data1", b"data2", b"data3"], @@ -181,7 +171,6 @@ async def test_timeout_exception_gets_mapped(): @pytest.mark.asyncio async def test_handle_async_request_uses_env_proxy(monkeypatch): """Aiohttp transport should honor HTTP(S)_PROXY env vars""" - import asyncio proxy_url = "http://proxy.local:3128" monkeypatch.setenv("HTTP_PROXY", proxy_url) monkeypatch.setenv("http_proxy", proxy_url) @@ -200,7 +189,7 @@ async def test_handle_async_request_uses_env_proxy(monkeypatch): self._loop = asyncio.get_running_loop() except RuntimeError: self._loop = None - + def request(self, *args, **kwargs): captured["proxy"] = kwargs.get("proxy") @@ -231,30 +220,118 @@ async def test_handle_async_request_uses_env_proxy(monkeypatch): assert captured["proxy"] == proxy_url +@pytest.mark.asyncio +async def test_handle_async_request_uses_env_proxy_per_url(monkeypatch): + """Aiohttp transport should honor HTTP(S)_PROXY env vars unless NO_PROXY matches""" + proxy_url = "http://proxy.local:3128" + monkeypatch.setenv("NO_PROXY", "example.com") + monkeypatch.setenv("HTTP_PROXY", proxy_url) + monkeypatch.setenv("http_proxy", proxy_url) + monkeypatch.setenv("HTTPS_PROXY", proxy_url) + monkeypatch.setenv("https_proxy", proxy_url) + monkeypatch.delenv("DISABLE_AIOHTTP_TRUST_ENV", raising=False) + + request_count = 0 + proxied_count = 0 + + class FakeSession: + def __init__(self): + self.closed = False + try: + self._loop = asyncio.get_running_loop() + except RuntimeError: + self._loop = None + + def request(self, *args, **kwargs): + nonlocal request_count + nonlocal proxied_count + request_count += 1 + + if kwargs.get("proxy") is not None: + proxied_count += 1 + + class Resp: + status = 200 + headers = {} + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + pass + + @property + def content(self): + class C: + async def iter_chunked(self, size): + yield b"" + + return C() + + return Resp() + + transport = LiteLLMAiohttpTransport(client=lambda: FakeSession()) # type: ignore + request = httpx.Request("GET", "http://example.com") + await transport.handle_async_request(request) + + request = httpx.Request("GET", "http://foo.com") + await transport.handle_async_request(request) + + assert request_count == 2 + assert proxied_count == 1 + + +@pytest.mark.asyncio +async def test_handle_async_request_proxy_cache_per_host(monkeypatch): + """Aiohttp transport should only cache a proxy per host rather than full URL""" + proxy_url = "http://proxy.local:3128" + monkeypatch.setenv("NO_PROXY", "example.com") + monkeypatch.setenv("HTTP_PROXY", proxy_url) + monkeypatch.setenv("http_proxy", proxy_url) + monkeypatch.setenv("HTTPS_PROXY", proxy_url) + monkeypatch.setenv("https_proxy", proxy_url) + monkeypatch.delenv("DISABLE_AIOHTTP_TRUST_ENV", raising=False) + + def factory(): + return _make_mock_session() + + transport = LiteLLMAiohttpTransport(client=factory) # type: ignore + request = httpx.Request("GET", "http://foo.com/path1") + await transport.handle_async_request(request) + + request = httpx.Request("GET", "http://foo.com/path2") + await transport.handle_async_request(request) + + assert len(transport.proxy_cache) == 1 + + def _make_mock_response(should_fail=False, fail_count={"count": 0}): """Helper to create a mock aiohttp response""" + class MockResp: status = 200 headers = {} - + async def __aenter__(self): if should_fail and fail_count["count"] < 1: fail_count["count"] += 1 raise RuntimeError("Session is closed") return self - + async def __aexit__(self, *args): pass - + @property def content(self): class C: async def iter_chunked(self, size): yield b"test" + return C() - + return MockResp() + @pytest.mark.asyncio async def test_handle_async_request_total_timeout_triggers(): """ @@ -298,10 +375,10 @@ async def test_handle_async_request_total_timeout_triggers(): await transport.aclose() await runner.cleanup() + def _make_mock_session(closed=False): """Helper to create a mock aiohttp session""" - import asyncio - + class MockSession: def __init__(self): self.closed = closed @@ -309,10 +386,10 @@ def _make_mock_session(closed=False): self._loop = asyncio.get_running_loop() except RuntimeError: self._loop = None - + def request(self, *args, **kwargs): return _make_mock_response() - + return MockSession() @@ -320,14 +397,14 @@ def _make_mock_session(closed=False): async def test_handle_closed_session_before_request(): """Test that closed sessions are detected and recreated""" counts = {"sessions": 0} - + def factory(): counts["sessions"] += 1 return _make_mock_session(closed=counts["sessions"] == 1) - + transport = LiteLLMAiohttpTransport(client=factory) # type: ignore response = await transport.handle_async_request(httpx.Request("GET", "http://example.com")) - + assert counts["sessions"] == 2 # Created 2 sessions: closed one, then open one assert response.status_code == 200 @@ -337,7 +414,7 @@ async def test_handle_session_closed_during_request(): """Test that sessions closed during request are handled with retry""" counts = {"sessions": 0, "requests": 0} fail_count = {"count": 0} - + class MockSession: def __init__(self): self.closed = False @@ -345,18 +422,18 @@ async def test_handle_session_closed_during_request(): self._loop = __import__("asyncio").get_running_loop() except RuntimeError: self._loop = None - + def request(self, *args, **kwargs): counts["requests"] += 1 return _make_mock_response(should_fail=True, fail_count=fail_count) - + def factory(): counts["sessions"] += 1 return MockSession() - + transport = LiteLLMAiohttpTransport(client=factory) # type: ignore response = await transport.handle_async_request(httpx.Request("GET", "http://example.com")) - + assert counts["requests"] == 2 # First request failed, second succeeded assert counts["sessions"] == 2 # Created 2 sessions for retry assert response.status_code == 200 From 6c188c5ae2fa36d8f7d479479e93037f5777c06c Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Tue, 2 Dec 2025 15:36:23 -0800 Subject: [PATCH 08/37] [Feat] New model/provider - Adds support for Google Cloud Chirp3 HD on /speech (#17391) * docs vertex tts * place vertex ai types in file * use VertexAITextToSpeechConfig * use vertex_voice_dict * refactor docs * docs vertex ai chirp * TestVertexAITextToSpeechConfig * new provider vertex ai chirp3 * test_litellm_speech_vertex_ai_chirp * add vertex_ai/chirp cost trackign --- docs/my-website/docs/providers/vertex.md | 349 ------------- .../docs/providers/vertex_speech.md | 423 ++++++++++++++++ docs/my-website/sidebars.js | 1 + .../text_to_speech/transformation.py | 472 ++++++++++++++++++ litellm/main.py | 64 +-- ...odel_prices_and_context_window_backup.json | 9 + .../types/llms/vertex_ai_text_to_speech.py | 54 ++ litellm/utils.py | 6 + model_prices_and_context_window.json | 9 + provider_endpoints_support.json | 16 + tests/audio_tests/speech_vertex.mp3 | Bin 0 -> 122924 bytes .../text_to_speech/test_transformation.py | 190 +++++++ 12 files changed, 1212 insertions(+), 381 deletions(-) create mode 100644 docs/my-website/docs/providers/vertex_speech.md create mode 100644 litellm/llms/vertex_ai/text_to_speech/transformation.py create mode 100644 litellm/types/llms/vertex_ai_text_to_speech.py create mode 100644 tests/audio_tests/speech_vertex.mp3 create mode 100644 tests/litellm/llms/vertex_ai/text_to_speech/test_transformation.py diff --git a/docs/my-website/docs/providers/vertex.md b/docs/my-website/docs/providers/vertex.md index 70babea3814..da2997f6202 100644 --- a/docs/my-website/docs/providers/vertex.md +++ b/docs/my-website/docs/providers/vertex.md @@ -2550,355 +2550,6 @@ print(response) - -## **Gemini TTS (Text-to-Speech) Audio Output** - -:::info - -LiteLLM supports Gemini TTS models on Vertex AI that can generate audio responses using the OpenAI-compatible `audio` parameter format. - -::: - -### Supported Models - -LiteLLM supports Gemini TTS models with audio capabilities on Vertex AI (e.g. `vertex_ai/gemini-2.5-flash-preview-tts` and `vertex_ai/gemini-2.5-pro-preview-tts`). For the complete list of available TTS models and voices, see the [official Gemini TTS documentation](https://ai.google.dev/gemini-api/docs/speech-generation). - -### Limitations - -:::warning - -**Important Limitations**: -- Gemini TTS models only support the `pcm16` audio format -- **Streaming support has not been added** to TTS models yet -- The `modalities` parameter must be set to `['audio']` for TTS requests - -::: - -### Quick Start - - - - -```python -from litellm import completion -import json - -## GET CREDENTIALS -file_path = 'path/to/vertex_ai_service_account.json' - -# Load the JSON file -with open(file_path, 'r') as file: - vertex_credentials = json.load(file) - -# Convert to JSON string -vertex_credentials_json = json.dumps(vertex_credentials) - -response = completion( - model="vertex_ai/gemini-2.5-flash-preview-tts", - messages=[{"role": "user", "content": "Say hello in a friendly voice"}], - modalities=["audio"], # Required for TTS models - audio={ - "voice": "Kore", - "format": "pcm16" # Required: must be "pcm16" - }, - vertex_credentials=vertex_credentials_json -) - -print(response) -``` - - - - -1. Setup config.yaml - -```yaml -model_list: - - model_name: gemini-tts-flash - litellm_params: - model: vertex_ai/gemini-2.5-flash-preview-tts - vertex_project: "your-project-id" - vertex_location: "us-central1" - vertex_credentials: "/path/to/service_account.json" - - model_name: gemini-tts-pro - litellm_params: - model: vertex_ai/gemini-2.5-pro-preview-tts - vertex_project: "your-project-id" - vertex_location: "us-central1" - vertex_credentials: "/path/to/service_account.json" -``` - -2. Start proxy - -```bash -litellm --config /path/to/config.yaml -``` - -3. Make TTS request - -```bash -curl http://0.0.0.0:4000/v1/chat/completions \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer " \ - -d '{ - "model": "gemini-tts-flash", - "messages": [{"role": "user", "content": "Say hello in a friendly voice"}], - "modalities": ["audio"], - "audio": { - "voice": "Kore", - "format": "pcm16" - } - }' -``` - - - - -### Advanced Usage - -You can combine TTS with other Gemini features: - -```python -response = completion( - model="vertex_ai/gemini-2.5-pro-preview-tts", - messages=[ - {"role": "system", "content": "You are a helpful assistant that speaks clearly."}, - {"role": "user", "content": "Explain quantum computing in simple terms"} - ], - modalities=["audio"], - audio={ - "voice": "Charon", - "format": "pcm16" - }, - temperature=0.7, - max_tokens=150, - vertex_credentials=vertex_credentials_json -) -``` - -For more information about Gemini's TTS capabilities and available voices, see the [official Gemini TTS documentation](https://ai.google.dev/gemini-api/docs/speech-generation). - -## **Text to Speech APIs** - -:::info - -LiteLLM supports calling [Vertex AI Text to Speech API](https://console.cloud.google.com/vertex-ai/generative/speech/text-to-speech) in the OpenAI text to speech API format - -::: - - - -### Usage - Basic - - - - -Vertex AI does not support passing a `model` param - so passing `model=vertex_ai/` is the only required param - -**Sync Usage** - -```python -speech_file_path = Path(__file__).parent / "speech_vertex.mp3" -response = litellm.speech( - model="vertex_ai/", - input="hello what llm guardrail do you have", -) -response.stream_to_file(speech_file_path) -``` - -**Async Usage** -```python -speech_file_path = Path(__file__).parent / "speech_vertex.mp3" -response = litellm.aspeech( - model="vertex_ai/", - input="hello what llm guardrail do you have", -) -response.stream_to_file(speech_file_path) -``` - - - - -1. Add model to config.yaml -```yaml -model_list: - - model_name: vertex-tts - litellm_params: - model: vertex_ai/ # Vertex AI does not support passing a `model` param - so passing `model=vertex_ai/` is the only required param - vertex_project: "adroit-crow-413218" - vertex_location: "us-central1" - vertex_credentials: adroit-crow-413218-a956eef1a2a8.json - -litellm_settings: - drop_params: True -``` - -2. Start Proxy - -``` -$ litellm --config /path/to/config.yaml -``` - -3. Make Request use OpenAI Python SDK - - -```python -import openai - -client = openai.OpenAI(api_key="sk-1234", base_url="http://0.0.0.0:4000") - -# see supported values for "voice" on vertex here: -# https://console.cloud.google.com/vertex-ai/generative/speech/text-to-speech -response = client.audio.speech.create( - model = "vertex-tts", - input="the quick brown fox jumped over the lazy dogs", - voice={'languageCode': 'en-US', 'name': 'en-US-Studio-O'} -) -print("response from proxy", response) -``` - - - - - -### Usage - `ssml` as input - -Pass your `ssml` as input to the `input` param, if it contains ``, it will be automatically detected and passed as `ssml` to the Vertex AI API - -If you need to force your `input` to be passed as `ssml`, set `use_ssml=True` - - - - -Vertex AI does not support passing a `model` param - so passing `model=vertex_ai/` is the only required param - - -```python -speech_file_path = Path(__file__).parent / "speech_vertex.mp3" - - -ssml = """ - -

    Hello, world!

    -

    This is a test of the text-to-speech API.

    -
    -""" - -response = litellm.speech( - input=ssml, - model="vertex_ai/test", - voice={ - "languageCode": "en-UK", - "name": "en-UK-Studio-O", - }, - audioConfig={ - "audioEncoding": "LINEAR22", - "speakingRate": "10", - }, -) -response.stream_to_file(speech_file_path) -``` - -
    - - - -```python -import openai - -client = openai.OpenAI(api_key="sk-1234", base_url="http://0.0.0.0:4000") - -ssml = """ - -

    Hello, world!

    -

    This is a test of the text-to-speech API.

    -
    -""" - -# see supported values for "voice" on vertex here: -# https://console.cloud.google.com/vertex-ai/generative/speech/text-to-speech -response = client.audio.speech.create( - model = "vertex-tts", - input=ssml, - voice={'languageCode': 'en-US', 'name': 'en-US-Studio-O'}, -) -print("response from proxy", response) -``` - -
    -
    - - -### Forcing SSML Usage - -You can force the use of SSML by setting the `use_ssml` parameter to `True`. This is useful when you want to ensure that your input is treated as SSML, even if it doesn't contain the `` tags. - -Here are examples of how to force SSML usage: - - - - - -Vertex AI does not support passing a `model` param - so passing `model=vertex_ai/` is the only required param - - -```python -speech_file_path = Path(__file__).parent / "speech_vertex.mp3" - - -ssml = """ - -

    Hello, world!

    -

    This is a test of the text-to-speech API.

    -
    -""" - -response = litellm.speech( - input=ssml, - use_ssml=True, - model="vertex_ai/test", - voice={ - "languageCode": "en-UK", - "name": "en-UK-Studio-O", - }, - audioConfig={ - "audioEncoding": "LINEAR22", - "speakingRate": "10", - }, -) -response.stream_to_file(speech_file_path) -``` - -
    - - - -```python -import openai - -client = openai.OpenAI(api_key="sk-1234", base_url="http://0.0.0.0:4000") - -ssml = """ - -

    Hello, world!

    -

    This is a test of the text-to-speech API.

    -
    -""" - -# see supported values for "voice" on vertex here: -# https://console.cloud.google.com/vertex-ai/generative/speech/text-to-speech -response = client.audio.speech.create( - model = "vertex-tts", - input=ssml, # pass as None since OpenAI SDK requires this param - voice={'languageCode': 'en-US', 'name': 'en-US-Studio-O'}, - extra_body={"use_ssml": True}, -) -print("response from proxy", response) -``` - -
    -
    - ## **Fine Tuning APIs** diff --git a/docs/my-website/docs/providers/vertex_speech.md b/docs/my-website/docs/providers/vertex_speech.md new file mode 100644 index 00000000000..d0acacb5aec --- /dev/null +++ b/docs/my-website/docs/providers/vertex_speech.md @@ -0,0 +1,423 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# Vertex AI Text to Speech + +| Property | Details | +|-------|-------| +| Description | Google Cloud Text-to-Speech with Chirp3 HD voices and Gemini TTS | +| Provider Route on LiteLLM | `vertex_ai/chirp` (Chirp), `vertex_ai/gemini-*-tts` (Gemini) | + +## Chirp3 HD Voices + +Google Cloud Text-to-Speech API with high-quality Chirp3 HD voices. + +### Quick Start + +#### LiteLLM Python SDK + +```python showLineNumbers title="Chirp3 Quick Start" +from litellm import speech +from pathlib import Path + +speech_file_path = Path(__file__).parent / "speech.mp3" +response = speech( + model="vertex_ai/chirp", + voice="alloy", # OpenAI voice name - automatically mapped + input="Hello, this is Vertex AI Text to Speech", + vertex_project="your-project-id", + vertex_location="us-central1", +) +response.stream_to_file(speech_file_path) +``` + +#### LiteLLM AI Gateway + +**1. Setup config.yaml** + +```yaml showLineNumbers title="config.yaml" +model_list: + - model_name: vertex-tts + litellm_params: + model: vertex_ai/chirp + vertex_project: "your-project-id" + vertex_location: "us-central1" + vertex_credentials: "/path/to/service_account.json" +``` + +**2. Start the proxy** + +```bash title="Start LiteLLM Proxy" +litellm --config /path/to/config.yaml +``` + +**3. Make requests** + + + + +```bash showLineNumbers title="Chirp3 Quick Start" +curl http://0.0.0.0:4000/v1/audio/speech \ + -H "Authorization: Bearer sk-1234" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "vertex-tts", + "voice": "alloy", + "input": "Hello, this is Vertex AI Text to Speech" + }' \ + --output speech.mp3 +``` + + + + +```python showLineNumbers title="Chirp3 Quick Start" +import openai + +client = openai.OpenAI(api_key="sk-1234", base_url="http://0.0.0.0:4000") + +response = client.audio.speech.create( + model="vertex-tts", + voice="alloy", + input="Hello, this is Vertex AI Text to Speech", +) +response.stream_to_file("speech.mp3") +``` + + + + +### Voice Mapping + +LiteLLM maps OpenAI voice names to Google Cloud voices. You can use either OpenAI voices or Google Cloud voices directly. + +| OpenAI Voice | Google Cloud Voice | +|-------------|-------------------| +| `alloy` | en-US-Studio-O | +| `echo` | en-US-Studio-M | +| `fable` | en-GB-Studio-B | +| `onyx` | en-US-Wavenet-D | +| `nova` | en-US-Studio-O | +| `shimmer` | en-US-Wavenet-F | + +### Using Google Cloud Voices Directly + +#### LiteLLM Python SDK + +```python showLineNumbers title="Chirp3 HD Voice" +from litellm import speech + +# Pass Chirp3 HD voice name directly +response = speech( + model="vertex_ai/chirp", + voice="en-US-Chirp3-HD-Charon", + input="Hello with a Chirp3 HD voice", + vertex_project="your-project-id", +) +response.stream_to_file("speech.mp3") +``` + +```python showLineNumbers title="Voice as Dict (Multilingual)" +from litellm import speech + +# Pass as dict for full control over language and voice +response = speech( + model="vertex_ai/chirp", + voice={ + "languageCode": "de-DE", + "name": "de-DE-Chirp3-HD-Charon", + }, + input="Hallo, dies ist ein Test", + vertex_project="your-project-id", +) +response.stream_to_file("speech.mp3") +``` + +#### LiteLLM AI Gateway + + + + +```bash showLineNumbers title="Chirp3 HD Voice" +curl http://0.0.0.0:4000/v1/audio/speech \ + -H "Authorization: Bearer sk-1234" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "vertex-tts", + "voice": "en-US-Chirp3-HD-Charon", + "input": "Hello with a Chirp3 HD voice" + }' \ + --output speech.mp3 +``` + +```bash showLineNumbers title="Voice as Dict (Multilingual)" +curl http://0.0.0.0:4000/v1/audio/speech \ + -H "Authorization: Bearer sk-1234" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "vertex-tts", + "voice": {"languageCode": "de-DE", "name": "de-DE-Chirp3-HD-Charon"}, + "input": "Hallo, dies ist ein Test" + }' \ + --output speech.mp3 +``` + + + + +```python showLineNumbers title="Chirp3 HD Voice" +import openai + +client = openai.OpenAI(api_key="sk-1234", base_url="http://0.0.0.0:4000") + +response = client.audio.speech.create( + model="vertex-tts", + voice="en-US-Chirp3-HD-Charon", + input="Hello with a Chirp3 HD voice", +) +response.stream_to_file("speech.mp3") +``` + +```python showLineNumbers title="Voice as Dict (Multilingual)" +import openai + +client = openai.OpenAI(api_key="sk-1234", base_url="http://0.0.0.0:4000") + +response = client.audio.speech.create( + model="vertex-tts", + voice={"languageCode": "de-DE", "name": "de-DE-Chirp3-HD-Charon"}, + input="Hallo, dies ist ein Test", +) +response.stream_to_file("speech.mp3") +``` + + + + +Browse available voices: [Google Cloud Text-to-Speech Console](https://console.cloud.google.com/vertex-ai/generative/speech/text-to-speech) + +### Passing Raw SSML + +LiteLLM auto-detects SSML when your input contains `` tags and passes it through unchanged. + +#### LiteLLM Python SDK + +```python showLineNumbers title="SSML Input" +from litellm import speech + +ssml = """ + +

    Hello, world!

    +

    This is a test of the text-to-speech API.

    +
    +""" + +response = speech( + model="vertex_ai/chirp", + voice="en-US-Studio-O", + input=ssml, # Auto-detected as SSML + vertex_project="your-project-id", +) +response.stream_to_file("speech.mp3") +``` + +```python showLineNumbers title="Force SSML Mode" +from litellm import speech + +# Force SSML mode with use_ssml=True +response = speech( + model="vertex_ai/chirp", + voice="en-US-Studio-O", + input="Speaking slowly", + use_ssml=True, + vertex_project="your-project-id", +) +response.stream_to_file("speech.mp3") +``` + +#### LiteLLM AI Gateway + + + + +```bash showLineNumbers title="SSML Input" +curl http://0.0.0.0:4000/v1/audio/speech \ + -H "Authorization: Bearer sk-1234" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "vertex-tts", + "voice": "en-US-Studio-O", + "input": "

    Hello!

    How are you?

    " + }' \ + --output speech.mp3 +``` + +
    + + +```python showLineNumbers title="SSML Input" +import openai + +client = openai.OpenAI(api_key="sk-1234", base_url="http://0.0.0.0:4000") + +ssml = """

    Hello!

    How are you?

    """ + +response = client.audio.speech.create( + model="vertex-tts", + voice="en-US-Studio-O", + input=ssml, +) +response.stream_to_file("speech.mp3") +``` + +
    +
    + +### Supported Parameters + +| Parameter | Description | Values | +|-----------|-------------|--------| +| `voice` | Voice selection | OpenAI voice, Google Cloud voice name, or dict | +| `input` | Text to convert | Plain text or SSML | +| `speed` | Speaking rate | 0.25 to 4.0 (default: 1.0) | +| `response_format` | Audio format | `mp3`, `opus`, `wav`, `pcm`, `flac` | +| `use_ssml` | Force SSML mode | `True` / `False` | + +### Async Usage + +```python showLineNumbers title="Async Speech Generation" +import asyncio +from litellm import aspeech + +async def main(): + response = await aspeech( + model="vertex_ai/chirp", + voice="alloy", + input="Hello from async", + vertex_project="your-project-id", + ) + response.stream_to_file("speech.mp3") + +asyncio.run(main()) +``` + +--- + +## Gemini TTS + +Gemini models with audio output capabilities using the chat completions API. + +:::warning +**Limitations:** +- Only supports `pcm16` audio format +- Streaming not yet supported +- Must set `modalities: ["audio"]` +::: + +### Quick Start + +#### LiteLLM Python SDK + +```python showLineNumbers title="Gemini TTS Quick Start" +from litellm import completion +import json + +# Load credentials +with open('path/to/service_account.json', 'r') as file: + vertex_credentials = json.dumps(json.load(file)) + +response = completion( + model="vertex_ai/gemini-2.5-flash-preview-tts", + messages=[{"role": "user", "content": "Say hello in a friendly voice"}], + modalities=["audio"], + audio={ + "voice": "Kore", + "format": "pcm16" + }, + vertex_credentials=vertex_credentials +) +print(response) +``` + +#### LiteLLM AI Gateway + +**1. Setup config.yaml** + +```yaml showLineNumbers title="config.yaml" +model_list: + - model_name: gemini-tts + litellm_params: + model: vertex_ai/gemini-2.5-flash-preview-tts + vertex_project: "your-project-id" + vertex_location: "us-central1" + vertex_credentials: "/path/to/service_account.json" +``` + +**2. Start the proxy** + +```bash title="Start LiteLLM Proxy" +litellm --config /path/to/config.yaml +``` + +**3. Make requests** + + + + +```bash showLineNumbers title="Gemini TTS Request" +curl http://0.0.0.0:4000/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer sk-1234" \ + -d '{ + "model": "gemini-tts", + "messages": [{"role": "user", "content": "Say hello in a friendly voice"}], + "modalities": ["audio"], + "audio": {"voice": "Kore", "format": "pcm16"} + }' +``` + + + + +```python showLineNumbers title="Gemini TTS Request" +import openai + +client = openai.OpenAI(api_key="sk-1234", base_url="http://0.0.0.0:4000") + +response = client.chat.completions.create( + model="gemini-tts", + messages=[{"role": "user", "content": "Say hello in a friendly voice"}], + modalities=["audio"], + audio={"voice": "Kore", "format": "pcm16"}, +) +print(response) +``` + + + + +### Supported Models + +- `vertex_ai/gemini-2.5-flash-preview-tts` +- `vertex_ai/gemini-2.5-pro-preview-tts` + +See [Gemini TTS documentation](https://ai.google.dev/gemini-api/docs/speech-generation) for available voices. + +### Advanced Usage + +```python showLineNumbers title="Gemini TTS with System Prompt" +from litellm import completion + +response = completion( + model="vertex_ai/gemini-2.5-pro-preview-tts", + messages=[ + {"role": "system", "content": "You are a helpful assistant that speaks clearly."}, + {"role": "user", "content": "Explain quantum computing in simple terms"} + ], + modalities=["audio"], + audio={"voice": "Charon", "format": "pcm16"}, + temperature=0.7, + max_tokens=150, + vertex_credentials=vertex_credentials +) +``` diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index a4a2956ff6a..983816ed216 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -521,6 +521,7 @@ const sidebars = { "providers/vertex_partner", "providers/vertex_self_deployed", "providers/vertex_image", + "providers/vertex_speech", "providers/vertex_batch", "providers/vertex_ocr", ] diff --git a/litellm/llms/vertex_ai/text_to_speech/transformation.py b/litellm/llms/vertex_ai/text_to_speech/transformation.py new file mode 100644 index 00000000000..aff14b1004f --- /dev/null +++ b/litellm/llms/vertex_ai/text_to_speech/transformation.py @@ -0,0 +1,472 @@ +""" +Vertex AI Text-to-Speech transformation + +Maps OpenAI TTS spec to Google Cloud Text-to-Speech API +Reference: https://cloud.google.com/text-to-speech/docs/reference/rest/v1/text/synthesize +""" + +import base64 +from typing import TYPE_CHECKING, Any, Coroutine, Dict, Optional, Tuple, Union + +import httpx + +from litellm.llms.base_llm.text_to_speech.transformation import ( + BaseTextToSpeechConfig, + TextToSpeechRequestData, +) +from litellm.llms.vertex_ai.vertex_llm_base import VertexBase +from litellm.types.llms.vertex_ai import VERTEX_CREDENTIALS_TYPES +from litellm.types.llms.vertex_ai_text_to_speech import ( + VertexTextToSpeechAudioConfig, + VertexTextToSpeechInput, + VertexTextToSpeechVoice, +) + +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj + from litellm.types.llms.openai import HttpxBinaryResponseContent +else: + LiteLLMLoggingObj = Any + HttpxBinaryResponseContent = Any + + +class VertexAITextToSpeechConfig(BaseTextToSpeechConfig, VertexBase): + """ + Configuration for Google Cloud/Vertex AI Text-to-Speech + + Reference: https://cloud.google.com/text-to-speech/docs/reference/rest/v1/text/synthesize + """ + + # Default values + DEFAULT_LANGUAGE_CODE = "en-US" + DEFAULT_VOICE_NAME = "en-US-Studio-O" + DEFAULT_AUDIO_ENCODING = "LINEAR16" + DEFAULT_SPEAKING_RATE = "1" + + # API endpoint + TTS_API_URL = "https://texttospeech.googleapis.com/v1/text:synthesize" + + # Voice name mappings from OpenAI voices to Google Cloud voices + # Users can pass either: + # 1. OpenAI voice names (alloy, echo, fable, onyx, nova, shimmer) - will be mapped + # 2. Google Cloud/Vertex AI voice names (en-US-Studio-O, en-US-Wavenet-D, etc.) - used directly + VOICE_MAPPINGS = { + "alloy": "en-US-Studio-O", + "echo": "en-US-Studio-M", + "fable": "en-GB-Studio-B", + "onyx": "en-US-Wavenet-D", + "nova": "en-US-Studio-O", + "shimmer": "en-US-Wavenet-F", + } + + # Response format mappings from OpenAI to Google Cloud audio encoding + FORMAT_MAPPINGS = { + "mp3": "MP3", + "opus": "OGG_OPUS", + "aac": "MP3", # Google doesn't have AAC, use MP3 + "flac": "FLAC", + "wav": "LINEAR16", + "pcm": "LINEAR16", + } + + def __init__(self) -> None: + BaseTextToSpeechConfig.__init__(self) + VertexBase.__init__(self) + + def _map_voice_to_vertex_format( + self, + voice: Optional[Union[str, Dict]], + ) -> Tuple[Optional[str], Optional[Dict]]: + """ + Map voice to Vertex AI format. + + Supports both: + 1. OpenAI voice names (alloy, echo, fable, onyx, nova, shimmer) - will be mapped + 2. Vertex AI voice names (en-US-Studio-O, en-US-Wavenet-D, etc.) - used directly + 3. Dict with languageCode and name - used as-is + + Returns: + Tuple of (voice_str, voice_dict) where: + - voice_str: Original string voice (for interface compatibility) + - voice_dict: Vertex AI format dict with languageCode and name + """ + if voice is None: + return None, None + + if isinstance(voice, dict): + # Already in Vertex AI format + return None, voice + + # voice is a string + voice_str = voice + + # Map OpenAI voice if it's a known OpenAI voice, otherwise use directly + if voice in self.VOICE_MAPPINGS: + mapped_voice_name = self.VOICE_MAPPINGS[voice] + else: + # Assume it's already a Vertex AI voice name + mapped_voice_name = voice + + # Extract language code from voice name (e.g., "en-US-Studio-O" -> "en-US") + parts = mapped_voice_name.split("-") + if len(parts) >= 2: + language_code = f"{parts[0]}-{parts[1]}" + else: + language_code = self.DEFAULT_LANGUAGE_CODE + + voice_dict = { + "languageCode": language_code, + "name": mapped_voice_name, + } + + return voice_str, voice_dict + + def dispatch_text_to_speech( + self, + model: str, + input: str, + voice: Optional[Union[str, Dict]], + optional_params: Dict, + litellm_params_dict: Dict, + logging_obj: "LiteLLMLoggingObj", + timeout: Union[float, httpx.Timeout], + extra_headers: Optional[Dict[str, Any]], + base_llm_http_handler: Any, + aspeech: bool, + api_base: Optional[str], + api_key: Optional[str], + **kwargs: Any, + ) -> Union[ + "HttpxBinaryResponseContent", + Coroutine[Any, Any, "HttpxBinaryResponseContent"], + ]: + """ + Dispatch method to handle Vertex AI TTS requests + + This method encapsulates Vertex AI-specific credential resolution and parameter handling. + Voice mapping is handled in map_openai_params (similar to Azure AVA pattern). + + Args: + base_llm_http_handler: The BaseLLMHTTPHandler instance from main.py + """ + # Resolve Vertex AI credentials using VertexBase helpers + vertex_credentials = self.safe_get_vertex_ai_credentials(litellm_params_dict) + vertex_project = self.safe_get_vertex_ai_project(litellm_params_dict) + vertex_location = self.safe_get_vertex_ai_location(litellm_params_dict) + + # Convert voice to string if it's a dict (extract name) + # Actual voice mapping happens in map_openai_params + voice_str: Optional[str] = None + if isinstance(voice, str): + voice_str = voice + elif isinstance(voice, dict): + # Extract voice name from dict if needed + voice_str = voice.get("name") if voice else None + + # Store credentials in litellm_params for use in transform methods + litellm_params_dict.update({ + "vertex_credentials": vertex_credentials, + "vertex_project": vertex_project, + "vertex_location": vertex_location, + "api_base": api_base, + }) + + # Call the text_to_speech_handler + response = base_llm_http_handler.text_to_speech_handler( + model=model, + input=input, + voice=voice_str, + text_to_speech_provider_config=self, + text_to_speech_optional_params=optional_params, + custom_llm_provider="vertex_ai", + litellm_params=litellm_params_dict, + logging_obj=logging_obj, + timeout=timeout, + extra_headers=extra_headers, + client=None, + _is_async=aspeech, + ) + + return response + + def get_supported_openai_params(self, model: str) -> list: + """ + Vertex AI TTS supports these OpenAI parameters + + Note: Vertex AI also supports additional parameters like audioConfig + which can be passed but are not part of the OpenAI spec + """ + return ["voice", "response_format", "speed"] + + def map_openai_params( + self, + model: str, + optional_params: Dict, + voice: Optional[Union[str, Dict]] = None, + drop_params: bool = False, + kwargs: Dict = {}, + ) -> Tuple[Optional[str], Dict]: + """ + Map OpenAI parameters to Vertex AI TTS parameters + + Voice handling (similar to Azure AVA): + - If voice is an OpenAI voice name (alloy, echo, etc.), it maps to a Vertex AI voice + - If voice is already a Vertex AI voice name (en-US-Studio-O, etc.), it's used directly + - If voice is a dict with languageCode and name, it's used as-is + + Note: For Vertex AI, voice dict is stored in mapped_params["vertex_voice_dict"] + because the base class interface expects voice to be a string. + + Returns: + Tuple of (mapped_voice_str, mapped_params) + """ + mapped_params = {} + + ########################################################## + # Map voice using helper + ########################################################## + mapped_voice_str, voice_dict = self._map_voice_to_vertex_format(voice) + if voice_dict is not None: + mapped_params["vertex_voice_dict"] = voice_dict + + # Map response format + if "response_format" in optional_params: + format_name = optional_params["response_format"] + if format_name in self.FORMAT_MAPPINGS: + mapped_params["audioEncoding"] = self.FORMAT_MAPPINGS[format_name] + else: + # Try to use it directly as Google Cloud format + mapped_params["audioEncoding"] = format_name + else: + # Default to LINEAR16 + mapped_params["audioEncoding"] = self.DEFAULT_AUDIO_ENCODING + + # Map speed (OpenAI: 0.25-4.0, Vertex AI: speakingRate 0.25-4.0) + if "speed" in optional_params: + speed = optional_params["speed"] + if speed is not None: + mapped_params["speakingRate"] = str(speed) + + # Pass through Vertex AI-specific parameters from kwargs + if "audioConfig" in kwargs: + mapped_params["audioConfig"] = kwargs["audioConfig"] + + if "use_ssml" in kwargs: + mapped_params["use_ssml"] = kwargs["use_ssml"] + + return mapped_voice_str, mapped_params + + def validate_environment( + self, + headers: dict, + model: str, + api_key: Optional[str] = None, + api_base: Optional[str] = None, + ) -> dict: + """ + Validate Vertex AI environment and set up authentication headers + + Note: Actual authentication is handled in transform_text_to_speech_request + because Vertex AI requires OAuth2 token refresh + """ + validated_headers = headers.copy() + + # Content-Type for JSON + validated_headers["Content-Type"] = "application/json" + validated_headers["charset"] = "UTF-8" + + return validated_headers + + def get_complete_url( + self, + model: str, + api_base: Optional[str], + litellm_params: dict, + ) -> str: + """ + Get the complete URL for Vertex AI TTS request + + Google Cloud TTS endpoint: https://texttospeech.googleapis.com/v1/text:synthesize + """ + if api_base: + return api_base + + return self.TTS_API_URL + + def _validate_vertex_input( + self, + input_data: VertexTextToSpeechInput, + optional_params: Dict, + ) -> VertexTextToSpeechInput: + """ + Validate and transform input for Vertex AI TTS + + Handles text vs SSML input detection and validation + """ + # Remove None values + if input_data.get("text") is None: + input_data.pop("text", None) + if input_data.get("ssml") is None: + input_data.pop("ssml", None) + + # Check if use_ssml is set + use_ssml = optional_params.get("use_ssml", False) + + if use_ssml: + if "text" in input_data: + input_data["ssml"] = input_data.pop("text") + elif "ssml" not in input_data: + raise ValueError("SSML input is required when use_ssml is True.") + else: + # LiteLLM will auto-detect if text is in ssml format + # check if "text" is an ssml - in this case we should pass it as ssml instead of text + if input_data: + _text = input_data.get("text", None) or "" + if "" in _text: + input_data["ssml"] = input_data.pop("text") + + if not input_data: + raise ValueError("Either 'text' or 'ssml' must be provided.") + if "text" in input_data and "ssml" in input_data: + raise ValueError("Only one of 'text' or 'ssml' should be provided, not both.") + + return input_data + + def transform_text_to_speech_request( + self, + model: str, + input: str, + voice: Optional[str], + optional_params: Dict, + litellm_params: Dict, + headers: dict, + ) -> TextToSpeechRequestData: + """ + Transform OpenAI TTS request to Vertex AI TTS format + + This method handles: + 1. Authentication with Vertex AI + 2. Building the request body + 3. Setting up headers + + Returns: + TextToSpeechRequestData: Contains dict_body and headers + """ + # Get Vertex AI credentials from litellm_params + vertex_credentials: Optional[VERTEX_CREDENTIALS_TYPES] = litellm_params.get( + "vertex_credentials" + ) + vertex_project: Optional[str] = litellm_params.get("vertex_project") + + ####### Authenticate with Vertex AI ######## + _auth_header, vertex_project = self._ensure_access_token( + credentials=vertex_credentials, + project_id=vertex_project, + custom_llm_provider="vertex_ai_beta", + ) + + auth_header, _ = self._get_token_and_url( + model="", + auth_header=_auth_header, + gemini_api_key=None, + vertex_credentials=vertex_credentials, + vertex_project=vertex_project, + vertex_location=litellm_params.get("vertex_location"), + stream=False, + custom_llm_provider="vertex_ai_beta", + api_base=litellm_params.get("api_base"), + ) + + # Set authentication headers + headers["Authorization"] = f"Bearer {auth_header}" + headers["x-goog-user-project"] = vertex_project + + ####### Build the request ################ + vertex_input = VertexTextToSpeechInput(text=input) + vertex_input = self._validate_vertex_input(vertex_input, optional_params) + + # Build voice configuration + # Check for voice dict stored in: + # 1. litellm_params by dispatch method + # 2. optional_params by map_openai_params + voice_dict = ( + litellm_params.get("vertex_voice_dict") + or optional_params.get("vertex_voice_dict") + ) + if voice_dict is not None and isinstance(voice_dict, dict): + vertex_voice = VertexTextToSpeechVoice(**voice_dict) + elif voice is not None and isinstance(voice, str): + # Handle string voice (shouldn't normally happen if dispatch was called) + parts = voice.split("-") + if len(parts) >= 2: + language_code = f"{parts[0]}-{parts[1]}" + else: + language_code = self.DEFAULT_LANGUAGE_CODE + vertex_voice = VertexTextToSpeechVoice( + languageCode=language_code, + name=voice, + ) + else: + # Use defaults + vertex_voice = VertexTextToSpeechVoice( + languageCode=self.DEFAULT_LANGUAGE_CODE, + name=self.DEFAULT_VOICE_NAME, + ) + + # Build audio configuration + audio_encoding = optional_params.get("audioEncoding", self.DEFAULT_AUDIO_ENCODING) + speaking_rate = optional_params.get("speakingRate", self.DEFAULT_SPEAKING_RATE) + + # Check for full audioConfig in optional_params + if "audioConfig" in optional_params: + vertex_audio_config = VertexTextToSpeechAudioConfig(**optional_params["audioConfig"]) + else: + vertex_audio_config = VertexTextToSpeechAudioConfig( + audioEncoding=audio_encoding, + speakingRate=speaking_rate, + ) + + request_body: Dict[str, Any] = { + "input": dict(vertex_input), + "voice": dict(vertex_voice), + "audioConfig": dict(vertex_audio_config), + } + + return TextToSpeechRequestData( + dict_body=request_body, + headers=headers, + ) + + def transform_text_to_speech_response( + self, + model: str, + raw_response: httpx.Response, + logging_obj: "LiteLLMLoggingObj", + ) -> "HttpxBinaryResponseContent": + """ + Transform Vertex AI TTS response to standard format + + Vertex AI returns JSON with base64-encoded audio content. + We decode it and return as HttpxBinaryResponseContent. + """ + from litellm.types.llms.openai import HttpxBinaryResponseContent + + # Parse JSON response + _json_response = raw_response.json() + + # Get base64-encoded audio content + response_content = _json_response.get("audioContent") + if not response_content: + raise ValueError("No audioContent in Vertex AI TTS response") + + # Decode base64 to get binary content + binary_data = base64.b64decode(response_content) + + # Create an httpx.Response object with the binary data + response = httpx.Response( + status_code=200, + content=binary_data, + ) + + # Initialize the HttpxBinaryResponseContent instance + return HttpxBinaryResponseContent(response) diff --git a/litellm/main.py b/litellm/main.py index a09a9453017..fa9172632cf 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -206,7 +206,6 @@ from .llms.vertex_ai.image_generation.image_generation_handler import ( from .llms.vertex_ai.multimodal_embeddings.embedding_handler import ( VertexMultimodalEmbedding, ) -from .llms.vertex_ai.text_to_speech.text_to_speech_handler import VertexTextToSpeechAPI from .llms.vertex_ai.vertex_ai_partner_models.main import VertexAIPartnerModels from .llms.vertex_ai.vertex_embeddings.embedding_handler import VertexEmbedding from .llms.vertex_ai.vertex_gemma_models.main import VertexAIGemmaModels @@ -277,7 +276,7 @@ google_batch_embeddings = GoogleBatchEmbeddings() vertex_partner_models_chat_completion = VertexAIPartnerModels() vertex_gemma_chat_completion = VertexAIGemmaModels() vertex_model_garden_chat_completion = VertexAIModelGardenModels() -vertex_text_to_speech = VertexTextToSpeechAPI() +# vertex_text_to_speech is now replaced by VertexAITextToSpeechConfig sagemaker_llm = SagemakerLLM() watsonx_chat_completion = WatsonXChatHandler() openai_like_embedding = OpenAILikeEmbeddingHandler() @@ -6145,30 +6144,13 @@ def speech( # noqa: PLR0915 _is_async=aspeech or False, ) elif custom_llm_provider == "vertex_ai" or custom_llm_provider == "vertex_ai_beta": + from litellm.llms.vertex_ai.text_to_speech.transformation import ( + VertexAITextToSpeechConfig, + ) + generic_optional_params = GenericLiteLLMParams(**kwargs) - api_base = generic_optional_params.api_base or "" - vertex_ai_project = ( - generic_optional_params.vertex_project - or litellm.vertex_project - or get_secret_str("VERTEXAI_PROJECT") - ) - vertex_ai_location = ( - generic_optional_params.vertex_location - or litellm.vertex_location - or get_secret_str("VERTEXAI_LOCATION") - ) - vertex_credentials = ( - generic_optional_params.vertex_credentials - or get_secret_str("VERTEXAI_CREDENTIALS") - ) - - if voice is not None and not isinstance(voice, dict): - raise litellm.BadRequestError( - message=f"'voice' is required to be passed as a dict for Vertex AI TTS, passed in voice={voice}", - model=model, - llm_provider=custom_llm_provider, - ) + # Handle Gemini models separately (they use speech_to_completion_bridge) if "gemini" in model: from .endpoints.speech.speech_to_completion_bridge.handler import ( speech_to_completion_bridge_handler, @@ -6184,19 +6166,37 @@ def speech( # noqa: PLR0915 logging_obj=logging_obj, custom_llm_provider=custom_llm_provider, ) - response = vertex_text_to_speech.audio_speech( - _is_async=aspeech, - vertex_credentials=vertex_credentials, - vertex_project=vertex_ai_project, - vertex_location=vertex_ai_location, - timeout=timeout, - api_base=api_base, + + # Vertex AI Text-to-Speech (Google Cloud TTS) + if text_to_speech_provider_config is None: + text_to_speech_provider_config = VertexAITextToSpeechConfig() + + # Cast to specific Vertex AI config type to access dispatch method + vertex_config = cast( + VertexAITextToSpeechConfig, text_to_speech_provider_config + ) + + # Store Vertex AI specific params in litellm_params_dict + litellm_params_dict.update({ + "vertex_project": generic_optional_params.vertex_project, + "vertex_location": generic_optional_params.vertex_location, + "vertex_credentials": generic_optional_params.vertex_credentials, + }) + + response = vertex_config.dispatch_text_to_speech( model=model, input=input, voice=voice, optional_params=optional_params, - kwargs=kwargs, + litellm_params_dict=litellm_params_dict, logging_obj=logging_obj, + timeout=timeout, + extra_headers=headers, + base_llm_http_handler=base_llm_http_handler, + aspeech=aspeech or False, + api_base=generic_optional_params.api_base, + api_key=None, # Vertex AI uses OAuth, not API key + **kwargs, ) elif custom_llm_provider == "gemini": from .endpoints.speech.speech_to_completion_bridge.handler import ( diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index f28e9b1290f..f3398e470d8 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -24825,6 +24825,15 @@ "supports_parallel_function_calling": true, "supports_tool_choice": true }, + "vertex_ai/chirp": { + "input_cost_per_character": 30e-06, + "litellm_provider": "vertex_ai", + "mode": "audio_speech", + "source": "https://cloud.google.com/text-to-speech/pricing", + "supported_endpoints": [ + "/v1/audio/speech" + ] + }, "vertex_ai/claude-3-5-haiku": { "input_cost_per_token": 1e-06, "litellm_provider": "vertex_ai-anthropic_models", diff --git a/litellm/types/llms/vertex_ai_text_to_speech.py b/litellm/types/llms/vertex_ai_text_to_speech.py new file mode 100644 index 00000000000..e65b75356bf --- /dev/null +++ b/litellm/types/llms/vertex_ai_text_to_speech.py @@ -0,0 +1,54 @@ +""" +Type definitions for Vertex AI Text-to-Speech API + +Reference: https://cloud.google.com/text-to-speech/docs/reference/rest/v1/text/synthesize +""" + +from typing import Optional + +from typing_extensions import TypedDict + + +class VertexTextToSpeechInput(TypedDict, total=False): + """ + Input for Vertex AI Text-to-Speech synthesis. + + Exactly one of text or ssml must be provided. + """ + text: Optional[str] + ssml: Optional[str] + + +class VertexTextToSpeechVoice(TypedDict, total=False): + """ + Voice configuration for Vertex AI Text-to-Speech. + + Attributes: + languageCode: The language code (e.g., "en-US", "de-DE") + name: The voice name (e.g., "en-US-Studio-O", "en-US-Wavenet-D") + """ + languageCode: str + name: str + + +class VertexTextToSpeechAudioConfig(TypedDict, total=False): + """ + Audio configuration for Vertex AI Text-to-Speech. + + Attributes: + audioEncoding: The audio encoding format (e.g., "LINEAR16", "MP3", "OGG_OPUS") + speakingRate: The speaking rate (0.25 to 4.0, default "1") + """ + audioEncoding: str + speakingRate: str + + +class VertexTextToSpeechRequest(TypedDict, total=False): + """ + Request body for Vertex AI Text-to-Speech API. + + Reference: https://cloud.google.com/text-to-speech/docs/reference/rest/v1/text/synthesize + """ + input: VertexTextToSpeechInput + voice: VertexTextToSpeechVoice + audioConfig: Optional[VertexTextToSpeechAudioConfig] diff --git a/litellm/utils.py b/litellm/utils.py index 6c50afc5f49..3775506e248 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -7910,6 +7910,12 @@ class ProviderConfigManager: ) return RunwayMLTextToSpeechConfig() + elif litellm.LlmProviders.VERTEX_AI == provider: + from litellm.llms.vertex_ai.text_to_speech.transformation import ( + VertexAITextToSpeechConfig, + ) + + return VertexAITextToSpeechConfig() return None @staticmethod diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index f28e9b1290f..f3398e470d8 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -24825,6 +24825,15 @@ "supports_parallel_function_calling": true, "supports_tool_choice": true }, + "vertex_ai/chirp": { + "input_cost_per_character": 30e-06, + "litellm_provider": "vertex_ai", + "mode": "audio_speech", + "source": "https://cloud.google.com/text-to-speech/pricing", + "supported_endpoints": [ + "/v1/audio/speech" + ] + }, "vertex_ai/claude-3-5-haiku": { "input_cost_per_token": 1e-06, "litellm_provider": "vertex_ai-anthropic_models", diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index b5bde3e5ce4..441391dc742 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -734,6 +734,22 @@ "ocr": true } }, + "vertex_ai/chirp": { + "display_name": "Google - Vertex AI Chirp3 HD (`vertex_ai/chirp`)", + "url": "https://docs.litellm.ai/docs/providers/vertex_speech", + "endpoints": { + "chat_completions": false, + "messages": false, + "responses": false, + "embeddings": false, + "image_generations": false, + "audio_transcriptions": false, + "audio_speech": true, + "moderations": false, + "batches": false, + "rerank": false + } + }, "gemini": { "display_name": "Google AI Studio - Gemini (`gemini`)", "url": "https://docs.litellm.ai/docs/providers/gemini", diff --git a/tests/audio_tests/speech_vertex.mp3 b/tests/audio_tests/speech_vertex.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..91efaea150caa267f9f54ae56caed2c719dd1992 GIT binary patch literal 122924 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 l`z2)l`SgN#7i}u-K}Bg{{^?-@)rOA literal 0 HcmV?d00001 diff --git a/tests/litellm/llms/vertex_ai/text_to_speech/test_transformation.py b/tests/litellm/llms/vertex_ai/text_to_speech/test_transformation.py new file mode 100644 index 00000000000..9a5888e8506 --- /dev/null +++ b/tests/litellm/llms/vertex_ai/text_to_speech/test_transformation.py @@ -0,0 +1,190 @@ +import json +import os +import sys +from unittest.mock import MagicMock, Mock, patch + +import httpx +import pytest + +sys.path.insert( + 0, os.path.abspath("../../../../..") +) # Adds the parent directory to the system path + +import litellm +from litellm.llms.vertex_ai.text_to_speech.transformation import ( + VertexAITextToSpeechConfig, +) + + +class TestVertexAITextToSpeechConfig: + """Tests for VertexAITextToSpeechConfig transformation""" + + def test_get_complete_url(self): + """Test that get_complete_url returns the correct Google Cloud TTS API URL""" + config = VertexAITextToSpeechConfig() + + url = config.get_complete_url( + model="vertex_ai/chirp", + api_base=None, + litellm_params={}, + ) + + assert url == "https://texttospeech.googleapis.com/v1/text:synthesize" + + def test_get_complete_url_with_custom_api_base(self): + """Test that get_complete_url uses custom api_base when provided""" + config = VertexAITextToSpeechConfig() + + custom_url = "https://custom-tts-endpoint.example.com/v1/synthesize" + url = config.get_complete_url( + model="vertex_ai/chirp", + api_base=custom_url, + litellm_params={}, + ) + + assert url == custom_url + + @patch.object(VertexAITextToSpeechConfig, "_ensure_access_token") + @patch.object(VertexAITextToSpeechConfig, "_get_token_and_url") + def test_transform_text_to_speech_request_body( + self, mock_get_token, mock_ensure_token + ): + """Test that transform_text_to_speech_request generates correct request body""" + # Mock authentication + mock_ensure_token.return_value = ("mock-token", "test-project") + mock_get_token.return_value = ("mock-token", "mock-url") + + config = VertexAITextToSpeechConfig() + + # Test with voice dict in litellm_params (as set by dispatch) + result = config.transform_text_to_speech_request( + model="vertex_ai/chirp", + input="Hello, this is a test", + voice=None, + optional_params={ + "vertex_voice_dict": { + "languageCode": "en-US", + "name": "en-US-Chirp3-HD-Charon", + } + }, + litellm_params={ + "vertex_credentials": None, + "vertex_project": "test-project", + "vertex_location": "us-central1", + }, + headers={}, + ) + + # Verify request body structure + assert "dict_body" in result + request_body = result["dict_body"] + + assert "input" in request_body + assert request_body["input"] == {"text": "Hello, this is a test"} + + assert "voice" in request_body + assert request_body["voice"]["languageCode"] == "en-US" + assert request_body["voice"]["name"] == "en-US-Chirp3-HD-Charon" + + assert "audioConfig" in request_body + + # Verify headers contain auth + assert "headers" in result + assert "Authorization" in result["headers"] + + def test_voice_mapping_openai_to_vertex(self): + """Test that OpenAI voice names are correctly mapped to Vertex AI voices""" + config = VertexAITextToSpeechConfig() + + # Test the _map_voice_to_vertex_format helper + voice_str, voice_dict = config._map_voice_to_vertex_format("alloy") + + assert voice_str == "alloy" + assert voice_dict is not None + assert voice_dict["name"] == "en-US-Studio-O" + assert voice_dict["languageCode"] == "en-US" + + def test_voice_mapping_vertex_voice_passthrough(self): + """Test that Vertex AI voice names are passed through directly""" + config = VertexAITextToSpeechConfig() + + # Test with a Chirp3 HD voice + voice_str, voice_dict = config._map_voice_to_vertex_format( + "en-US-Chirp3-HD-Charon" + ) + + assert voice_str == "en-US-Chirp3-HD-Charon" + assert voice_dict is not None + assert voice_dict["name"] == "en-US-Chirp3-HD-Charon" + assert voice_dict["languageCode"] == "en-US" + + def test_voice_mapping_dict_passthrough(self): + """Test that voice dict is passed through unchanged""" + config = VertexAITextToSpeechConfig() + + voice_input = { + "languageCode": "de-DE", + "name": "de-DE-Chirp3-HD-Charon", + } + voice_str, voice_dict = config._map_voice_to_vertex_format(voice_input) + + assert voice_str is None + assert voice_dict == voice_input + + +@patch("litellm.llms.custom_httpx.llm_http_handler.HTTPHandler.post") +@patch.object(VertexAITextToSpeechConfig, "_ensure_access_token") +@patch.object(VertexAITextToSpeechConfig, "_get_token_and_url") +def test_litellm_speech_vertex_ai_chirp(mock_get_token, mock_ensure_token, mock_post): + """ + Test that litellm.speech(model="vertex_ai/chirp") sends the correct URL and request body + """ + # Mock authentication + mock_ensure_token.return_value = ("mock-token", "test-project") + mock_get_token.return_value = ("mock-token", "mock-url") + + # Mock HTTP response + mock_response = Mock(spec=httpx.Response) + mock_response.content = b'{"audioContent": "SGVsbG8gV29ybGQ="}' # base64 encoded "Hello World" + mock_response.status_code = 200 + mock_response.headers = {"content-type": "application/json"} + mock_response.json.return_value = {"audioContent": "SGVsbG8gV29ybGQ="} + mock_post.return_value = mock_response + + litellm.speech( + model="vertex_ai/chirp", + input="Hello, this is a test", + voice="en-US-Chirp3-HD-Charon", + vertex_project="test-project", + vertex_location="us-central1", + ) + + # Verify the HTTP call was made + mock_post.assert_called_once() + call_kwargs = mock_post.call_args.kwargs + + # Verify the URL is the Google Cloud TTS API + assert call_kwargs["url"] == "https://texttospeech.googleapis.com/v1/text:synthesize" + + # Verify request body structure + assert "data" in call_kwargs + request_body = json.loads(call_kwargs["data"]) + + # Verify input + assert "input" in request_body + assert request_body["input"] == {"text": "Hello, this is a test"} + + # Verify voice + assert "voice" in request_body + assert request_body["voice"]["name"] == "en-US-Chirp3-HD-Charon" + assert request_body["voice"]["languageCode"] == "en-US" + + # Verify audioConfig + assert "audioConfig" in request_body + + # Verify headers contain authorization + assert "headers" in call_kwargs + assert "Authorization" in call_kwargs["headers"] + assert call_kwargs["headers"]["Authorization"] == "Bearer mock-token" + + From 7fb2f4730b2fd0eef4269e1ce711b5dbfa2be88e Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Tue, 2 Dec 2025 15:53:10 -0800 Subject: [PATCH 09/37] build: remove duplicate packages --- docker/Dockerfile.non_root | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docker/Dockerfile.non_root b/docker/Dockerfile.non_root index 2dcb7cb4787..4616ff9be60 100644 --- a/docker/Dockerfile.non_root +++ b/docker/Dockerfile.non_root @@ -10,7 +10,17 @@ WORKDIR /app # Install build dependencies including Node.js for UI build USER root -RUN apk add --no-cache build-base bash nodejs npm \ +RUN apk add --no-cache \ + clang \ + llvm \ + lld \ + gcc \ + python3-dev \ + linux-headers \ + build-base \ + bash \ + nodejs \ + npm \ && pip install --no-cache-dir --upgrade pip build # Copy project files From 9867cc6cf98422bb29aa326ec75a1c5068facce1 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 2 Dec 2025 15:58:27 -0800 Subject: [PATCH 10/37] Change edit team models to match create team models --- .../src/components/team/team_info.test.tsx | 115 +++++++++++++++++- .../src/components/team/team_info.tsx | 101 +++++++++++++-- 2 files changed, 202 insertions(+), 14 deletions(-) diff --git a/ui/litellm-dashboard/src/components/team/team_info.test.tsx b/ui/litellm-dashboard/src/components/team/team_info.test.tsx index 54362193350..3a87b42d253 100644 --- a/ui/litellm-dashboard/src/components/team/team_info.test.tsx +++ b/ui/litellm-dashboard/src/components/team/team_info.test.tsx @@ -13,6 +13,7 @@ vi.mock("@/components/networking", () => ({ getGuardrailsList: vi.fn(), fetchMCPAccessGroups: vi.fn(), getTeamPermissionsCall: vi.fn(), + organizationInfoCall: vi.fn(), })); describe("TeamInfoView", () => { @@ -161,6 +162,116 @@ describe("TeamInfoView", () => { const allProxyModelsOption = screen.queryByText("All Proxy Models"); expect(allProxyModelsOption).not.toBeInTheDocument(); - }, // This is a workaround to fix the flaky test issue. TODO: Remove this once we have a better solution. - 10000); + }, 10000); // This is a workaround to fix the flaky test issue. TODO: Remove this once we have a better solution. + + it("should only show organization models in dropdown when team is in organization with limited models", async () => { + const organizationId = "org-123"; + const organizationModels = ["gpt-4", "claude-3-opus"]; + const userModels = ["gpt-4", "gpt-3.5-turbo", "claude-3-opus", "claude-2"]; + + vi.mocked(networking.teamInfoCall).mockResolvedValue({ + team_id: "123", + team_info: { + team_alias: "Test Team", + team_id: "123", + organization_id: organizationId, + admins: ["admin@test.com"], + members: ["user1@test.com"], + members_with_roles: [ + { + user_id: "user1@test.com", + user_email: "user1@test.com", + role: "member", + spend: 0, + budget_id: "budget1", + }, + ], + metadata: {}, + tpm_limit: null, + rpm_limit: null, + max_budget: null, + budget_duration: null, + models: ["gpt-4"], + blocked: false, + spend: 0, + max_parallel_requests: null, + budget_reset_at: null, + model_id: null, + litellm_model_table: null, + created_at: "2024-01-01T00:00:00Z", + team_member_budget_table: null, + }, + keys: [], + team_memberships: [], + }); + + vi.mocked(networking.organizationInfoCall).mockResolvedValue({ + organization_id: organizationId, + organization_name: "Test Organization", + spend: 0, + max_budget: null, + models: organizationModels, + tpm_limit: null, + rpm_limit: null, + members: null, + }); + + vi.mocked(networking.getGuardrailsList).mockResolvedValue({ guardrails: [] }); + vi.mocked(networking.fetchMCPAccessGroups).mockResolvedValue([]); + + render( + {}} + onClose={() => {}} + accessToken="123" + is_team_admin={true} + is_proxy_admin={true} + userModels={userModels} + editTeam={false} + premiumUser={false} + />, + ); + + await waitFor(() => { + expect(screen.getAllByText("Test Team")).not.toBeNull(); + }); + + const settingsTab = screen.getByRole("tab", { name: "Settings" }); + act(() => { + fireEvent.click(settingsTab); + }); + + await waitFor(() => { + expect(screen.getByText("Team Settings")).toBeInTheDocument(); + }); + + const editButton = screen.getByRole("button", { name: "Edit Settings" }); + act(() => { + fireEvent.click(editButton); + }); + + await waitFor(() => { + expect(screen.getByLabelText("Models")).toBeInTheDocument(); + }); + + const modelsSelect = screen.getByLabelText("Models"); + act(() => { + fireEvent.mouseDown(modelsSelect); + }); + + await waitFor(() => { + const dropdownOptions = screen.getAllByRole("option"); + const optionTexts = dropdownOptions.map((option) => option.textContent); + + organizationModels.forEach((model) => { + expect(optionTexts).toContain(model); + }); + + const modelsNotInOrganization = userModels.filter((m) => !organizationModels.includes(m)); + modelsNotInOrganization.forEach((model) => { + expect(optionTexts).not.toContain(model); + }); + }); + }, 10000); }); diff --git a/ui/litellm-dashboard/src/components/team/team_info.tsx b/ui/litellm-dashboard/src/components/team/team_info.tsx index a53d35e57bb..581722e8fa7 100644 --- a/ui/litellm-dashboard/src/components/team/team_info.tsx +++ b/ui/litellm-dashboard/src/components/team/team_info.tsx @@ -2,6 +2,8 @@ import UserSearchModal from "@/components/common_components/user_search_modal"; import { getGuardrailsList, Member, + Organization, + organizationInfoCall, teamInfoCall, teamMemberAddCall, teamMemberDeleteCall, @@ -28,11 +30,11 @@ import { } from "@tremor/react"; import { Button, Form, Input, message, Select, Switch, Tooltip } from "antd"; import { CheckIcon, CopyIcon } from "lucide-react"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { copyToClipboard as utilCopyToClipboard } from "../../utils/dataUtils"; import DeleteResourceModal from "../common_components/DeleteResourceModal"; import PassThroughRoutesSelector from "../common_components/PassThroughRoutesSelector"; -import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key"; +import { getModelDisplayName, unfurlWildcardModelsInList } from "../key_team_helpers/fetch_available_models_team_key"; import LoggingSettingsView from "../logging_settings_view"; import MCPServerSelector from "../mcp_server_management/MCPServerSelector"; import MCPToolPermissions from "../mcp_server_management/MCPToolPermissions"; @@ -117,6 +119,29 @@ export interface TeamInfoProps { premiumUser?: boolean; } +const getOrganizationModels = (organization: Organization | null, userModels: string[]) => { + let tempModelsToPick = []; + + if (organization) { + // Check if organization has "all-proxy-models" in its models array + if (organization.models.includes("all-proxy-models")) { + // Treat as all-proxy-models (use userModels) + tempModelsToPick = userModels; + } else if (organization.models.length > 0) { + // Organization has specific models + tempModelsToPick = organization.models; + } else { + // Empty array [] is treated as all-proxy-models + tempModelsToPick = userModels; + } + } else { + // No organization, show all available models + tempModelsToPick = userModels; + } + + return unfurlWildcardModelsInList(tempModelsToPick, userModels); +}; + const TeamInfoView: React.FC = ({ teamId, onClose, @@ -143,6 +168,7 @@ const TeamInfoView: React.FC = ({ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const [isTeamSaving, setIsTeamSaving] = useState(false); + const [organization, setOrganization] = useState(null); console.log("userModels in team info", userModels); @@ -166,6 +192,31 @@ const TeamInfoView: React.FC = ({ fetchTeamInfo(); }, [teamId, accessToken]); + // Fetch organization data when team has organization_id + useEffect(() => { + const fetchOrganization = async () => { + if (!accessToken || !teamData?.team_info?.organization_id) { + setOrganization(null); + return; + } + + try { + const orgData = await organizationInfoCall(accessToken, teamData.team_info.organization_id); + setOrganization(orgData); + } catch (error) { + console.error("Error fetching organization info:", error); + setOrganization(null); + } + }; + + fetchOrganization(); + }, [accessToken, teamData?.team_info?.organization_id]); + + // Compute modelsToPick based on organization and userModels + const modelsToPick = useMemo(() => { + return getOrganizationModels(organization, userModels); + }, [organization, userModels]); + const fetchMcpAccessGroups = async () => { if (!accessToken) return; if (mcpAccessGroupsLoaded) return; @@ -596,15 +647,41 @@ const TeamInfoView: React.FC = ({ rules={[{ required: true, message: "Please select at least one model" }]} > + From 8ee298f9c9c178ccc7191ebfbf63736898557443 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Tue, 2 Dec 2025 16:06:06 -0800 Subject: [PATCH 11/37] fix: remove python3 headers --- docker/Dockerfile.non_root | 1 - 1 file changed, 1 deletion(-) diff --git a/docker/Dockerfile.non_root b/docker/Dockerfile.non_root index 4616ff9be60..8b66a367eeb 100644 --- a/docker/Dockerfile.non_root +++ b/docker/Dockerfile.non_root @@ -15,7 +15,6 @@ RUN apk add --no-cache \ llvm \ lld \ gcc \ - python3-dev \ linux-headers \ build-base \ bash \ From 10d56e7c4682c868c9c11483d21f77abc3d042f5 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Tue, 2 Dec 2025 16:19:34 -0800 Subject: [PATCH 12/37] add new deepseek-v3p2 (#17395) --- litellm/model_prices_and_context_window_backup.json | 13 +++++++++++++ model_prices_and_context_window.json | 13 +++++++++++++ 2 files changed, 26 insertions(+) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index f3398e470d8..932508824af 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -10421,6 +10421,19 @@ "supports_response_schema": true, "supports_tool_choice": true }, + "fireworks_ai/accounts/fireworks/models/deepseek-v3p2": { + "input_cost_per_token": 1.2e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 163840, + "max_output_tokens": 163840, + "max_tokens": 163840, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://fireworks.ai/models/fireworks/deepseek-v3p2", + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, "fireworks_ai/accounts/fireworks/models/firefunction-v2": { "input_cost_per_token": 9e-07, "litellm_provider": "fireworks_ai", diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index f3398e470d8..932508824af 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -10421,6 +10421,19 @@ "supports_response_schema": true, "supports_tool_choice": true }, + "fireworks_ai/accounts/fireworks/models/deepseek-v3p2": { + "input_cost_per_token": 1.2e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 163840, + "max_output_tokens": 163840, + "max_tokens": 163840, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://fireworks.ai/models/fireworks/deepseek-v3p2", + "supports_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, "fireworks_ai/accounts/fireworks/models/firefunction-v2": { "input_cost_per_token": 9e-07, "litellm_provider": "fireworks_ai", From 31cad8e6e63a9de464c1dbc3d5635a7fcc4962f5 Mon Sep 17 00:00:00 2001 From: flozonn <74357383+flozonn@users.noreply.github.com> Date: Wed, 3 Dec 2025 01:33:07 +0100 Subject: [PATCH 13/37] feat: Add Nova lite 2 reasoning support with reasoningConfig (#17371) --- litellm/constants.py | 3 + .../bedrock/chat/converse_transformation.py | 102 ++- model_prices_and_context_window.json | 65 ++ .../chat/test_converse_transformation.py | 33 + .../test_converse_transformation_nova_2.py | 794 ++++++++++++++++++ 5 files changed, 995 insertions(+), 2 deletions(-) create mode 100644 tests/test_litellm/llms/bedrock/chat/test_converse_transformation_nova_2.py diff --git a/litellm/constants.py b/litellm/constants.py index 1d42ef9a910..6a67a9a0e18 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -909,6 +909,9 @@ BEDROCK_CONVERSE_MODELS = [ "meta.llama3-2-3b-instruct-v1:0", "meta.llama3-2-11b-instruct-v1:0", "meta.llama3-2-90b-instruct-v1:0", + "amazon.nova-lite-v1:0", + "amazon.nova-2-lite-v1:0", + "amazon.nova-pro-v1:0", ] diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index 3b3a138ec67..705f3c9e630 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -246,6 +246,93 @@ class AmazonConverseConfig(BaseConfig): llm_provider="bedrock", ) + def _is_nova_lite_2_model(self, model: str) -> bool: + """ + Check if the model is a Nova Lite 2 model that supports reasoningConfig. + + Nova Lite 2 models use a different reasoning configuration structure compared to + Anthropic's thinking parameter and GPT-OSS's reasoning_effort parameter. + + Supported models: + - amazon.nova-2-lite-v1:0 + - us.amazon.nova-2-lite-v1:0 + - eu.amazon.nova-2-lite-v1:0 + - apac.amazon.nova-2-lite-v1:0 + + Args: + model: The model identifier + + Returns: + True if the model is a Nova Lite 2 model, False otherwise + + Examples: + >>> config = AmazonConverseConfig() + >>> config._is_nova_lite_2_model("amazon.nova-2-lite-v1:0") + True + >>> config._is_nova_lite_2_model("us.amazon.nova-2-lite-v1:0") + True + >>> config._is_nova_lite_2_model("amazon.nova-pro-1-5-v1:0") + False + >>> config._is_nova_lite_2_model("amazon.nova-pro-v1:0") + False + """ + # Remove regional prefix if present (us., eu., apac.) + model_without_region = model + for prefix in ["us.", "eu.", "apac."]: + if model.startswith(prefix): + model_without_region = model[len(prefix) :] + break + + # Check if the model is specifically Nova Lite 2 + return "nova-2-lite" in model_without_region + + def _transform_reasoning_effort_to_reasoning_config( + self, reasoning_effort: str + ) -> dict: + """ + Transform reasoning_effort parameter to Nova 2 reasoningConfig structure. + + Nova 2 models use a reasoningConfig structure in additionalModelRequestFields + that differs from both Anthropic's thinking parameter and GPT-OSS's reasoning_effort. + + Args: + reasoning_effort: The reasoning effort level, must be "low" or "high" + + Returns: + dict: A dictionary containing the reasoningConfig structure: + { + "reasoningConfig": { + "type": "enabled", + "maxReasoningEffort": "low" | "medium" |"high" + } + } + + Raises: + BadRequestError: If reasoning_effort is not "low", "medium" or "high" + + Examples: + >>> config = AmazonConverseConfig() + >>> config._transform_reasoning_effort_to_reasoning_config("high") + {'reasoningConfig': {'type': 'enabled', 'maxReasoningEffort': 'high'}} + >>> config._transform_reasoning_effort_to_reasoning_config("low") + {'reasoningConfig': {'type': 'enabled', 'maxReasoningEffort': 'low'}} + """ + valid_values = ["low", "medium", "high"] + if reasoning_effort not in valid_values: + raise litellm.exceptions.BadRequestError( + message=f"Invalid reasoning_effort value '{reasoning_effort}' for Nova 2 models. " + f"Supported values: {valid_values}", + model="amazon.nova-2-lite-v1:0", + llm_provider="bedrock_converse", + ) + + return { + "reasoningConfig": { + "type": "enabled", + "maxReasoningEffort": reasoning_effort, + } + } + def get_supported_openai_params(self, model: str) -> List[str]: from litellm.utils import supports_function_calling @@ -299,6 +386,10 @@ class AmazonConverseConfig(BaseConfig): if "gpt-oss" in model: supported_params.append("reasoning_effort") + elif self._is_nova_lite_2_model(model): + # Nova Lite 2 models support reasoning_effort (transformed to reasoningConfig) + # These models use a different reasoning structure than Anthropic's thinking parameter + supported_params.append("reasoning_effort") elif ( "claude-3-7" in model or "claude-sonnet-4" in model @@ -564,6 +655,12 @@ class AmazonConverseConfig(BaseConfig): # GPT-OSS models: keep reasoning_effort as-is # It will be passed through to additionalModelRequestFields optional_params["reasoning_effort"] = value + elif self._is_nova_lite_2_model(model): + # Nova Lite 2 models: transform to reasoningConfig + reasoning_config = ( + self._transform_reasoning_effort_to_reasoning_config(value) + ) + optional_params.update(reasoning_config) else: # Anthropic and other models: convert to thinking parameter optional_params["thinking"] = AnthropicConfig._map_reasoning_effort( @@ -574,8 +671,9 @@ class AmazonConverseConfig(BaseConfig): self._validate_request_metadata(value) # type: ignore optional_params["requestMetadata"] = value - # Only update thinking tokens for non-GPT-OSS models - if "gpt-oss" not in model: + # Only update thinking tokens for non-GPT-OSS models and non-Nova-Lite-2 models + # Nova Lite 2 handles token budgeting differently through reasoningConfig + if "gpt-oss" not in model and not self._is_nova_lite_2_model(model): self.update_optional_params_with_thinking_tokens( non_default_params=non_default_params, optional_params=optional_params ) diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 932508824af..19ed734c5f8 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -269,6 +269,71 @@ "supports_response_schema": true, "supports_vision": true }, + "amazon.nova-2-lite-v1:0": { + "input_cost_per_token": 3e-07, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 2.5e-06, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_video_input": true, + "supports_vision": true + }, + "apac.amazon.nova-2-lite-v1:0": { + "input_cost_per_token": 6e-08, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 2.75e-06, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_video_input": true, + "supports_vision": true + }, + "eu.amazon.nova-2-lite-v1:0": { + "input_cost_per_token": 6e-08, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 2.75e-06, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_video_input": true, + "supports_vision": true + }, + "us.amazon.nova-2-lite-v1:0": { + "input_cost_per_token": 6e-08, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 2.75e-06, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_video_input": true, + "supports_vision": true + }, + "amazon.nova-micro-v1:0": { "input_cost_per_token": 3.5e-08, "litellm_provider": "bedrock_converse", diff --git a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py index 37c95be72ce..e603f94ab87 100644 --- a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py @@ -2702,3 +2702,36 @@ def test_empty_assistant_message_handling(): finally: # Restore original modify_params setting litellm.modify_params = original_modify_params + + +def test_is_nova_lite_2_model(): + """Test the _is_nova_lite_2_model() method for detecting Nova 2 models.""" + config = AmazonConverseConfig() + + # Test with amazon.nova-2-lite-v1:0 + assert config._is_nova_lite_2_model("amazon.nova-2-lite-v1:0") is True + + # Test with regional variants + assert config._is_nova_lite_2_model("us.amazon.nova-2-lite-v1:0") is True + assert config._is_nova_lite_2_model("eu.amazon.nova-2-lite-v1:0") is True + assert config._is_nova_lite_2_model("apac.amazon.nova-2-lite-v1:0") is True + + # Test with other Nova 2 variants (pro, micro) + assert config._is_nova_lite_2_model("amazon.nova-pro-1-5-v1:0") is False + assert config._is_nova_lite_2_model("amazon.nova-micro-1-5-v1:0") is False + assert config._is_nova_lite_2_model("us.amazon.nova-pro-1-5-v1:0") is False + assert config._is_nova_lite_2_model("eu.amazon.nova-micro-1-5-v1:0") is False + + # Test with non-Nova-1.5 lite models (should return False) + assert config._is_nova_lite_2_model("amazon.nova-lite-v1:0") is False + assert config._is_nova_lite_2_model("amazon.nova-pro-v1:0") is False + assert config._is_nova_lite_2_model("amazon.nova-micro-v1:0") is False + + # Test with Nova v1:0 models (should return False) + assert config._is_nova_lite_2_model("us.amazon.nova-lite-v1:0") is False + assert config._is_nova_lite_2_model("eu.amazon.nova-pro-v1:0") is False + + # Test with completely different models (should return False) + assert config._is_nova_lite_2_model("anthropic.claude-3-5-sonnet-20240620-v1:0") is False + assert config._is_nova_lite_2_model("meta.llama3-70b-instruct-v1:0") is False + assert config._is_nova_lite_2_model("mistral.mistral-7b-instruct-v0:2") is False diff --git a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation_nova_2.py b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation_nova_2.py new file mode 100644 index 00000000000..23243dac201 --- /dev/null +++ b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation_nova_2.py @@ -0,0 +1,794 @@ +""" +Unit tests for Amazon Nova 2 reasoning configuration transformation. + +Tests the _transform_reasoning_effort_to_reasoning_config method in AmazonConverseConfig. +""" + +import pytest +import sys +import os + +sys.path.insert( + 0, os.path.abspath("../..") +) # Adds the parent directory to the system path + +import litellm +from litellm.llms.bedrock.chat.converse_transformation import AmazonConverseConfig + + +class TestNova15ReasoningTransformation: + """Test suite for Nova 2 reasoning effort transformation.""" + + def test_reasoning_effort_low_transformation(self): + """Test that reasoning_effort='low' is transformed to correct reasoningConfig structure.""" + config = AmazonConverseConfig() + + result = config._transform_reasoning_effort_to_reasoning_config("low") + + # Verify the structure + assert "reasoningConfig" in result + assert result["reasoningConfig"]["type"] == "enabled" + assert result["reasoningConfig"]["maxReasoningEffort"] == "low" + + def test_reasoning_effort_high_transformation(self): + """Test that reasoning_effort='high' is transformed to correct reasoningConfig structure.""" + config = AmazonConverseConfig() + + result = config._transform_reasoning_effort_to_reasoning_config("high") + + # Verify the structure + assert "reasoningConfig" in result + assert result["reasoningConfig"]["type"] == "enabled" + assert result["reasoningConfig"]["maxReasoningEffort"] == "high" + + def test_invalid_reasoning_effort_value(self): + """Test that invalid reasoning_effort values raise BadRequestError.""" + config = AmazonConverseConfig() + + # Test with invalid value "invalid" + with pytest.raises(litellm.exceptions.BadRequestError) as exc_info: + config._transform_reasoning_effort_to_reasoning_config("invalid") + + # Verify error message contains the invalid value and valid values + error_message = str(exc_info.value) + assert "invalid" in error_message + assert "low" in error_message + assert "high" in error_message + assert "Nova 2" in error_message + + def test_invalid_reasoning_effort_empty_string(self): + """Test that empty string raises BadRequestError.""" + config = AmazonConverseConfig() + + with pytest.raises(litellm.exceptions.BadRequestError) as exc_info: + config._transform_reasoning_effort_to_reasoning_config("") + + # Verify error message + error_message = str(exc_info.value) + assert "low" in error_message + assert "high" in error_message + + def test_invalid_reasoning_effort_wrong_case(self): + """Test that case-sensitive values are rejected (e.g., 'Low' instead of 'low').""" + config = AmazonConverseConfig() + + with pytest.raises(litellm.exceptions.BadRequestError): + config._transform_reasoning_effort_to_reasoning_config("Low") + + with pytest.raises(litellm.exceptions.BadRequestError): + config._transform_reasoning_effort_to_reasoning_config("HIGH") + + +class TestNova2ParameterMapping: + """Test suite for Nova 2 parameter mapping integration.""" + + def test_nova_2_reasoning_effort_low_mapping(self): + """Test that reasoning_effort='low' is correctly mapped to reasoningConfig for Nova 2.""" + config = AmazonConverseConfig() + + model = "amazon.nova-2-lite-v1:0" + non_default_params = {"reasoning_effort": "low"} + optional_params = {} + + result = config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model=model, + drop_params=False, + ) + + # Verify reasoningConfig is in result + assert "reasoningConfig" in result + assert result["reasoningConfig"]["type"] == "enabled" + assert result["reasoningConfig"]["maxReasoningEffort"] == "low" + + # Verify thinking is NOT in result + assert "thinking" not in result + + # Verify reasoning_effort is NOT kept as-is (should be transformed) + assert "reasoning_effort" not in result + + def test_nova_2_reasoning_effort_high_mapping(self): + """Test that reasoning_effort='high' is correctly mapped to reasoningConfig for Nova 2.""" + config = AmazonConverseConfig() + + model = "amazon.nova-2-lite-v1:0" + non_default_params = {"reasoning_effort": "high"} + optional_params = {} + + result = config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model=model, + drop_params=False, + ) + + # Verify reasoningConfig is in result + assert "reasoningConfig" in result + assert result["reasoningConfig"]["type"] == "enabled" + assert result["reasoningConfig"]["maxReasoningEffort"] == "high" + + # Verify thinking is NOT in result + assert "thinking" not in result + + # Verify reasoning_effort is NOT kept as-is (should be transformed) + assert "reasoning_effort" not in result + + def test_nova_2_without_reasoning_effort(self): + """Test that Nova 2 without reasoning_effort has no reasoningConfig in result.""" + config = AmazonConverseConfig() + + model = "amazon.nova-2-lite-v1:0" + non_default_params = {"temperature": 0.7} + optional_params = {} + + result = config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model=model, + drop_params=False, + ) + + # Verify reasoningConfig is NOT in result + assert "reasoningConfig" not in result + + # Verify thinking is NOT in result + assert "thinking" not in result + + # Verify reasoning_effort is NOT in result + assert "reasoning_effort" not in result + + def test_nova_2_regional_variant_us(self): + """Test that US regional variant of Nova 2 works correctly.""" + config = AmazonConverseConfig() + + model = "us.amazon.nova-2-lite-v1:0" + non_default_params = {"reasoning_effort": "high"} + optional_params = {} + + result = config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model=model, + drop_params=False, + ) + + # Verify reasoningConfig is in result + assert "reasoningConfig" in result + assert result["reasoningConfig"]["type"] == "enabled" + assert result["reasoningConfig"]["maxReasoningEffort"] == "high" + + def test_nova_2_regional_variant_eu(self): + """Test that EU regional variant of Nova 2 works correctly.""" + config = AmazonConverseConfig() + + model = "eu.amazon.nova-2-lite-v1:0" + non_default_params = {"reasoning_effort": "low"} + optional_params = {} + + result = config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model=model, + drop_params=False, + ) + + # Verify reasoningConfig is in result + assert "reasoningConfig" in result + assert result["reasoningConfig"]["type"] == "enabled" + assert result["reasoningConfig"]["maxReasoningEffort"] == "low" + + def test_nova_2_regional_variant_apac(self): + """Test that APAC regional variant of Nova 2 works correctly.""" + config = AmazonConverseConfig() + + model = "apac.amazon.nova-2-lite-v1:0" + non_default_params = {"reasoning_effort": "high"} + optional_params = {} + + result = config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model=model, + drop_params=False, + ) + + # Verify reasoningConfig is in result + assert "reasoningConfig" in result + assert result["reasoningConfig"]["type"] == "enabled" + assert result["reasoningConfig"]["maxReasoningEffort"] == "high" + + def test_nova_2_with_other_params(self): + """Test that Nova 2 reasoning works alongside other parameters.""" + config = AmazonConverseConfig() + + model = "amazon.nova-2-lite-v1:0" + non_default_params = { + "reasoning_effort": "high", + "temperature": 0.8, + "max_tokens": 1000, + "top_p": 0.9, + } + optional_params = {} + + result = config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model=model, + drop_params=False, + ) + + # Verify reasoningConfig is in result + assert "reasoningConfig" in result + assert result["reasoningConfig"]["type"] == "enabled" + assert result["reasoningConfig"]["maxReasoningEffort"] == "high" + + # Verify other params are also present + assert result["temperature"] == 0.8 + assert result["maxTokens"] == 1000 + assert result["topP"] == 0.9 + + +class TestNova15SupportedParameters: + """Test suite for Nova 2 supported parameters.""" + + def test_nova_2_supports_reasoning_effort(self): + """Test that Nova 2 model reports reasoning_effort in supported params.""" + config = AmazonConverseConfig() + + model = "amazon.nova-2-lite-v1:0" + supported_params = config.get_supported_openai_params(model) + + # Verify reasoning_effort is in supported params + assert "reasoning_effort" in supported_params + + # Verify thinking is NOT in supported params (Nova 2 uses reasoningConfig, not thinking) + assert "thinking" not in supported_params + + def test_nova_2_regional_variant_us_supported_params(self): + """Test that US regional variant returns same supported params.""" + config = AmazonConverseConfig() + + model = "us.amazon.nova-2-lite-v1:0" + supported_params = config.get_supported_openai_params(model) + + # Verify reasoning_effort is in supported params + assert "reasoning_effort" in supported_params + + # Verify thinking is NOT in supported params + assert "thinking" not in supported_params + + def test_nova_2_regional_variant_eu_supported_params(self): + """Test that EU regional variant returns same supported params.""" + config = AmazonConverseConfig() + + model = "eu.amazon.nova-2-lite-v1:0" + supported_params = config.get_supported_openai_params(model) + + # Verify reasoning_effort is in supported params + assert "reasoning_effort" in supported_params + + # Verify thinking is NOT in supported params + assert "thinking" not in supported_params + + def test_nova_2_regional_variant_apac_supported_params(self): + """Test that APAC regional variant returns same supported params.""" + config = AmazonConverseConfig() + + model = "apac.amazon.nova-2-lite-v1:0" + supported_params = config.get_supported_openai_params(model) + + # Verify reasoning_effort is in supported params + assert "reasoning_effort" in supported_params + + # Verify thinking is NOT in supported params + assert "thinking" not in supported_params + + def test_nova_2_has_standard_params(self): + """Test that Nova 2 still has all standard supported params.""" + config = AmazonConverseConfig() + + model = "amazon.nova-2-lite-v1:0" + supported_params = config.get_supported_openai_params(model) + + # Verify standard params are present + assert "max_tokens" in supported_params + assert "max_completion_tokens" in supported_params + assert "stream" in supported_params + assert "stream_options" in supported_params + assert "stop" in supported_params + assert "temperature" in supported_params + assert "top_p" in supported_params + assert "tools" in supported_params + assert "response_format" in supported_params + + +class TestNova15ResponseParsing: + """Test suite for Nova 2 response parsing.""" + + def test_transform_reasoning_content_single_block(self): + """Test that reasoning content is extracted correctly from a single block.""" + config = AmazonConverseConfig() + + reasoning_blocks = [ + {"reasoningText": {"text": "Let me think through this step by step..."}} + ] + + result = config._transform_reasoning_content(reasoning_blocks) + + assert result == "Let me think through this step by step..." + + def test_transform_reasoning_content_multiple_blocks(self): + """Test that reasoning content is concatenated from multiple blocks.""" + config = AmazonConverseConfig() + + reasoning_blocks = [ + {"reasoningText": {"text": "First, I need to analyze the problem. "}}, + {"reasoningText": {"text": "Then, I'll consider the solution."}}, + ] + + result = config._transform_reasoning_content(reasoning_blocks) + + assert ( + result + == "First, I need to analyze the problem. Then, I'll consider the solution." + ) + + def test_transform_reasoning_content_empty_blocks(self): + """Test that empty reasoning blocks return empty string.""" + config = AmazonConverseConfig() + + reasoning_blocks = [] + + result = config._transform_reasoning_content(reasoning_blocks) + + assert result == "" + + def test_transform_thinking_blocks_with_text(self): + """Test that thinking blocks are populated correctly with text.""" + config = AmazonConverseConfig() + + reasoning_blocks = [{"reasoningText": {"text": "My reasoning process..."}}] + + result = config._transform_thinking_blocks(reasoning_blocks) + + assert len(result) == 1 + assert result[0]["type"] == "thinking" + assert result[0]["thinking"] == "My reasoning process..." + assert "signature" not in result[0] + + def test_transform_thinking_blocks_with_signature(self): + """Test that signature field is preserved when present.""" + config = AmazonConverseConfig() + + reasoning_blocks = [ + { + "reasoningText": { + "text": "My reasoning...", + "signature": "signature-hash-12345", + } + } + ] + + result = config._transform_thinking_blocks(reasoning_blocks) + + assert len(result) == 1 + assert result[0]["type"] == "thinking" + assert result[0]["thinking"] == "My reasoning..." + assert result[0]["signature"] == "signature-hash-12345" + + def test_transform_thinking_blocks_with_redacted_content(self): + """Test that redacted content blocks are handled correctly.""" + config = AmazonConverseConfig() + + reasoning_blocks = [ + {"reasoningText": {"text": "First part of reasoning..."}}, + {"redactedContent": {}}, + {"reasoningText": {"text": "Second part after redaction..."}}, + ] + + result = config._transform_thinking_blocks(reasoning_blocks) + + assert len(result) == 3 + assert result[0]["type"] == "thinking" + assert result[0]["thinking"] == "First part of reasoning..." + assert result[1]["type"] == "redacted_thinking" + assert result[2]["type"] == "thinking" + assert result[2]["thinking"] == "Second part after redaction..." + + def test_transform_thinking_blocks_multiple_blocks(self): + """Test that multiple thinking blocks are all transformed.""" + config = AmazonConverseConfig() + + reasoning_blocks = [ + {"reasoningText": {"text": "Step 1: Analyze the problem"}}, + { + "reasoningText": { + "text": "Step 2: Consider solutions", + "signature": "sig-abc", + } + }, + {"reasoningText": {"text": "Step 3: Choose best approach"}}, + ] + + result = config._transform_thinking_blocks(reasoning_blocks) + + assert len(result) == 3 + assert all(block["type"] == "thinking" for block in result) + assert result[0]["thinking"] == "Step 1: Analyze the problem" + assert result[1]["thinking"] == "Step 2: Consider solutions" + assert result[1]["signature"] == "sig-abc" + assert result[2]["thinking"] == "Step 3: Choose best approach" + + def test_transform_thinking_blocks_empty_list(self): + """Test that empty thinking blocks list returns empty list.""" + config = AmazonConverseConfig() + + reasoning_blocks = [] + + result = config._transform_thinking_blocks(reasoning_blocks) + + assert result == [] + + def test_response_parsing_integration(self): + """Test that response parsing works end-to-end with Nova 2 structure.""" + config = AmazonConverseConfig() + + # Simulate a Nova 2 response with reasoning content + reasoning_blocks = [ + { + "reasoningText": { + "text": "Let me analyze this carefully. ", + "signature": "test-signature", + } + }, + {"reasoningText": {"text": "Based on my analysis, the answer is clear."}}, + ] + + # Test reasoning content extraction + reasoning_content = config._transform_reasoning_content(reasoning_blocks) + assert ( + reasoning_content + == "Let me analyze this carefully. Based on my analysis, the answer is clear." + ) + + # Test thinking blocks transformation + thinking_blocks = config._transform_thinking_blocks(reasoning_blocks) + assert len(thinking_blocks) == 2 + assert thinking_blocks[0]["thinking"] == "Let me analyze this carefully. " + assert thinking_blocks[0]["signature"] == "test-signature" + assert ( + thinking_blocks[1]["thinking"] + == "Based on my analysis, the answer is clear." + ) + + +class TestNova15StreamingResponseParsing: + """Test suite for Nova 2 streaming response parsing.""" + + def test_streaming_reasoning_content_start_event(self): + """Test that streaming start event with reasoningContent is handled correctly.""" + from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder + + handler = AWSEventStreamDecoder(model="amazon.nova-2-lite-v1:0") + + # Simulate a start event with redacted reasoning content + chunk_data = { + "start": {"reasoningContent": {"redactedContent": {}}}, + "contentBlockIndex": 0, + } + + result = handler.converse_chunk_parser(chunk_data) + + # Verify thinking blocks are populated + assert result.choices[0].delta.thinking_blocks is not None + assert len(result.choices[0].delta.thinking_blocks) == 1 + assert result.choices[0].delta.thinking_blocks[0]["type"] == "redacted_thinking" + + def test_streaming_reasoning_content_delta_text(self): + """Test that streaming delta event with reasoning text is handled correctly.""" + from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder + + handler = AWSEventStreamDecoder(model="amazon.nova-2-lite-v1:0") + + # Simulate a delta event with reasoning text + chunk_data = { + "delta": {"reasoningContent": {"text": "Let me think about this..."}}, + "contentBlockIndex": 0, + } + + result = handler.converse_chunk_parser(chunk_data) + + # Verify reasoning content is extracted + assert result.choices[0].delta.reasoning_content == "Let me think about this..." + + # Verify thinking blocks are populated + assert result.choices[0].delta.thinking_blocks is not None + assert len(result.choices[0].delta.thinking_blocks) == 1 + assert result.choices[0].delta.thinking_blocks[0]["type"] == "thinking" + assert ( + result.choices[0].delta.thinking_blocks[0]["thinking"] + == "Let me think about this..." + ) + + def test_streaming_reasoning_content_delta_signature(self): + """Test that streaming delta event with signature is handled correctly.""" + from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder + + handler = AWSEventStreamDecoder(model="amazon.nova-2-lite-v1:0") + + # Simulate a delta event with signature + chunk_data = { + "delta": {"reasoningContent": {"signature": "signature-hash-xyz"}}, + "contentBlockIndex": 0, + } + + result = handler.converse_chunk_parser(chunk_data) + + # Verify reasoning content is set to empty string for consistency + assert result.choices[0].delta.reasoning_content == "" + + # Verify thinking blocks are populated with signature + assert result.choices[0].delta.thinking_blocks is not None + assert len(result.choices[0].delta.thinking_blocks) == 1 + assert result.choices[0].delta.thinking_blocks[0]["type"] == "thinking" + assert ( + result.choices[0].delta.thinking_blocks[0]["signature"] + == "signature-hash-xyz" + ) + assert result.choices[0].delta.thinking_blocks[0]["thinking"] == "" + + def test_streaming_reasoning_content_multiple_deltas(self): + """Test that multiple reasoning content deltas are accumulated correctly.""" + from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder + + handler = AWSEventStreamDecoder(model="amazon.nova-2-lite-v1:0") + + # Simulate multiple delta events + chunks = [ + { + "delta": {"reasoningContent": {"text": "First, "}}, + "contentBlockIndex": 0, + }, + { + "delta": {"reasoningContent": {"text": "I need to analyze "}}, + "contentBlockIndex": 0, + }, + { + "delta": {"reasoningContent": {"text": "the problem."}}, + "contentBlockIndex": 0, + }, + ] + + results = [] + for chunk_data in chunks: + result = handler.converse_chunk_parser(chunk_data) + results.append(result) + + # Verify each delta has the correct reasoning content + assert results[0].choices[0].delta.reasoning_content == "First, " + assert results[1].choices[0].delta.reasoning_content == "I need to analyze " + assert results[2].choices[0].delta.reasoning_content == "the problem." + + # Verify thinking blocks are populated for each delta + for result in results: + assert result.choices[0].delta.thinking_blocks is not None + assert len(result.choices[0].delta.thinking_blocks) == 1 + assert result.choices[0].delta.thinking_blocks[0]["type"] == "thinking" + + def test_streaming_reasoning_then_text_content(self): + """Test that reasoning content followed by text content is handled correctly.""" + from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder + + handler = AWSEventStreamDecoder(model="amazon.nova-2-lite-v1:0") + + # Simulate reasoning content followed by text content + chunks = [ + { + "delta": {"reasoningContent": {"text": "Let me think..."}}, + "contentBlockIndex": 0, + }, + {"delta": {"text": "Based on my reasoning, "}, "contentBlockIndex": 1}, + {"delta": {"text": "the answer is 42."}, "contentBlockIndex": 1}, + ] + + results = [] + for chunk_data in chunks: + result = handler.converse_chunk_parser(chunk_data) + results.append(result) + + # Verify first chunk has reasoning content + assert results[0].choices[0].delta.reasoning_content == "Let me think..." + assert results[0].choices[0].delta.thinking_blocks is not None + + # Verify subsequent chunks have text content + assert results[1].choices[0].delta.content == "Based on my reasoning, " + assert results[2].choices[0].delta.content == "the answer is 42." + + def test_streaming_redacted_content_delta(self): + """Test that streaming delta with redacted content is handled correctly.""" + from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder + + handler = AWSEventStreamDecoder(model="amazon.nova-2-lite-v1:0") + + # Simulate a delta event with redacted content + chunk_data = { + "delta": {"reasoningContent": {"redactedContent": {}}}, + "contentBlockIndex": 0, + } + + result = handler.converse_chunk_parser(chunk_data) + + # Verify reasoning content is set to empty string for consistency + assert result.choices[0].delta.reasoning_content == "" + + # Verify thinking blocks contain redacted block + assert result.choices[0].delta.thinking_blocks is not None + assert len(result.choices[0].delta.thinking_blocks) == 1 + assert result.choices[0].delta.thinking_blocks[0]["type"] == "redacted_thinking" + + def test_streaming_provider_specific_fields(self): + """Test that provider_specific_fields are populated in streaming responses.""" + from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder + + handler = AWSEventStreamDecoder(model="amazon.nova-2-lite-v1:0") + + # Simulate a delta event with reasoning content + chunk_data = { + "delta": {"reasoningContent": {"text": "Reasoning text"}}, + "contentBlockIndex": 0, + } + + result = handler.converse_chunk_parser(chunk_data) + + # Verify provider_specific_fields are populated + assert result.choices[0].delta.provider_specific_fields is not None + assert "reasoningContent" in result.choices[0].delta.provider_specific_fields + assert ( + result.choices[0].delta.provider_specific_fields["reasoningContent"]["text"] + == "Reasoning text" + ) + + def test_streaming_mixed_content_blocks(self): + """Test streaming with mixed content blocks (reasoning, text, tool calls).""" + from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder + + handler = AWSEventStreamDecoder(model="amazon.nova-2-lite-v1:0") + + # Simulate a complex streaming scenario + chunks = [ + # Start with reasoning + { + "delta": { + "reasoningContent": { + "text": "I need to call a tool to get information." + } + }, + "contentBlockIndex": 0, + }, + # Tool use start + { + "start": {"toolUse": {"toolUseId": "tool-123", "name": "get_weather"}}, + "contentBlockIndex": 1, + }, + # Tool use delta + { + "delta": {"toolUse": {"input": '{"location": "NYC"}'}}, + "contentBlockIndex": 1, + }, + # Text response + {"delta": {"text": "The weather is sunny."}, "contentBlockIndex": 2}, + ] + + results = [] + for chunk_data in chunks: + result = handler.converse_chunk_parser(chunk_data) + results.append(result) + + # Verify reasoning content in first chunk + assert ( + results[0].choices[0].delta.reasoning_content + == "I need to call a tool to get information." + ) + + # Verify tool call in second and third chunks + assert results[1].choices[0].delta.tool_calls is not None + assert ( + results[1].choices[0].delta.tool_calls[0]["function"]["name"] + == "get_weather" + ) + assert results[2].choices[0].delta.tool_calls is not None + + # Verify text content in fourth chunk + assert results[3].choices[0].delta.content == "The weather is sunny." + + def test_extract_reasoning_content_str_with_text(self): + """Test extract_reasoning_content_str method with text.""" + from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder + + handler = AWSEventStreamDecoder(model="amazon.nova-2-lite-v1:0") + + reasoning_block = {"text": "This is reasoning text"} + + result = handler.extract_reasoning_content_str(reasoning_block) + + assert result == "This is reasoning text" + + def test_extract_reasoning_content_str_without_text(self): + """Test extract_reasoning_content_str method without text (e.g., signature only).""" + from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder + + handler = AWSEventStreamDecoder(model="amazon.nova-2-lite-v1:0") + + reasoning_block = {"signature": "sig-123"} + + result = handler.extract_reasoning_content_str(reasoning_block) + + assert result is None + + def test_translate_thinking_blocks_streaming_text(self): + """Test translate_thinking_blocks method with text.""" + from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder + + handler = AWSEventStreamDecoder(model="amazon.nova-2-lite-v1:0") + + thinking_block = {"text": "Thinking content"} + + result = handler.translate_thinking_blocks(thinking_block) + + assert result is not None + assert len(result) == 1 + assert result[0]["type"] == "thinking" + assert result[0]["thinking"] == "Thinking content" + + def test_translate_thinking_blocks_streaming_signature(self): + """Test translate_thinking_blocks method with signature.""" + from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder + + handler = AWSEventStreamDecoder(model="amazon.nova-2-lite-v1:0") + + thinking_block = {"signature": "sig-abc"} + + result = handler.translate_thinking_blocks(thinking_block) + + assert result is not None + assert len(result) == 1 + assert result[0]["type"] == "thinking" + assert result[0]["signature"] == "sig-abc" + assert ( + result[0]["thinking"] == "" + ) # Empty string for consistency with Anthropic + + def test_translate_thinking_blocks_streaming_redacted(self): + """Test translate_thinking_blocks method with redacted content.""" + from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder + + handler = AWSEventStreamDecoder(model="amazon.nova-2-lite-v1:0") + + thinking_block = {"redactedContent": {}} + + result = handler.translate_thinking_blocks(thinking_block) + + assert result is not None + assert len(result) == 1 + assert result[0]["type"] == "redacted_thinking" From 4063501d6981a7b0606f4942deb2b7d0cc43dc06 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 2 Dec 2025 17:08:17 -0800 Subject: [PATCH 14/37] Show all credential values on Edit Credential Modal --- .../model_add/AddCredentialModal.test.tsx | 108 +++++++++++++++ .../model_add/AddCredentialModal.tsx | 118 +++++++++++++++++ .../model_add/EditCredentialModal.test.tsx | 123 ++++++++++++++++++ ...ntials_tab.tsx => EditCredentialModal.tsx} | 60 ++++----- .../src/components/model_add/credentials.tsx | 22 ++-- 5 files changed, 386 insertions(+), 45 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/model_add/AddCredentialModal.test.tsx create mode 100644 ui/litellm-dashboard/src/components/model_add/AddCredentialModal.tsx create mode 100644 ui/litellm-dashboard/src/components/model_add/EditCredentialModal.test.tsx rename ui/litellm-dashboard/src/components/model_add/{add_credentials_tab.tsx => EditCredentialModal.tsx} (78%) diff --git a/ui/litellm-dashboard/src/components/model_add/AddCredentialModal.test.tsx b/ui/litellm-dashboard/src/components/model_add/AddCredentialModal.test.tsx new file mode 100644 index 00000000000..aee7a0cdd1d --- /dev/null +++ b/ui/litellm-dashboard/src/components/model_add/AddCredentialModal.test.tsx @@ -0,0 +1,108 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { Providers } from "../provider_info_helpers"; +import AddCredentialModal from "./AddCredentialModal"; + +vi.mock("../networking", async () => { + const actual = await vi.importActual("../networking"); + return { + ...actual, + getProviderCreateMetadata: vi.fn().mockResolvedValue([ + { + provider: "OpenAI", + provider_display_name: Providers.OpenAI, + litellm_provider: "openai", + default_model_placeholder: "gpt-3.5-turbo", + credential_fields: [ + { + key: "api_key", + label: "OpenAI API Key", + field_type: "password", + required: true, + }, + { + key: "api_base", + label: "API Base", + field_type: "text", + placeholder: "https://api.openai.com/v1", + }, + ], + }, + { + provider: "Anthropic", + provider_display_name: Providers.Anthropic, + litellm_provider: "anthropic", + default_model_placeholder: "claude-3-opus-20240229", + credential_fields: [ + { + key: "api_key", + label: "Anthropic API Key", + field_type: "password", + required: true, + }, + ], + }, + ]), + }; +}); + +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + }, + }); + +const mockUploadProps = { + beforeUpload: vi.fn(), + onChange: vi.fn(), +}; + +describe("AddCredentialModal", () => { + it("should render", () => { + const queryClient = createQueryClient(); + const onCancel = vi.fn(); + const onAddCredential = vi.fn(); + + render( + + + , + ); + + expect(screen.getByText("Add New Credential")).toBeInTheDocument(); + expect(screen.getByLabelText("Credential Name:")).toBeInTheDocument(); + expect(screen.getByLabelText("Provider:")).toBeInTheDocument(); + }); + + it("should show the correct provider fields", async () => { + const queryClient = createQueryClient(); + const onCancel = vi.fn(); + const onAddCredential = vi.fn(); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByLabelText("OpenAI API Key")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("https://api.openai.com/v1")).toBeInTheDocument(); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/model_add/AddCredentialModal.tsx b/ui/litellm-dashboard/src/components/model_add/AddCredentialModal.tsx new file mode 100644 index 00000000000..694a98201c6 --- /dev/null +++ b/ui/litellm-dashboard/src/components/model_add/AddCredentialModal.tsx @@ -0,0 +1,118 @@ +import { TextInput } from "@tremor/react"; +import { Select as AntdSelect, Button, Form, Modal, Tooltip, Typography } from "antd"; +import type { UploadProps } from "antd/es/upload"; +import React, { useState } from "react"; +import ProviderSpecificFields from "../add_model/provider_specific_fields"; +import { Providers, providerLogoMap } from "../provider_info_helpers"; +const { Link } = Typography; + +interface AddCredentialsModalProps { + open: boolean; + onCancel: () => void; + onAddCredential: (values: any) => void; + uploadProps: UploadProps; +} + +const AddCredentialsModal: React.FC = ({ open, onCancel, onAddCredential, uploadProps }) => { + const [form] = Form.useForm(); + const [selectedProvider, setSelectedProvider] = useState(Providers.OpenAI); + + const handleSubmit = (values: any) => { + const filteredValues = Object.entries(values).reduce((acc, [key, value]) => { + if (value !== "" && value !== undefined && value !== null) { + acc[key] = value; + } + return acc; + }, {} as any); + onAddCredential(filteredValues); + form.resetFields(); + }; + + return ( + { + onCancel(); + form.resetFields(); + }} + footer={null} + width={600} + > +
    + {/* Credential Name */} + + + + + {/* Provider Selection */} + + { + setSelectedProvider(value as Providers); + form.setFieldValue("custom_llm_provider", value); + }} + > + {Object.entries(Providers).map(([providerEnum, providerDisplayName]) => ( + +
    + {`${providerEnum} { + const target = e.target as HTMLImageElement; + const parent = target.parentElement; + if (parent) { + const fallbackDiv = document.createElement("div"); + fallbackDiv.className = + "w-5 h-5 rounded-full bg-gray-200 flex items-center justify-center text-xs"; + fallbackDiv.textContent = providerDisplayName.charAt(0); + parent.replaceChild(fallbackDiv, target); + } + }} + /> + {providerDisplayName} +
    +
    + ))} +
    +
    + + + + {/* Modal Footer */} +
    + + Need Help? + + +
    + + +
    +
    + +
    + ); +}; + +export default AddCredentialsModal; diff --git a/ui/litellm-dashboard/src/components/model_add/EditCredentialModal.test.tsx b/ui/litellm-dashboard/src/components/model_add/EditCredentialModal.test.tsx new file mode 100644 index 00000000000..def3b4f6cd7 --- /dev/null +++ b/ui/litellm-dashboard/src/components/model_add/EditCredentialModal.test.tsx @@ -0,0 +1,123 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { Providers } from "../provider_info_helpers"; +import { CredentialItem } from "../networking"; +import EditCredentialModal from "./EditCredentialModal"; + +vi.mock("../networking", async () => { + const actual = await vi.importActual("../networking"); + return { + ...actual, + getProviderCreateMetadata: vi.fn().mockResolvedValue([ + { + provider: "OpenAI", + provider_display_name: Providers.OpenAI, + litellm_provider: "openai", + default_model_placeholder: "gpt-3.5-turbo", + credential_fields: [ + { + key: "api_key", + label: "OpenAI API Key", + field_type: "password", + required: true, + }, + { + key: "api_base", + label: "API Base", + field_type: "text", + placeholder: "https://api.openai.com/v1", + }, + ], + }, + { + provider: "Anthropic", + provider_display_name: Providers.Anthropic, + litellm_provider: "anthropic", + default_model_placeholder: "claude-3-opus-20240229", + credential_fields: [ + { + key: "api_key", + label: "Anthropic API Key", + field_type: "password", + required: true, + }, + ], + }, + ]), + }; +}); + +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + }, + }); + +const mockUploadProps = { + beforeUpload: vi.fn(), + onChange: vi.fn(), +}; + +const mockCredential: CredentialItem = { + credential_name: "test-credential", + credential_values: { + api_key: "test-api-key", + api_base: "https://api.test.com", + }, + credential_info: { + custom_llm_provider: Providers.OpenAI, + }, +}; + +describe("EditCredentialModal", () => { + it("should render", () => { + const queryClient = createQueryClient(); + const onCancel = vi.fn(); + const onUpdateCredential = vi.fn(); + + render( + + + , + ); + + expect(screen.getByText("Edit Credential")).toBeInTheDocument(); + expect(screen.getByLabelText("Credential Name:")).toBeInTheDocument(); + expect(screen.getByLabelText("Provider:")).toBeInTheDocument(); + }); + + it("should render initial values", async () => { + const queryClient = createQueryClient(); + const onCancel = vi.fn(); + const onUpdateCredential = vi.fn(); + + render( + + + , + ); + + await waitFor(() => { + const credentialNameInput = screen.getByLabelText("Credential Name:") as HTMLInputElement; + expect(credentialNameInput.value).toBe("test-credential"); + expect(credentialNameInput.disabled).toBe(true); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/model_add/add_credentials_tab.tsx b/ui/litellm-dashboard/src/components/model_add/EditCredentialModal.tsx similarity index 78% rename from ui/litellm-dashboard/src/components/model_add/add_credentials_tab.tsx rename to ui/litellm-dashboard/src/components/model_add/EditCredentialModal.tsx index 9c061eb121a..b206ed6c91d 100644 --- a/ui/litellm-dashboard/src/components/model_add/add_credentials_tab.tsx +++ b/ui/litellm-dashboard/src/components/model_add/EditCredentialModal.tsx @@ -1,34 +1,29 @@ -import React, { useEffect, useState } from "react"; -import { Form, Button, Tooltip, Typography, Select as AntdSelect, Modal } from "antd"; -import type { UploadProps } from "antd/es/upload"; -import { Providers, providerLogoMap } from "../provider_info_helpers"; -import ProviderSpecificFields from "../add_model/provider_specific_fields"; import { TextInput } from "@tremor/react"; +import { Select as AntdSelect, Button, Form, Modal, Tooltip, Typography } from "antd"; +import type { UploadProps } from "antd/es/upload"; +import { useEffect, useState } from "react"; +import ProviderSpecificFields from "../add_model/provider_specific_fields"; import { CredentialItem } from "../networking"; -const { Title, Link } = Typography; +import { Providers, providerLogoMap } from "../provider_info_helpers"; +const { Link } = Typography; -interface AddCredentialsModalProps { - isVisible: boolean; +interface EditCredentialsModalProps { + open: boolean; onCancel: () => void; - onAddCredential: (values: any) => void; onUpdateCredential: (values: any) => void; uploadProps: UploadProps; - addOrEdit: "add" | "edit"; existingCredential: CredentialItem | null; } -const AddCredentialsModal: React.FC = ({ - isVisible, +export default function EditCredentialsModal({ + open, onCancel, - onAddCredential, onUpdateCredential, uploadProps, - addOrEdit, existingCredential, -}) => { +}: EditCredentialsModalProps) { const [form] = Form.useForm(); - const [selectedProvider, setSelectedProvider] = useState(Providers.OpenAI); - const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); + const [selectedProvider, setSelectedProvider] = useState(Providers.Anthropic); const handleSubmit = (values: any) => { const filteredValues = Object.entries(values).reduce((acc, [key, value]) => { @@ -37,23 +32,25 @@ const AddCredentialsModal: React.FC = ({ } return acc; }, {} as any); - if (addOrEdit === "add") { - onAddCredential(filteredValues); - } else { - onUpdateCredential(filteredValues); - } + onUpdateCredential(filteredValues); form.resetFields(); }; useEffect(() => { if (existingCredential) { + // Spread all credential_values dynamically, converting undefined/null to null for form compatibility + const credentialValues = Object.entries(existingCredential.credential_values || {}).reduce( + (acc, [key, value]) => { + acc[key] = value ?? null; + return acc; + }, + {} as Record, + ); + form.setFieldsValue({ credential_name: existingCredential.credential_name, custom_llm_provider: existingCredential.credential_info.custom_llm_provider, - api_base: existingCredential.credential_values.api_base, - api_version: existingCredential.credential_values.api_version, - base_model: existingCredential.credential_values.base_model, - api_key: existingCredential.credential_values.api_key, + ...credentialValues, }); setSelectedProvider(existingCredential.credential_info.custom_llm_provider as Providers); } @@ -61,14 +58,15 @@ const AddCredentialsModal: React.FC = ({ return ( { onCancel(); form.resetFields(); }} footer={null} width={600} + destroyOnHidden={true} >
    {/* Credential Name */} @@ -142,12 +140,10 @@ const AddCredentialsModal: React.FC = ({ > Cancel - +
    ); -}; - -export default AddCredentialsModal; +} diff --git a/ui/litellm-dashboard/src/components/model_add/credentials.tsx b/ui/litellm-dashboard/src/components/model_add/credentials.tsx index e36a759294b..eecd26db25f 100644 --- a/ui/litellm-dashboard/src/components/model_add/credentials.tsx +++ b/ui/litellm-dashboard/src/components/model_add/credentials.tsx @@ -19,10 +19,11 @@ import { credentialUpdateCall, CredentialItem, } from "@/components/networking"; // Assume this is your networking function -import AddCredentialsTab from "./add_credentials_tab"; +import AddCredentialsTab from "./AddCredentialModal"; import CredentialDeleteModal from "./CredentialDeleteModal"; import { Form } from "antd"; import NotificationsManager from "../molecules/notifications_manager"; +import EditCredentialsModal from "./EditCredentialModal"; interface CredentialsPanelProps { accessToken: string | null; uploadProps: UploadProps; @@ -60,10 +61,10 @@ const CredentialsPanel: React.FC = ({ }, }; - const response = await credentialUpdateCall(accessToken, values.credential_name, newCredential); + await credentialUpdateCall(accessToken, values.credential_name, newCredential); NotificationsManager.success("Credential updated successfully"); setIsUpdateModalOpen(false); - fetchCredentials(accessToken); + await fetchCredentials(accessToken); }; const handleAddCredential = async (values: any) => { @@ -84,10 +85,10 @@ const CredentialsPanel: React.FC = ({ }; // Add to list and close modal - const response = await credentialCreateCall(accessToken, newCredential); + await credentialCreateCall(accessToken, newCredential); NotificationsManager.success("Credential added successfully"); setIsAddModalOpen(false); - fetchCredentials(accessToken); + await fetchCredentials(accessToken); }; useEffect(() => { @@ -189,23 +190,18 @@ const CredentialsPanel: React.FC = ({ {isAddModalOpen && ( setIsAddModalOpen(false)} uploadProps={uploadProps} - addOrEdit="add" - onUpdateCredential={handleUpdateCredential} - existingCredential={null} /> )} {isUpdateModalOpen && ( - setIsUpdateModalOpen(false)} - addOrEdit="edit" /> )} From 427074ac6e80ffe3b30054dd10c2a7427cfbf96f Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Tue, 2 Dec 2025 17:27:50 -0800 Subject: [PATCH 15/37] Fix: Datadog callback regression when ddtrace is installed (#17393) * fix DD agent host logging * docs fix * test_datadog_agent_configuration * test_datadog_ignores_ddtrace_agent_host --- docs/my-website/docs/observability/datadog.md | 18 +++---- litellm/integrations/datadog/datadog.py | 13 ++--- tests/logging_callback_tests/test_datadog.py | 47 +++++++++++++++++-- 3 files changed, 60 insertions(+), 18 deletions(-) diff --git a/docs/my-website/docs/observability/datadog.md b/docs/my-website/docs/observability/datadog.md index 5cb5ab3af2d..b2901650ea6 100644 --- a/docs/my-website/docs/observability/datadog.md +++ b/docs/my-website/docs/observability/datadog.md @@ -71,17 +71,19 @@ DD_SOURCE="litellm_dev" # [OPTIONAL] your datadog source. use to different Send logs through a local DataDog agent (useful for containerized environments): ```shell -DD_AGENT_HOST="localhost" # hostname or IP of DataDog agent -DD_AGENT_PORT="10518" # [OPTIONAL] port of DataDog agent (default: 10518) -DD_API_KEY="5f2d0f310***********" # [OPTIONAL] your datadog API Key (agent handles auth) -DD_SOURCE="litellm_dev" # [OPTIONAL] your datadog source +LITELLM_DD_AGENT_HOST="localhost" # hostname or IP of DataDog agent +LITELLM_DD_AGENT_PORT="10518" # [OPTIONAL] port of DataDog agent (default: 10518) +DD_API_KEY="5f2d0f310***********" # [OPTIONAL] your datadog API Key (agent handles auth) +DD_SOURCE="litellm_dev" # [OPTIONAL] your datadog source ``` -When `DD_AGENT_HOST` is set, logs are sent to the agent instead of directly to DataDog API. This is useful for: +When `LITELLM_DD_AGENT_HOST` is set, logs are sent to the agent instead of directly to DataDog API. This is useful for: - Centralized log shipping in containerized environments - Reducing direct API calls from multiple services - Leveraging agent-side processing and filtering +**Note:** We use `LITELLM_DD_AGENT_HOST` instead of `DD_AGENT_HOST` to avoid conflicts with `ddtrace` which automatically sets `DD_AGENT_HOST` for APM tracing. + **Step 3**: Start the proxy, make a test request Start proxy @@ -191,8 +193,8 @@ LiteLLM supports customizing the following Datadog environment variables |---------------------|-------------|---------------|----------| | `DD_API_KEY` | Your Datadog API key for authentication (required for direct API, optional for agent) | None | Conditional* | | `DD_SITE` | Your Datadog site (e.g., "us5.datadoghq.com") (required for direct API) | None | Conditional* | -| `DD_AGENT_HOST` | Hostname or IP of DataDog agent (e.g., "localhost"). When set, logs are sent to agent instead of direct API | None | ❌ No | -| `DD_AGENT_PORT` | Port of DataDog agent for log intake | "10518" | ❌ No | +| `LITELLM_DD_AGENT_HOST` | Hostname or IP of DataDog agent (e.g., "localhost"). When set, logs are sent to agent instead of direct API | None | ❌ No | +| `LITELLM_DD_AGENT_PORT` | Port of DataDog agent for log intake | "10518" | ❌ No | | `DD_ENV` | Environment tag for your logs (e.g., "production", "staging") | "unknown" | ❌ No | | `DD_SERVICE` | Service name for your logs | "litellm-server" | ❌ No | | `DD_SOURCE` | Source name for your logs | "litellm" | ❌ No | @@ -201,5 +203,5 @@ LiteLLM supports customizing the following Datadog environment variables | `POD_NAME` | Pod name tag (useful for Kubernetes deployments) | "unknown" | ❌ No | \* **Required when using Direct API** (default): `DD_API_KEY` and `DD_SITE` are required -\* **Optional when using DataDog Agent**: Set `DD_AGENT_HOST` to use agent mode; `DD_API_KEY` and `DD_SITE` are not required +\* **Optional when using DataDog Agent**: Set `LITELLM_DD_AGENT_HOST` to use agent mode; `DD_API_KEY` and `DD_SITE` are not required diff --git a/litellm/integrations/datadog/datadog.py b/litellm/integrations/datadog/datadog.py index 46e1a2c201f..21e1d562224 100644 --- a/litellm/integrations/datadog/datadog.py +++ b/litellm/integrations/datadog/datadog.py @@ -65,11 +65,11 @@ class DataDogLogger( `DD_SITE` - your datadog site, example = `"us5.datadoghq.com"` Optional environment variables (DataDog Agent): - `DD_AGENT_HOST` - hostname or IP of DataDog agent, example = `"localhost"` - `DD_AGENT_PORT` - port of DataDog agent (default: 10518 for logs) + `LITELLM_DD_AGENT_HOST` - hostname or IP of DataDog agent, example = `"localhost"` + `LITELLM_DD_AGENT_PORT` - port of DataDog agent (default: 10518 for logs) - Note: If DD_AGENT_HOST is set, logs will be sent to the agent instead of directly to DataDog API. - In this case, DD_API_KEY and DD_SITE are not required (agent handles authentication). + Note: We use LITELLM_DD_AGENT_HOST instead of DD_AGENT_HOST to avoid conflicts + with ddtrace which automatically sets DD_AGENT_HOST for APM tracing. """ try: verbose_logger.debug("Datadog: in init datadog logger") @@ -85,7 +85,8 @@ class DataDogLogger( ) # Configure DataDog endpoint (Agent or Direct API) - dd_agent_host = os.getenv("DD_AGENT_HOST") + # Use LITELLM_DD_AGENT_HOST to avoid conflicts with ddtrace's DD_AGENT_HOST + dd_agent_host = os.getenv("LITELLM_DD_AGENT_HOST") if dd_agent_host: self._configure_dd_agent(dd_agent_host=dd_agent_host) else: @@ -127,7 +128,7 @@ class DataDogLogger( Args: dd_agent_host: Hostname or IP of DataDog agent """ - dd_agent_port = os.getenv("DD_AGENT_PORT", "10518") # default port for logs + dd_agent_port = os.getenv("LITELLM_DD_AGENT_PORT", "10518") # default port for logs self.intake_url = f"http://{dd_agent_host}:{dd_agent_port}/api/v2/logs" self.DD_API_KEY = os.getenv("DD_API_KEY") # Optional when using agent verbose_logger.debug(f"Datadog: Using DD Agent at {self.intake_url}") diff --git a/tests/logging_callback_tests/test_datadog.py b/tests/logging_callback_tests/test_datadog.py index 13125aa4952..c877f34ac03 100644 --- a/tests/logging_callback_tests/test_datadog.py +++ b/tests/logging_callback_tests/test_datadog.py @@ -633,11 +633,14 @@ async def test_datadog_message_redaction(): def test_datadog_agent_configuration(): """ - Test that DataDog logger correctly configures agent endpoint when DD_AGENT_HOST is set + Test that DataDog logger correctly configures agent endpoint when LITELLM_DD_AGENT_HOST is set. + + Note: We use LITELLM_DD_AGENT_HOST instead of DD_AGENT_HOST to avoid conflicts + with ddtrace which automatically sets DD_AGENT_HOST for APM tracing. """ test_env = { - "DD_AGENT_HOST": "localhost", - "DD_AGENT_PORT": "10518", + "LITELLM_DD_AGENT_HOST": "localhost", + "LITELLM_DD_AGENT_PORT": "10518", } # Remove DD_SITE and DD_API_KEY to verify they're not required for agent mode @@ -654,4 +657,40 @@ def test_datadog_agent_configuration(): assert dd_logger.intake_url == "http://localhost:10518/api/v2/logs", f"Expected agent URL, got {dd_logger.intake_url}" # Verify DD_API_KEY is optional (can be None) - assert dd_logger.DD_API_KEY is None or isinstance(dd_logger.DD_API_KEY, str) \ No newline at end of file + assert dd_logger.DD_API_KEY is None or isinstance(dd_logger.DD_API_KEY, str) + + +def test_datadog_ignores_ddtrace_agent_host(): + """ + Regression test: Ensure DD_AGENT_HOST set by ddtrace doesn't interfere with LiteLLM logging. + + When users have ddtrace installed for APM tracing, it automatically sets DD_AGENT_HOST. + LiteLLM should ignore DD_AGENT_HOST and only use LITELLM_DD_AGENT_HOST for agent mode. + + This prevents the 404 error when ddtrace's DD_AGENT_HOST points to an APM endpoint + that doesn't support /api/v2/logs. + + Regression test for: https://github.com/BerriAI/litellm/issues/16379 + """ + test_env = { + # User's explicit config for LiteLLM logging (direct API) + "DD_API_KEY": "fake-api-key", + "DD_SITE": "us5.datadoghq.com", + # ddtrace automatically sets these for APM tracing + "DD_AGENT_HOST": "10.176.100.40", + "DD_AGENT_PORT": "8126", + } + + with patch.dict(os.environ, test_env, clear=False): + with patch("asyncio.create_task"): + dd_logger = DataDogLogger() + + # Verify direct API endpoint is used (DD_AGENT_HOST should be ignored) + expected_url = "https://http-intake.logs.us5.datadoghq.com/api/v2/logs" + assert dd_logger.intake_url == expected_url, ( + f"Expected direct API URL '{expected_url}', got '{dd_logger.intake_url}'. " + "DD_AGENT_HOST (set by ddtrace) should be ignored - only LITELLM_DD_AGENT_HOST should trigger agent mode." + ) + + # Verify API key is set correctly + assert dd_logger.DD_API_KEY == "fake-api-key" \ No newline at end of file From 209e9e05aa3a5543639df154fe54974e0b708e9a Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Wed, 3 Dec 2025 10:13:59 +0530 Subject: [PATCH 16/37] Fix gemini 3 last chunk thinking block --- .../vertex_and_google_ai_studio_gemini.py | 5 +++++ .../test_gemini_reasoning_content.py | 20 +++++++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 tests/local_testing/test_gemini_reasoning_content.py 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 5fef8c1ec49..cfc13f5601d 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 @@ -1091,6 +1091,11 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): if "thoughtSignature" in part: part_copy = part.copy() part_copy.pop("thoughtSignature") + + text_content = part_copy.get("text") + if isinstance(text_content, str) and text_content.strip() == "": + continue + thinking_blocks.append( ChatCompletionThinkingBlock( type="thinking", diff --git a/tests/local_testing/test_gemini_reasoning_content.py b/tests/local_testing/test_gemini_reasoning_content.py new file mode 100644 index 00000000000..7e516ae8439 --- /dev/null +++ b/tests/local_testing/test_gemini_reasoning_content.py @@ -0,0 +1,20 @@ +import json +from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexGeminiConfig + + +def test_empty_part_does_not_create_thinking_block(): + parts = [{"text": "", "thoughtSignature": "sig-1"}] + config = VertexGeminiConfig() + thinking_blocks = config._extract_thinking_blocks_from_parts(parts) + assert thinking_blocks == [] + + +def test_non_empty_part_creates_thinking_block(): + parts = [{"text": "Some thinking", "thoughtSignature": "sig-2"}] + config = VertexGeminiConfig() + thinking_blocks = config._extract_thinking_blocks_from_parts(parts) + assert len(thinking_blocks) == 1 + block = thinking_blocks[0] + # thinking should be valid JSON containing the text + parsed = json.loads(block["thinking"]) if isinstance(block["thinking"], str) else None + assert parsed is not None and parsed.get("text") == "Some thinking" From 40c203e32b5991cefa14d9d7befaac6385ee0538 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Wed, 3 Dec 2025 11:04:53 +0530 Subject: [PATCH 17/37] Make thought sign in tool call id as a beta feat --- .../vertex_and_google_ai_studio_gemini.py | 12 +- .../test_thought_signature_in_tool_call_id.py | 277 ++++++++++++------ 2 files changed, 188 insertions(+), 101 deletions(-) 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 5fef8c1ec49..1c2efe1eaa7 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 @@ -1205,14 +1205,16 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): } # Embed thought signature in ID for OpenAI client compatibility if thought_signature: - _tool_response_chunk[ - "id" - ] = _encode_tool_call_id_with_signature( - _tool_response_chunk["id"] or "", thought_signature - ) _tool_response_chunk["provider_specific_fields"] = { # type: ignore "thought_signature": thought_signature } + # Only embed in ID if preview features are enabled + if litellm.enable_preview_features: + _tool_response_chunk[ + "id" + ] = _encode_tool_call_id_with_signature( + _tool_response_chunk["id"] or "", thought_signature + ) _tools.append(_tool_response_chunk) cumulative_tool_call_idx += 1 if len(_tools) == 0: diff --git a/tests/test_litellm/llms/vertex_ai/gemini/test_thought_signature_in_tool_call_id.py b/tests/test_litellm/llms/vertex_ai/gemini/test_thought_signature_in_tool_call_id.py index 68c0f3bdbfc..46bb8930a7a 100644 --- a/tests/test_litellm/llms/vertex_ai/gemini/test_thought_signature_in_tool_call_id.py +++ b/tests/test_litellm/llms/vertex_ai/gemini/test_thought_signature_in_tool_call_id.py @@ -4,9 +4,13 @@ Tests for embedding thought signatures in tool call IDs for OpenAI client compat When using OpenAI clients (instead of LiteLLM SDK), provider_specific_fields are not preserved. This test suite validates that thought signatures can be embedded in tool call IDs and extracted when converting back to Gemini format. + +Note: Embedding signatures in tool call IDs is a beta feature that requires +enable_preview_features=True to be enabled. """ import pytest +import litellm from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( VertexGeminiConfig, ) @@ -62,36 +66,57 @@ def test_encode_tool_call_id_without_signature(): assert decoded_signature is None -def test_tool_call_id_includes_signature_in_response(): - """Test that tool call IDs in responses include embedded thought signatures""" +@pytest.mark.parametrize("enable_preview_features", [True, False]) +def test_tool_call_id_includes_signature_in_response(enable_preview_features): + """Test that tool call IDs in responses include embedded thought signatures only when preview features are enabled""" test_signature = "Co4CAdHtim/rWgXbz2Ghp4tShzLeMASrPw6JJyYIC3cbVyZnKzU3uv8/wVzyS2sKRPL2m8QQHHXbNQhEEz500G7n/4ZMmksdTtfQcJMoT76S1DGwhnAiLwTgWCNXs3lEb4M19EVYoWFxhrH5Lr9YMIquoU9U4paydGwvZyIyigamIg4B6WnxrRsf0KZV12gJed0DZuKczvOFtHz3zUnmZRlOiTzd5gBVyQM+5jv1VI8m4WUKd6cN/5a5ZvaA0ggiO6kdVhlpIVs7GczSEVJD8KH4u02X7VSnb7CvykqDntZzV0y8rZFBEFGKrChmeHlWXP4D1IB3F9KQyhuLgWImMzg4BajKVxxMU737JGnNISy5" - parts_with_signature = [ - HttpxPartType( - functionCall={ - "name": "get_current_temperature", - "args": {"location": "Paris"}, - }, - thoughtSignature=test_signature, + # Save original state + original_flag = litellm.enable_preview_features + litellm.enable_preview_features = enable_preview_features + + try: + parts_with_signature = [ + HttpxPartType( + functionCall={ + "name": "get_current_temperature", + "args": {"location": "Paris"}, + }, + thoughtSignature=test_signature, + ) + ] + + function, tools, _ = VertexGeminiConfig._transform_parts( + parts=parts_with_signature, + cumulative_tool_call_idx=0, + is_function_call=False, ) - ] - function, tools, _ = VertexGeminiConfig._transform_parts( - parts=parts_with_signature, - cumulative_tool_call_idx=0, - is_function_call=False, - ) + # Verify tool call exists + assert tools is not None + assert len(tools) == 1 + tool_call_id = tools[0]["id"] + + # Verify signature is always in provider_specific_fields + assert tools[0].get("provider_specific_fields", {}).get("thought_signature") == test_signature - # Verify tool call ID includes thought signature - assert tools is not None - assert len(tools) == 1 - tool_call_id = tools[0]["id"] - assert THOUGHT_SIGNATURE_SEPARATOR in tool_call_id - - # Verify we can decode it using the factory function - tool_obj = {"id": tool_call_id, "type": "function"} - decoded_sig = _get_thought_signature_from_tool(tool_obj) - assert decoded_sig == test_signature + if enable_preview_features: + # When preview features enabled, signature should be embedded in ID + assert THOUGHT_SIGNATURE_SEPARATOR in tool_call_id + # Verify we can decode it using the factory function + tool_obj = {"id": tool_call_id, "type": "function"} + decoded_sig = _get_thought_signature_from_tool(tool_obj) + assert decoded_sig == test_signature + else: + # When preview features disabled, signature should NOT be embedded in ID + assert THOUGHT_SIGNATURE_SEPARATOR not in tool_call_id + # But we can still extract from provider_specific_fields + tool_obj = {"id": tool_call_id, "type": "function", "provider_specific_fields": {"thought_signature": test_signature}} + decoded_sig = _get_thought_signature_from_tool(tool_obj) + assert decoded_sig == test_signature + finally: + # Restore original state + litellm.enable_preview_features = original_flag def test_get_thought_signature_backward_compatibility(): @@ -168,97 +193,157 @@ def test_convert_to_gemini_with_embedded_signature(): assert gemini_parts[0]["thoughtSignature"] == test_signature -def test_openai_client_e2e_flow(): +@pytest.mark.parametrize("enable_preview_features", [True, False]) +def test_openai_client_e2e_flow(enable_preview_features): """ End-to-end test simulating OpenAI client usage: 1. LiteLLM receives response from Gemini with thought signature - 2. LiteLLM embeds signature in tool call ID + 2. LiteLLM embeds signature in tool call ID (if preview features enabled) 3. OpenAI client sends message back with same tool call ID - 4. LiteLLM extracts signature from ID and sends to Gemini + 4. LiteLLM extracts signature from ID/provider_specific_fields and sends to Gemini """ test_signature = "Co4CAdHtim/rWgXbz2Ghp4tShzLeMASrPw6JJyYIC3cbVyZnKzU3uv8/wVzyS2sKRPL2m8QQHHXbNQhEEz500G7n/4ZMmksdTtfQcJMoT76S1DGwhnAiLwTgWCNXs3lEb4M19EVYoWFxhrH5Lr9YMIquoU9U4paydGwvZyIyigamIg4B6WnxrRsf0KZV12gJed0DZuKczvOFtHz3zUnmZRlOiTzd5gBVyQM+5jv1VI8m4WUKd6cN/5a5ZvaA0ggiO6kdVhlpIVs7GczSEVJD8KH4u02X7VSnb7CvykqDntZzV0y8rZFBEFGKrChmeHlWXP4D1IB3F9KQyhuLgWImMzg4BajKVxxMU737JGnNISy5" - # Step 1: Gemini returns function call with thought signature - gemini_parts = [ - HttpxPartType( - functionCall={ - "name": "get_current_temperature", - "args": {"location": "Paris"}, - }, - thoughtSignature=test_signature, - ) - ] + # Save original state + original_flag = litellm.enable_preview_features + litellm.enable_preview_features = enable_preview_features - # Step 2: LiteLLM transforms to OpenAI format with embedded signature - function, tools, _ = VertexGeminiConfig._transform_parts( - parts=gemini_parts, - cumulative_tool_call_idx=0, - is_function_call=False, - ) - - assert tools is not None - assert len(tools) == 1 - tool_call_id = tools[0]["id"] - assert THOUGHT_SIGNATURE_SEPARATOR in tool_call_id - - # Step 3: OpenAI client sends back assistant message (preserves tool_call_id) - openai_assistant_message = { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": tool_call_id, # Preserved from response - "type": "function", - "function": { + try: + # Step 1: Gemini returns function call with thought signature + gemini_parts = [ + HttpxPartType( + functionCall={ "name": "get_current_temperature", - "arguments": '{"location": "Paris"}', + "args": {"location": "Paris"}, }, + thoughtSignature=test_signature, + ) + ] + + # Step 2: LiteLLM transforms to OpenAI format + function, tools, _ = VertexGeminiConfig._transform_parts( + parts=gemini_parts, + cumulative_tool_call_idx=0, + is_function_call=False, + ) + + assert tools is not None + assert len(tools) == 1 + tool_call_id = tools[0]["id"] + + if enable_preview_features: + # When preview features enabled, signature should be embedded in ID + assert THOUGHT_SIGNATURE_SEPARATOR in tool_call_id + else: + # When preview features disabled, signature should NOT be embedded in ID + assert THOUGHT_SIGNATURE_SEPARATOR not in tool_call_id + + # Step 3: OpenAI client sends back assistant message + # For the disabled case, we simulate that the client might have provider_specific_fields + # or we use the embedded ID if preview features were enabled + if enable_preview_features: + openai_assistant_message = { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": tool_call_id, # Preserved from response (with embedded signature) + "type": "function", + "function": { + "name": "get_current_temperature", + "arguments": '{"location": "Paris"}', + }, + } + ], + } + else: + # When preview features disabled, simulate that provider_specific_fields might be preserved + # (though in real OpenAI client usage, this might not happen) + # For this test, we'll use provider_specific_fields to show extraction still works + openai_assistant_message = { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": tool_call_id, # ID without embedded signature + "type": "function", + "function": { + "name": "get_current_temperature", + "arguments": '{"location": "Paris"}', + }, + "provider_specific_fields": {"thought_signature": test_signature}, + } + ], } - ], - } - # Step 4: LiteLLM converts back to Gemini format, extracting signature - gemini_parts_converted = convert_to_gemini_tool_call_invoke( - openai_assistant_message - ) + # Step 4: LiteLLM converts back to Gemini format, extracting signature + gemini_parts_converted = convert_to_gemini_tool_call_invoke( + openai_assistant_message + ) - # Verify signature is preserved through the round trip - assert len(gemini_parts_converted) == 1 - assert "thoughtSignature" in gemini_parts_converted[0] - assert gemini_parts_converted[0]["thoughtSignature"] == test_signature + # Verify signature is preserved through the round trip + assert len(gemini_parts_converted) == 1 + assert "thoughtSignature" in gemini_parts_converted[0] + assert gemini_parts_converted[0]["thoughtSignature"] == test_signature + finally: + # Restore original state + litellm.enable_preview_features = original_flag -def test_parallel_tool_calls_with_signatures(): +@pytest.mark.parametrize("enable_preview_features", [True, False]) +def test_parallel_tool_calls_with_signatures(enable_preview_features): """Test that parallel tool calls preserve signatures correctly""" signature1 = "signature_for_first_call" # Only first call has signature (Gemini behavior for parallel calls) - gemini_parts = [ - HttpxPartType( - functionCall={"name": "get_temperature", "args": {"location": "Paris"}}, - thoughtSignature=signature1, - ), - HttpxPartType( - functionCall={"name": "get_temperature", "args": {"location": "London"}}, - # No signature for second parallel call - ), - ] + # Save original state + original_flag = litellm.enable_preview_features + litellm.enable_preview_features = enable_preview_features - function, tools, _ = VertexGeminiConfig._transform_parts( - parts=gemini_parts, - cumulative_tool_call_idx=0, - is_function_call=False, - ) + try: + gemini_parts = [ + HttpxPartType( + functionCall={"name": "get_temperature", "args": {"location": "Paris"}}, + thoughtSignature=signature1, + ), + HttpxPartType( + functionCall={"name": "get_temperature", "args": {"location": "London"}}, + # No signature for second parallel call + ), + ] - assert tools is not None - assert len(tools) == 2 + function, tools, _ = VertexGeminiConfig._transform_parts( + parts=gemini_parts, + cumulative_tool_call_idx=0, + is_function_call=False, + ) - # First tool call has signature in ID - assert THOUGHT_SIGNATURE_SEPARATOR in tools[0]["id"] - sig1 = _get_thought_signature_from_tool({"id": tools[0]["id"], "type": "function"}) - assert sig1 == signature1 + assert tools is not None + assert len(tools) == 2 - # Second tool call has no signature in ID - assert THOUGHT_SIGNATURE_SEPARATOR not in tools[1]["id"] - sig2 = _get_thought_signature_from_tool({"id": tools[1]["id"], "type": "function"}) - assert sig2 is None + # First tool call should have signature in provider_specific_fields + assert tools[0].get("provider_specific_fields", {}).get("thought_signature") == signature1 + + if enable_preview_features: + # When preview features enabled, first tool call has signature in ID + assert THOUGHT_SIGNATURE_SEPARATOR in tools[0]["id"] + sig1 = _get_thought_signature_from_tool({"id": tools[0]["id"], "type": "function"}) + assert sig1 == signature1 + else: + # When preview features disabled, signature should NOT be in ID + assert THOUGHT_SIGNATURE_SEPARATOR not in tools[0]["id"] + # But we can extract from provider_specific_fields + sig1 = _get_thought_signature_from_tool({ + "id": tools[0]["id"], + "type": "function", + "provider_specific_fields": {"thought_signature": signature1} + }) + assert sig1 == signature1 + + # Second tool call has no signature in ID (regardless of flag) + assert THOUGHT_SIGNATURE_SEPARATOR not in tools[1]["id"] + sig2 = _get_thought_signature_from_tool({"id": tools[1]["id"], "type": "function"}) + assert sig2 is None + finally: + # Restore original state + litellm.enable_preview_features = original_flag From 099ccf56a746c9bca8afa4c0c62fdb52fa5abfa9 Mon Sep 17 00:00:00 2001 From: Richard Song <9144514+richardmcsong@users.noreply.github.com> Date: Wed, 3 Dec 2025 00:57:07 -0500 Subject: [PATCH 18/37] Refactor add_schema_to_components to move definitions to components/schemas and add corresponding unit test (#17389) --- .../proxy/common_utils/custom_openapi_spec.py | 2 +- .../common_utils/test_custom_openapi_spec.py | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/common_utils/custom_openapi_spec.py b/litellm/proxy/common_utils/custom_openapi_spec.py index c448742f6dc..69472c2cda4 100644 --- a/litellm/proxy/common_utils/custom_openapi_spec.py +++ b/litellm/proxy/common_utils/custom_openapi_spec.py @@ -72,7 +72,7 @@ class CustomOpenAPISpec: openapi_schema["components"]["schemas"] = {} # Add the schema - openapi_schema["components"]["schemas"][schema_name] = schema_def + CustomOpenAPISpec._move_defs_to_components(openapi_schema, {schema_name: schema_def}) @staticmethod def add_request_body_to_paths(openapi_schema: Dict[str, Any], paths: List[str], schema_ref: str) -> None: diff --git a/tests/test_litellm/proxy/common_utils/test_custom_openapi_spec.py b/tests/test_litellm/proxy/common_utils/test_custom_openapi_spec.py index 7549b4259af..5fef35eb821 100644 --- a/tests/test_litellm/proxy/common_utils/test_custom_openapi_spec.py +++ b/tests/test_litellm/proxy/common_utils/test_custom_openapi_spec.py @@ -90,6 +90,35 @@ class TestCustomOpenAPISpec: ) assert result == base_openapi_schema +def test_defs_rewritten_in_add_schema_to_components(): + """ + Test that defs are rewritten to components/schemas in add_schema_to_components. + """ + + openapi_schema = {} + schema_name = "SchemaName" + schema_def = { + "type": "object", + "properties": { + "messages": { + "type": "array", + "items": { + "anyOf": [ + {"$ref": "#/$defs/UserMessage"}, + {"$ref": "#/$defs/AssistantMessage"} + ] + } + } + }, + "$defs": { + "UserMessage": {"type": "object"}, + "AssistantMessage": {"type": "object"} + } + } + CustomOpenAPISpec.add_schema_to_components(openapi_schema=openapi_schema, schema_name=schema_name, schema_def=schema_def) + assert "$defs" not in openapi_schema + assert openapi_schema["components"]["schemas"]["SchemaName"]["properties"]["messages"]["items"]["anyOf"][0]["$ref"] == "#/components/schemas/UserMessage" + assert openapi_schema["components"]["schemas"]["SchemaName"]["properties"]["messages"]["items"]["anyOf"][1]["$ref"] == "#/components/schemas/AssistantMessage" def test_move_defs_to_components(): """ From f22bc0aab20e9b5336e73d67ba1631176cbacfd6 Mon Sep 17 00:00:00 2001 From: Matt Greathouse Date: Wed, 3 Dec 2025 01:00:19 -0500 Subject: [PATCH 19/37] Support Deepseek 3.2 with Reasoning (#17384) * Add openrouter/deepseek/deepseek-v3.2 * Added deepseek-provided v3.2 * Allow reasoning effort param for openrouter models that support it * Added tests --- .../llms/openrouter/chat/transformation.py | 15 ++++++++++ model_prices_and_context_window.json | 30 +++++++++++++++++++ .../test_openrouter_chat_transformation.py | 27 +++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/litellm/llms/openrouter/chat/transformation.py b/litellm/llms/openrouter/chat/transformation.py index f1eafe4e294..b5610852fd2 100644 --- a/litellm/llms/openrouter/chat/transformation.py +++ b/litellm/llms/openrouter/chat/transformation.py @@ -10,6 +10,7 @@ from enum import Enum from typing import Any, AsyncIterator, Iterator, List, Optional, Tuple, Union, cast import httpx +import litellm from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator from litellm.llms.base_llm.chat.transformation import BaseLLMException @@ -28,6 +29,20 @@ class CacheControlSupportedModels(str, Enum): class OpenrouterConfig(OpenAIGPTConfig): + def get_supported_openai_params(self, model: str) -> list: + """ + Allow reasoning parameters for models flagged as reasoning-capable. + """ + supported_params = super().get_supported_openai_params(model=model) + try: + if litellm.supports_reasoning( + model=model, custom_llm_provider="openrouter" + ) or litellm.supports_reasoning(model=model): + supported_params.append("reasoning_effort") + except Exception: + pass + return list(dict.fromkeys(supported_params)) + def map_openai_params( self, non_default_params: dict, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 19ed734c5f8..f82abce525f 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -9629,6 +9629,21 @@ "supports_prompt_caching": true, "supports_tool_choice": true }, + "deepseek/deepseek-v3.2": { + "input_cost_per_token": 2.8e-07, + "input_cost_per_token_cache_hit": 2.8e-08, + "litellm_provider": "deepseek", + "max_input_tokens": 163840, + "max_output_tokens": 163840, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 4e-07, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, "deepseek.v3-v1:0": { "input_cost_per_token": 5.8e-07, "litellm_provider": "bedrock_converse", @@ -20565,6 +20580,21 @@ "supports_reasoning": true, "supports_tool_choice": true }, + "openrouter/deepseek/deepseek-v3.2": { + "input_cost_per_token": 2.8e-07, + "input_cost_per_token_cache_hit": 2.8e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 163840, + "max_output_tokens": 163840, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 4e-07, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, "openrouter/deepseek/deepseek-v3.2-exp": { "input_cost_per_token": 2e-07, "input_cost_per_token_cache_hit": 2e-08, diff --git a/tests/test_litellm/llms/openrouter/chat/test_openrouter_chat_transformation.py b/tests/test_litellm/llms/openrouter/chat/test_openrouter_chat_transformation.py index 64ac299fd79..d5a73b3fd12 100644 --- a/tests/test_litellm/llms/openrouter/chat/test_openrouter_chat_transformation.py +++ b/tests/test_litellm/llms/openrouter/chat/test_openrouter_chat_transformation.py @@ -489,3 +489,30 @@ def test_openrouter_cost_tracking_streaming(): # Verify cost field is preserved in the Usage object - this is the key data for cost tracking # The chunk_parser converts the dict to a Usage Pydantic model which includes the cost field assert result2.usage.cost == 0.0001 + + +def test_openrouter_reasoning_models_allow_reasoning_effort_param(): + """ + OpenRouter reasoning-capable models should accept the reasoning_effort param. + """ + config = OpenrouterConfig() + + supported_params = config.get_supported_openai_params( + model="openrouter/deepseek/deepseek-v3.2" + ) + + assert "reasoning_effort" in supported_params + assert supported_params.count("reasoning_effort") == 1 + + +def test_openrouter_non_reasoning_models_do_not_add_reasoning_effort(): + """ + Models without reasoning support should not gain reasoning-specific params. + """ + config = OpenrouterConfig() + + supported_params = config.get_supported_openai_params( + model="openrouter/anthropic/claude-3-5-haiku" + ) + + assert "reasoning_effort" not in supported_params From ae633184f72d8a12ae442a03ce86f51fc4f649fc Mon Sep 17 00:00:00 2001 From: mossbanay <2216177+mossbanay@users.noreply.github.com> Date: Wed, 3 Dec 2025 17:02:57 +1100 Subject: [PATCH 20/37] Add model price & details for Bedrock model global.anthropic.claude-opus-4-5-20251101-v1:0 (#17380) --- model_prices_and_context_window.json | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index f82abce525f..999c88dde05 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -23850,6 +23850,32 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 159 }, + "global.anthropic.claude-opus-4-5-20251101-v1:0": { + "cache_creation_input_token_cost": 6.25e-06, + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 5e-06, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 2.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "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, + "tool_use_system_prompt_tokens": 159 + }, "us.anthropic.claude-sonnet-4-20250514-v1:0": { "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, From 6b5ad5d5a6c9cce7008d0eb57258a826e6d82808 Mon Sep 17 00:00:00 2001 From: Ali Saleh Date: Wed, 3 Dec 2025 11:03:54 +0500 Subject: [PATCH 21/37] docs: Update Instructions For Phoenix Integration (#17373) --- .../docs/observability/phoenix_integration.md | 79 +++++++++++++++---- 1 file changed, 64 insertions(+), 15 deletions(-) diff --git a/docs/my-website/docs/observability/phoenix_integration.md b/docs/my-website/docs/observability/phoenix_integration.md index ad337439934..898d780668d 100644 --- a/docs/my-website/docs/observability/phoenix_integration.md +++ b/docs/my-website/docs/observability/phoenix_integration.md @@ -6,7 +6,7 @@ Open source tracing and evaluation platform :::tip -This is community maintained, Please make an issue if you run into a bug +This is community maintained. Please make an issue if you run into a bug: https://github.com/BerriAI/litellm ::: @@ -31,19 +31,16 @@ litellm.callbacks = ["arize_phoenix"] import litellm import os -os.environ["PHOENIX_API_KEY"] = "" # Necessary only using Phoenix Cloud -os.environ["PHOENIX_COLLECTOR_HTTP_ENDPOINT"] = "" # The URL of your Phoenix OSS instance e.g. http://localhost:6006/v1/traces -os.environ["PHOENIX_PROJECT_NAME"]="litellm" # OPTIONAL: you can configure project names, otherwise traces would go to "default" project +# Set env variables +os.environ["PHOENIX_API_KEY"] = "d0*****" # Set the Phoenix API key here. It is necessary only when using Phoenix Cloud. +os.environ["PHOENIX_COLLECTOR_HTTP_ENDPOINT"] = "https://app.phoenix.arize.com/s//v1/traces" # Set the URL of your Phoenix OSS instance, otherwise tracer would use https://app.phoenix.arize.com/v1/traces for Phoenix Cloud. +os.environ["PHOENIX_PROJECT_NAME"] = "litellm" # Configure the project name, otherwise traces would go to "default" project. +os.environ['OPENAI_API_KEY'] = "fake-key" # Set the OpenAI API key here. -# This defaults to https://app.phoenix.arize.com/v1/traces for Phoenix Cloud - -# LLM API Keys -os.environ['OPENAI_API_KEY']="" - -# set arize as a callback, litellm will send the data to arize +# Set arize_phoenix as a callback & LiteLLM will send the data to Phoenix. litellm.callbacks = ["arize_phoenix"] - -# openai call + +# OpenAI call response = litellm.completion( model="gpt-3.5-turbo", messages=[ @@ -52,8 +49,9 @@ response = litellm.completion( ) ``` -### Using with LiteLLM Proxy +## Using with LiteLLM Proxy +1. Setup config.yaml ```yaml model_list: @@ -66,12 +64,63 @@ model_list: litellm_settings: callbacks: ["arize_phoenix"] +general_settings: + master_key: "sk-1234" + environment_variables: PHOENIX_API_KEY: "d0*****" - PHOENIX_COLLECTOR_ENDPOINT: "https://app.phoenix.arize.com/v1/traces" # OPTIONAL, for setting the GRPC endpoint - PHOENIX_COLLECTOR_HTTP_ENDPOINT: "https://app.phoenix.arize.com/v1/traces" # OPTIONAL, for setting the HTTP endpoint + PHOENIX_COLLECTOR_ENDPOINT: "https://app.phoenix.arize.com/s//v1/traces" # OPTIONAL - For setting the gRPC endpoint + PHOENIX_COLLECTOR_HTTP_ENDPOINT: "https://app.phoenix.arize.com/s//v1/traces" # OPTIONAL - For setting the HTTP endpoint ``` +2. Start the proxy + +```bash +litellm --config config.yaml +``` + +3. Test it! + +```bash +curl -X POST 'http://0.0.0.0:4000/chat/completions' \ +-H 'Content-Type: application/json' \ +-H 'Authorization: Bearer sk-1234' \ +-d '{ "model": "gpt-4o", "messages": [{"role": "user", "content": "Hi 👋 - i'm openai"}]}' +``` + +## Supported Phoenix Endpoints +Phoenix now supports multiple deployment types. The correct endpoint depends on which version of Phoenix Cloud you are using. + +**Phoenix Cloud (With Spaces - New Version)** +Use this if your Phoenix URL contains `/s/` path. + +```bash +https://app.phoenix.arize.com/s//v1/traces +``` + +**Phoenix Cloud (Legacy - Deprecated)** +Use this only if your deployment still shows the `/legacy` pattern. + +```bash +https://app.phoenix.arize.com/legacy/v1/traces +``` + +**Phoenix Cloud (Without Spaces - Old Version)** +Use this if your Phoenix Cloud URL does not contain `/s/` or `/legacy` path. + +```bash +https://app.phoenix.arize.com/v1/traces +``` + +**Self-Hosted Phoenix (Local Instance)** +Use this when running Phoenix on your machine or a private server. + +```bash +http://localhost:6006/v1/traces +``` + +Depending on which Phoenix Cloud version or deployment you are using, you should set the corresponding endpoint in `PHOENIX_COLLECTOR_HTTP_ENDPOINT` or `PHOENIX_COLLECTOR_ENDPOINT`. + ## Support & Talk to Founders - [Schedule Demo 👋](https://calendly.com/d/4mp-gd3-k5k/berriai-1-1-onboarding-litellm-hosted-version) From 566adebdec41d3f2790de33e43d93892f529f597 Mon Sep 17 00:00:00 2001 From: Mariano Hielpos <108539968+mhielpos-asapp@users.noreply.github.com> Date: Wed, 3 Dec 2025 03:06:51 -0300 Subject: [PATCH 22/37] update model_prices_and_context_window.json (#17376) * update model_prices_and_context_window.json * update * update --- ...odel_prices_and_context_window_backup.json | 88 +++++++++++++++++-- model_prices_and_context_window.json | 88 +++++++++++++++++-- 2 files changed, 160 insertions(+), 16 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 932508824af..464f9c185f9 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -10772,25 +10772,25 @@ "supports_tool_choice": true }, "ft:babbage-002": { - "input_cost_per_token": 4e-07, + "input_cost_per_token": 1.6e-06, "input_cost_per_token_batches": 2e-07, "litellm_provider": "text-completion-openai", "max_input_tokens": 16384, "max_output_tokens": 4096, "max_tokens": 16384, "mode": "completion", - "output_cost_per_token": 4e-07, + "output_cost_per_token": 1.6e-06, "output_cost_per_token_batches": 2e-07 }, "ft:davinci-002": { - "input_cost_per_token": 2e-06, + "input_cost_per_token": 1.2e-05, "input_cost_per_token_batches": 1e-06, "litellm_provider": "text-completion-openai", "max_input_tokens": 16384, "max_output_tokens": 4096, "max_tokens": 16384, "mode": "completion", - "output_cost_per_token": 2e-06, + "output_cost_per_token": 1.2e-05, "output_cost_per_token_batches": 1e-06 }, "ft:gpt-3.5-turbo": { @@ -10853,6 +10853,7 @@ "supports_tool_choice": true }, "ft:gpt-4o-2024-08-06": { + "cache_read_input_token_cost": 1.875e-06, "input_cost_per_token": 3.75e-06, "input_cost_per_token_batches": 1.875e-06, "litellm_provider": "openai", @@ -10865,6 +10866,7 @@ "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, + "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, @@ -10885,8 +10887,7 @@ "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true + "supports_tool_choice": true }, "ft:gpt-4o-mini-2024-07-18": { "cache_read_input_token_cost": 1.5e-07, @@ -10905,8 +10906,79 @@ "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true + "supports_tool_choice": true + }, + "ft:gpt-4.1-2025-04-14": { + "cache_read_input_token_cost": 7.5e-07, + "input_cost_per_token": 3e-06, + "input_cost_per_token_batches": 1.5e-06, + "litellm_provider": "openai", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 1.2e-05, + "output_cost_per_token_batches": 6e-06, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "ft:gpt-4.1-mini-2025-04-14": { + "cache_read_input_token_cost": 2e-07, + "input_cost_per_token": 8e-07, + "input_cost_per_token_batches": 4e-07, + "litellm_provider": "openai", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 3.2e-06, + "output_cost_per_token_batches": 1.6e-06, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "ft:gpt-4.1-nano-2025-04-14": { + "cache_read_input_token_cost": 5e-08, + "input_cost_per_token": 2e-07, + "input_cost_per_token_batches": 1e-07, + "litellm_provider": "openai", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 8e-07, + "output_cost_per_token_batches": 4e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "ft:o4-mini-2025-04-16": { + "cache_read_input_token_cost": 1e-06, + "input_cost_per_token": 4e-06, + "input_cost_per_token_batches": 2e-06, + "litellm_provider": "openai", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 1.6e-05, + "output_cost_per_token_batches": 8e-06, + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true }, "gemini-1.0-pro": { "input_cost_per_character": 1.25e-07, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 999c88dde05..dbaa60e0f1d 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -10852,25 +10852,25 @@ "supports_tool_choice": true }, "ft:babbage-002": { - "input_cost_per_token": 4e-07, + "input_cost_per_token": 1.6e-06, "input_cost_per_token_batches": 2e-07, "litellm_provider": "text-completion-openai", "max_input_tokens": 16384, "max_output_tokens": 4096, "max_tokens": 16384, "mode": "completion", - "output_cost_per_token": 4e-07, + "output_cost_per_token": 1.6e-06, "output_cost_per_token_batches": 2e-07 }, "ft:davinci-002": { - "input_cost_per_token": 2e-06, + "input_cost_per_token": 1.2e-05, "input_cost_per_token_batches": 1e-06, "litellm_provider": "text-completion-openai", "max_input_tokens": 16384, "max_output_tokens": 4096, "max_tokens": 16384, "mode": "completion", - "output_cost_per_token": 2e-06, + "output_cost_per_token": 1.2e-05, "output_cost_per_token_batches": 1e-06 }, "ft:gpt-3.5-turbo": { @@ -10933,6 +10933,7 @@ "supports_tool_choice": true }, "ft:gpt-4o-2024-08-06": { + "cache_read_input_token_cost": 1.875e-06, "input_cost_per_token": 3.75e-06, "input_cost_per_token_batches": 1.875e-06, "litellm_provider": "openai", @@ -10945,6 +10946,7 @@ "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, + "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, @@ -10965,8 +10967,7 @@ "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true + "supports_tool_choice": true }, "ft:gpt-4o-mini-2024-07-18": { "cache_read_input_token_cost": 1.5e-07, @@ -10985,8 +10986,79 @@ "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, - "supports_tool_choice": true, - "supports_vision": true + "supports_tool_choice": true + }, + "ft:gpt-4.1-2025-04-14": { + "cache_read_input_token_cost": 7.5e-07, + "input_cost_per_token": 3e-06, + "input_cost_per_token_batches": 1.5e-06, + "litellm_provider": "openai", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 1.2e-05, + "output_cost_per_token_batches": 6e-06, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "ft:gpt-4.1-mini-2025-04-14": { + "cache_read_input_token_cost": 2e-07, + "input_cost_per_token": 8e-07, + "input_cost_per_token_batches": 4e-07, + "litellm_provider": "openai", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 3.2e-06, + "output_cost_per_token_batches": 1.6e-06, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "ft:gpt-4.1-nano-2025-04-14": { + "cache_read_input_token_cost": 5e-08, + "input_cost_per_token": 2e-07, + "input_cost_per_token_batches": 1e-07, + "litellm_provider": "openai", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 8e-07, + "output_cost_per_token_batches": 4e-07, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "ft:o4-mini-2025-04-16": { + "cache_read_input_token_cost": 1e-06, + "input_cost_per_token": 4e-06, + "input_cost_per_token_batches": 2e-06, + "litellm_provider": "openai", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 1.6e-05, + "output_cost_per_token_batches": 8e-06, + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true }, "gemini-1.0-pro": { "input_cost_per_character": 1.25e-07, From 17faea96bb75be88f6761398e3ad2130a0d29cfc Mon Sep 17 00:00:00 2001 From: Jonathan Yang Date: Wed, 3 Dec 2025 07:09:57 +0100 Subject: [PATCH 23/37] fix: conditionally pass enable_cleanup_closed to aiohttp TCPConnector (#17367) * fix: conditionally pass enable_cleanup_closed to aiohttp TCPConnector Fixes deprecation warning on Python 3.12.7+ and 3.13.1+ where enable_cleanup_closed is no longer needed since the underlying CPython SSL connection leak bug was fixed. See: https://github.com/python/cpython/pull/118960 * chore: add aiohttp source reference to AIOHTTP_NEEDS_CLEANUP_CLOSED --- litellm/constants.py | 7 +++++++ litellm/llms/custom_httpx/http_handler.py | 3 ++- litellm/proxy/proxy_server.py | 3 ++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/litellm/constants.py b/litellm/constants.py index 6a67a9a0e18..db617a2e475 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1,4 +1,5 @@ import os +import sys from typing import List, Literal DEFAULT_HEALTH_CHECK_PROMPT = str( @@ -103,6 +104,12 @@ _DEFAULT_TTL_FOR_HTTPX_CLIENTS = 3600 # 1 hour, re-use the same httpx client fo AIOHTTP_CONNECTOR_LIMIT = int(os.getenv("AIOHTTP_CONNECTOR_LIMIT", 0)) AIOHTTP_KEEPALIVE_TIMEOUT = int(os.getenv("AIOHTTP_KEEPALIVE_TIMEOUT", 120)) AIOHTTP_TTL_DNS_CACHE = int(os.getenv("AIOHTTP_TTL_DNS_CACHE", 300)) +# enable_cleanup_closed is only needed for Python versions with the SSL leak bug +# Fixed in Python 3.12.7+ and 3.13.1+ (see https://github.com/python/cpython/pull/118960) +# Reference: https://github.com/aio-libs/aiohttp/blob/master/aiohttp/connector.py#L74-L78 +AIOHTTP_NEEDS_CLEANUP_CLOSED = ( + (3, 13, 0) <= sys.version_info < (3, 13, 1) or sys.version_info < (3, 12, 7) +) # WebSocket constants # Default to None (unlimited) to match OpenAI's official agents SDK behavior diff --git a/litellm/llms/custom_httpx/http_handler.py b/litellm/llms/custom_httpx/http_handler.py index c35e910ab08..b06e8463abd 100644 --- a/litellm/llms/custom_httpx/http_handler.py +++ b/litellm/llms/custom_httpx/http_handler.py @@ -17,6 +17,7 @@ from litellm.constants import ( _DEFAULT_TTL_FOR_HTTPX_CLIENTS, AIOHTTP_CONNECTOR_LIMIT, AIOHTTP_KEEPALIVE_TIMEOUT, + AIOHTTP_NEEDS_CLEANUP_CLOSED, AIOHTTP_TTL_DNS_CACHE, DEFAULT_SSL_CIPHERS, ) @@ -798,7 +799,7 @@ class AsyncHTTPHandler: limit=AIOHTTP_CONNECTOR_LIMIT, keepalive_timeout=AIOHTTP_KEEPALIVE_TIMEOUT, ttl_dns_cache=AIOHTTP_TTL_DNS_CACHE, - enable_cleanup_closed=True, + enable_cleanup_closed=AIOHTTP_NEEDS_CLEANUP_CLOSED, **connector_kwargs, ), trust_env=trust_env, diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 99789162318..a1e01caddc1 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -32,6 +32,7 @@ from litellm._uuid import uuid from litellm.constants import ( AIOHTTP_CONNECTOR_LIMIT, AIOHTTP_KEEPALIVE_TIMEOUT, + AIOHTTP_NEEDS_CLEANUP_CLOSED, AIOHTTP_TTL_DNS_CACHE, AUDIO_SPEECH_CHUNK_SIZE, BASE_MCP_ROUTE, @@ -635,7 +636,7 @@ async def _initialize_shared_aiohttp_session(): limit=AIOHTTP_CONNECTOR_LIMIT, keepalive_timeout=AIOHTTP_KEEPALIVE_TIMEOUT, ttl_dns_cache=AIOHTTP_TTL_DNS_CACHE, - enable_cleanup_closed=True, + enable_cleanup_closed=AIOHTTP_NEEDS_CLEANUP_CLOSED, ) session = ClientSession(connector=connector) From 43dd9e4a90281e42fd1015cd72cbca5280fc4e4c Mon Sep 17 00:00:00 2001 From: Jonathan Yang Date: Wed, 3 Dec 2025 07:12:55 +0100 Subject: [PATCH 24/37] fix: replace deprecated .dict() with .model_dump() in streaming_handler (#17359) Replace Pydantic v1 `.dict()` method with v2 `.model_dump()` to fix PydanticDeprecatedSince20 warnings. The `.dict()` method is deprecated in Pydantic v2 and will be removed in v3. Fixes #5987 --- .../litellm_core_utils/streaming_handler.py | 53 +++++++++++-------- 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/litellm/litellm_core_utils/streaming_handler.py b/litellm/litellm_core_utils/streaming_handler.py index 4d8e109d882..a7f460fab59 100644 --- a/litellm/litellm_core_utils/streaming_handler.py +++ b/litellm/litellm_core_utils/streaming_handler.py @@ -96,9 +96,9 @@ class CustomStreamWrapper: self.system_fingerprint: Optional[str] = None self.received_finish_reason: Optional[str] = None - self.intermittent_finish_reason: Optional[str] = ( - None # finish reasons that show up mid-stream - ) + self.intermittent_finish_reason: Optional[ + str + ] = None # finish reasons that show up mid-stream self.special_tokens = [ "<|assistant|>", "<|system|>", @@ -735,7 +735,7 @@ class CustomStreamWrapper: and completion_obj["function_call"] is not None ) or ( - "tool_calls" in model_response.choices[0].delta + "tool_calls" in model_response.choices[0].delta and model_response.choices[0].delta["tool_calls"] is not None ) or ( @@ -889,7 +889,6 @@ class CustomStreamWrapper: ## check if openai/azure chunk original_chunk = response_obj.get("original_chunk", None) if original_chunk: - if len(original_chunk.choices) > 0: choices = [] for choice in original_chunk.choices: @@ -906,7 +905,6 @@ class CustomStreamWrapper: print_verbose(f"choices in streaming: {choices}") setattr(model_response, "choices", choices) else: - return model_response.system_fingerprint = ( original_chunk.system_fingerprint @@ -1435,9 +1433,9 @@ class CustomStreamWrapper: _json_delta = delta.model_dump() print_verbose(f"_json_delta: {_json_delta}") if "role" not in _json_delta or _json_delta["role"] is None: - _json_delta["role"] = ( - "assistant" # mistral's api returns role as None - ) + _json_delta[ + "role" + ] = "assistant" # mistral's api returns role as None if "tool_calls" in _json_delta and isinstance( _json_delta["tool_calls"], list ): @@ -1533,7 +1531,7 @@ class CustomStreamWrapper: async def _call_post_streaming_deployment_hook(self, chunk): """ Call the post-call streaming deployment hook for callbacks. - + This allows callbacks to modify streaming chunks before they're returned. """ try: @@ -1544,15 +1542,17 @@ class CustomStreamWrapper: # Get request kwargs from logging object request_data = self.logging_obj.model_call_details call_type_str = self.logging_obj.call_type - + try: typed_call_type = CallTypes(call_type_str) except ValueError: typed_call_type = None - + # Call hooks for all callbacks for callback in litellm.callbacks: - if isinstance(callback, CustomLogger) and hasattr(callback, "async_post_call_streaming_deployment_hook"): + if isinstance(callback, CustomLogger) and hasattr( + callback, "async_post_call_streaming_deployment_hook" + ): result = await callback.async_post_call_streaming_deployment_hook( request_data=request_data, response_chunk=chunk, @@ -1560,11 +1560,14 @@ class CustomStreamWrapper: ) if result is not None: chunk = result - + return chunk except Exception as e: from litellm._logging import verbose_logger - verbose_logger.exception(f"Error in post-call streaming deployment hook: {str(e)}") + + verbose_logger.exception( + f"Error in post-call streaming deployment hook: {str(e)}" + ) return chunk def cache_streaming_response(self, processed_chunk, cache_hit: bool): @@ -1687,7 +1690,7 @@ class CustomStreamWrapper: response, "usage" ): # remove usage from chunk, only send on final chunk # Convert the object to a dictionary - obj_dict = response.dict() + obj_dict = response.model_dump() # Remove an attribute (e.g., 'attr2') if "usage" in obj_dict: @@ -1852,7 +1855,7 @@ class CustomStreamWrapper: processed_chunk, "usage" ): # remove usage from chunk, only send on final chunk # Convert the object to a dictionary - obj_dict = processed_chunk.dict() + obj_dict = processed_chunk.model_dump() # Remove an attribute (e.g., 'attr2') if "usage" in obj_dict: @@ -1872,11 +1875,15 @@ class CustomStreamWrapper: if self.sent_last_chunk is True and self.stream_options is None: usage = calculate_total_usage(chunks=self.chunks) processed_chunk._hidden_params["usage"] = usage - + # Call post-call streaming deployment hook for final chunk if self.sent_last_chunk is True: - processed_chunk = await self._call_post_streaming_deployment_hook(processed_chunk) - + processed_chunk = ( + await self._call_post_streaming_deployment_hook( + processed_chunk + ) + ) + return processed_chunk raise StopAsyncIteration else: # temporary patch for non-aiohttp async calls @@ -1890,9 +1897,9 @@ class CustomStreamWrapper: chunk = next(self.completion_stream) if chunk is not None and chunk != b"": print_verbose(f"PROCESSED CHUNK PRE CHUNK CREATOR: {chunk}") - processed_chunk: Optional[ModelResponseStream] = ( - self.chunk_creator(chunk=chunk) - ) + processed_chunk: Optional[ + ModelResponseStream + ] = self.chunk_creator(chunk=chunk) print_verbose( f"PROCESSED CHUNK POST CHUNK CREATOR: {processed_chunk}" ) From e289f5e454140042eda28ca27b8797078e2fe9f6 Mon Sep 17 00:00:00 2001 From: Deepak Tammali <45919384+deepaktammali@users.noreply.github.com> Date: Wed, 3 Dec 2025 11:44:48 +0530 Subject: [PATCH 25/37] feat: make streaming chunk size configurable in bedrock converse and invoke handlers (#17357) --- litellm/llms/bedrock/chat/converse_handler.py | 10 ++++++++-- litellm/llms/bedrock/chat/invoke_handler.py | 20 ++++++++++++------- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/litellm/llms/bedrock/chat/converse_handler.py b/litellm/llms/bedrock/chat/converse_handler.py index fd1f6f0c893..d5bd054118d 100644 --- a/litellm/llms/bedrock/chat/converse_handler.py +++ b/litellm/llms/bedrock/chat/converse_handler.py @@ -29,6 +29,7 @@ def make_sync_call( logging_obj: LiteLLMLoggingObject, json_mode: Optional[bool] = False, fake_stream: bool = False, + stream_chunk_size: int = 1024, ): if client is None: client = _get_httpx_client() # Create a new client if none provided @@ -66,7 +67,7 @@ def make_sync_call( ) else: decoder = AWSEventStreamDecoder(model=model) - completion_stream = decoder.iter_bytes(response.iter_bytes(chunk_size=1024)) + completion_stream = decoder.iter_bytes(response.iter_bytes(chunk_size=stream_chunk_size)) # LOGGING logging_obj.post_call( @@ -102,6 +103,7 @@ class BedrockConverseLLM(BaseAWSLLM): fake_stream: bool = False, json_mode: Optional[bool] = False, api_key: Optional[str] = None, + stream_chunk_size: int = 1024, ) -> CustomStreamWrapper: request_data = await litellm.AmazonConverseConfig()._async_transform_request( model=model, @@ -143,6 +145,7 @@ class BedrockConverseLLM(BaseAWSLLM): logging_obj=logging_obj, fake_stream=fake_stream, json_mode=json_mode, + stream_chunk_size=stream_chunk_size, ) streaming_response = CustomStreamWrapper( completion_stream=completion_stream, @@ -260,6 +263,7 @@ class BedrockConverseLLM(BaseAWSLLM): ): ## SETUP ## stream = optional_params.pop("stream", None) + stream_chunk_size = optional_params.pop("stream_chunk_size", 1024) unencoded_model_id = optional_params.pop("model_id", None) fake_stream = optional_params.pop("fake_stream", False) json_mode = optional_params.get("json_mode", False) @@ -356,7 +360,8 @@ class BedrockConverseLLM(BaseAWSLLM): json_mode=json_mode, fake_stream=fake_stream, credentials=credentials, - api_key=api_key + api_key=api_key, + stream_chunk_size=stream_chunk_size, ) # type: ignore ### ASYNC COMPLETION return self.async_completion( @@ -433,6 +438,7 @@ class BedrockConverseLLM(BaseAWSLLM): logging_obj=logging_obj, json_mode=json_mode, fake_stream=fake_stream, + stream_chunk_size=stream_chunk_size, ) streaming_response = CustomStreamWrapper( completion_stream=completion_stream, diff --git a/litellm/llms/bedrock/chat/invoke_handler.py b/litellm/llms/bedrock/chat/invoke_handler.py index 7a960fd45d2..5e33a266449 100644 --- a/litellm/llms/bedrock/chat/invoke_handler.py +++ b/litellm/llms/bedrock/chat/invoke_handler.py @@ -192,6 +192,7 @@ async def make_call( fake_stream: bool = False, json_mode: Optional[bool] = False, bedrock_invoke_provider: Optional[litellm.BEDROCK_INVOKE_PROVIDERS_LITERAL] = None, + stream_chunk_size: int = 1024, ): try: if client is None: @@ -235,7 +236,7 @@ async def make_call( json_mode=json_mode, ) completion_stream = decoder.aiter_bytes( - response.aiter_bytes(chunk_size=1024) + response.aiter_bytes(chunk_size=stream_chunk_size) ) elif bedrock_invoke_provider == "deepseek_r1": decoder = AmazonDeepSeekR1StreamDecoder( @@ -243,12 +244,12 @@ async def make_call( sync_stream=False, ) completion_stream = decoder.aiter_bytes( - response.aiter_bytes(chunk_size=1024) + response.aiter_bytes(chunk_size=stream_chunk_size) ) else: decoder = AWSEventStreamDecoder(model=model) completion_stream = decoder.aiter_bytes( - response.aiter_bytes(chunk_size=1024) + response.aiter_bytes(chunk_size=stream_chunk_size) ) # LOGGING @@ -281,6 +282,7 @@ def make_sync_call( fake_stream: bool = False, json_mode: Optional[bool] = False, bedrock_invoke_provider: Optional[litellm.BEDROCK_INVOKE_PROVIDERS_LITERAL] = None, + stream_chunk_size: int = 1024, ): try: if client is None: @@ -321,16 +323,16 @@ def make_sync_call( sync_stream=True, json_mode=json_mode, ) - completion_stream = decoder.iter_bytes(response.iter_bytes(chunk_size=1024)) + completion_stream = decoder.iter_bytes(response.iter_bytes(chunk_size=stream_chunk_size)) elif bedrock_invoke_provider == "deepseek_r1": decoder = AmazonDeepSeekR1StreamDecoder( model=model, sync_stream=True, ) - completion_stream = decoder.iter_bytes(response.iter_bytes(chunk_size=1024)) + completion_stream = decoder.iter_bytes(response.iter_bytes(chunk_size=stream_chunk_size)) else: decoder = AWSEventStreamDecoder(model=model) - completion_stream = decoder.iter_bytes(response.iter_bytes(chunk_size=1024)) + completion_stream = decoder.iter_bytes(response.iter_bytes(chunk_size=stream_chunk_size)) # LOGGING logging_obj.post_call( @@ -729,6 +731,7 @@ class BedrockLLM(BaseAWSLLM): ## SETUP ## stream = optional_params.pop("stream", None) + stream_chunk_size = optional_params.pop("stream_chunk_size", 1024) provider = self.get_bedrock_invoke_provider(model) modelId = self.get_bedrock_model_id( @@ -1003,6 +1006,7 @@ class BedrockLLM(BaseAWSLLM): headers=prepped.headers, timeout=timeout, client=client, + stream_chunk_size=stream_chunk_size, ) # type: ignore ### ASYNC COMPLETION return self.async_completion( @@ -1048,7 +1052,7 @@ class BedrockLLM(BaseAWSLLM): decoder = AWSEventStreamDecoder(model=model) - completion_stream = decoder.iter_bytes(response.iter_bytes(chunk_size=1024)) + completion_stream = decoder.iter_bytes(response.iter_bytes(chunk_size=stream_chunk_size)) streaming_response = CustomStreamWrapper( completion_stream=completion_stream, model=model, @@ -1168,6 +1172,7 @@ class BedrockLLM(BaseAWSLLM): logger_fn=None, headers={}, client: Optional[AsyncHTTPHandler] = None, + stream_chunk_size: int = 1024, ) -> CustomStreamWrapper: # The call is not made here; instead, we prepare the necessary objects for the stream. @@ -1183,6 +1188,7 @@ class BedrockLLM(BaseAWSLLM): messages=messages, logging_obj=logging_obj, fake_stream=True if "ai21" in api_base else False, + stream_chunk_size=stream_chunk_size, ), model=model, custom_llm_provider="bedrock", From 4c6604b0da6bcdd9c230632f203921047163d636 Mon Sep 17 00:00:00 2001 From: Cesar Garcia <128240629+Chesars@users.noreply.github.com> Date: Wed, 3 Dec 2025 03:25:26 -0300 Subject: [PATCH 26/37] Cleanup: Remove orphan docs pages and Docusaurus template files (#17356) * docs: update getting started page - Add Core Functions table with link to full list - Add Responses API section - Add Async section with acompletion() example - Add "Switch Providers with One Line" example - Clarify Basic Usage supports multiple endpoints - Update models to current versions (openai/gpt-4o, anthropic/claude-sonnet-4) - Use provider/model format throughout - Fix deprecated import: from openai.error -> from openai - Keep original structure: community key, More details links, observability env vars * Cleanup: Remove orphan docs pages and Docusaurus template files - Remove orphan getting_started.md (not linked in sidebar) - Remove Docusaurus template intro.md - Remove tutorial-basics/ directory (Docusaurus template) - Remove tutorial-extras/ directory (Docusaurus template) --- docs/my-website/docs/getting_started.md | 108 ------------- docs/my-website/src/pages/intro.md | 47 ------ .../src/pages/tutorial-basics/_category_.json | 8 - .../pages/tutorial-basics/congratulations.md | 23 --- .../tutorial-basics/create-a-blog-post.md | 34 ---- .../tutorial-basics/create-a-document.md | 57 ------- .../pages/tutorial-basics/create-a-page.md | 43 ----- .../pages/tutorial-basics/deploy-your-site.md | 31 ---- .../tutorial-basics/markdown-features.mdx | 150 ------------------ .../src/pages/tutorial-extras/_category_.json | 7 - .../img/docsVersionDropdown.png | Bin 25427 -> 0 bytes .../tutorial-extras/img/localeDropdown.png | Bin 27841 -> 0 bytes .../tutorial-extras/manage-docs-versions.md | 55 ------- .../tutorial-extras/translate-your-site.md | 88 ---------- 14 files changed, 651 deletions(-) delete mode 100644 docs/my-website/docs/getting_started.md delete mode 100644 docs/my-website/src/pages/intro.md delete mode 100644 docs/my-website/src/pages/tutorial-basics/_category_.json delete mode 100644 docs/my-website/src/pages/tutorial-basics/congratulations.md delete mode 100644 docs/my-website/src/pages/tutorial-basics/create-a-blog-post.md delete mode 100644 docs/my-website/src/pages/tutorial-basics/create-a-document.md delete mode 100644 docs/my-website/src/pages/tutorial-basics/create-a-page.md delete mode 100644 docs/my-website/src/pages/tutorial-basics/deploy-your-site.md delete mode 100644 docs/my-website/src/pages/tutorial-basics/markdown-features.mdx delete mode 100644 docs/my-website/src/pages/tutorial-extras/_category_.json delete mode 100644 docs/my-website/src/pages/tutorial-extras/img/docsVersionDropdown.png delete mode 100644 docs/my-website/src/pages/tutorial-extras/img/localeDropdown.png delete mode 100644 docs/my-website/src/pages/tutorial-extras/manage-docs-versions.md delete mode 100644 docs/my-website/src/pages/tutorial-extras/translate-your-site.md diff --git a/docs/my-website/docs/getting_started.md b/docs/my-website/docs/getting_started.md deleted file mode 100644 index 6b2c1fd531e..00000000000 --- a/docs/my-website/docs/getting_started.md +++ /dev/null @@ -1,108 +0,0 @@ -# Getting Started - -import QuickStart from '../src/components/QuickStart.js' - -LiteLLM simplifies LLM API calls by mapping them all to the [OpenAI ChatCompletion format](https://platform.openai.com/docs/api-reference/chat). - -## basic usage - -By default we provide a free $10 community-key to try all providers supported on LiteLLM. - -```python -from litellm import completion - -## set ENV variables -os.environ["OPENAI_API_KEY"] = "your-api-key" -os.environ["COHERE_API_KEY"] = "your-api-key" - -messages = [{ "content": "Hello, how are you?","role": "user"}] - -# openai call -response = completion(model="gpt-3.5-turbo", messages=messages) - -# cohere call -response = completion("command-nightly", messages) -``` - -**Need a dedicated key?** -Email us @ krrish@berri.ai - -Next Steps 👉 [Call all supported models - e.g. Claude-2, Llama2-70b, etc.](./proxy_api.md#supported-models) - -More details 👉 - -- [Completion() function details](./completion/) -- [Overview of supported models / providers on LiteLLM](./providers/) -- [Search all models / providers](https://models.litellm.ai/) -- [Build your own OpenAI proxy](https://github.com/BerriAI/liteLLM-proxy/tree/main) - -## streaming - -Same example from before. Just pass in `stream=True` in the completion args. - -```python -from litellm import completion - -## set ENV variables -os.environ["OPENAI_API_KEY"] = "openai key" -os.environ["COHERE_API_KEY"] = "cohere key" - -messages = [{ "content": "Hello, how are you?","role": "user"}] - -# openai call -response = completion(model="gpt-3.5-turbo", messages=messages, stream=True) - -# cohere call -response = completion("command-nightly", messages, stream=True) - -print(response) -``` - -More details 👉 - -- [streaming + async](./completion/stream.md) -- [tutorial for streaming Llama2 on TogetherAI](./tutorials/TogetherAI_liteLLM.md) - -## exception handling - -LiteLLM maps exceptions across all supported providers to the OpenAI exceptions. All our exceptions inherit from OpenAI's exception types, so any error-handling you have for that, should work out of the box with LiteLLM. - -```python -from openai.error import OpenAIError -from litellm import completion - -os.environ["ANTHROPIC_API_KEY"] = "bad-key" -try: - # some code - completion(model="claude-instant-1", messages=[{"role": "user", "content": "Hey, how's it going?"}]) -except OpenAIError as e: - print(e) -``` - -## Logging Observability - Log LLM Input/Output ([Docs](https://docs.litellm.ai/docs/observability/callbacks)) - -LiteLLM exposes pre defined callbacks to send data to MLflow, Lunary, Langfuse, Helicone, Promptlayer, Traceloop, Slack - -```python -from litellm import completion - -## set env variables for logging tools (API key set up is not required when using MLflow) -os.environ["LUNARY_PUBLIC_KEY"] = "your-lunary-public-key" # get your public key at https://app.lunary.ai/settings -os.environ["HELICONE_API_KEY"] = "your-helicone-key" -os.environ["LANGFUSE_PUBLIC_KEY"] = "" -os.environ["LANGFUSE_SECRET_KEY"] = "" - -os.environ["OPENAI_API_KEY"] - -# set callbacks -litellm.success_callback = ["lunary", "mlflow", "langfuse", "helicone"] # log input/output to MLflow, langfuse, lunary, helicone - -#openai call -response = completion(model="gpt-3.5-turbo", messages=[{"role": "user", "content": "Hi 👋 - i'm openai"}]) -``` - -More details 👉 - -- [exception mapping](./exception_mapping.md) -- [retries + model fallbacks for completion()](./completion/reliable_completions.md) -- [tutorial for model fallbacks with completion()](./tutorials/fallbacks.md) diff --git a/docs/my-website/src/pages/intro.md b/docs/my-website/src/pages/intro.md deleted file mode 100644 index 8a2e69d95f9..00000000000 --- a/docs/my-website/src/pages/intro.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -sidebar_position: 1 ---- - -# Tutorial Intro - -Let's discover **Docusaurus in less than 5 minutes**. - -## Getting Started - -Get started by **creating a new site**. - -Or **try Docusaurus immediately** with **[docusaurus.new](https://docusaurus.new)**. - -### What you'll need - -- [Node.js](https://nodejs.org/en/download/) version 16.14 or above: - - When installing Node.js, you are recommended to check all checkboxes related to dependencies. - -## Generate a new site - -Generate a new Docusaurus site using the **classic template**. - -The classic template will automatically be added to your project after you run the command: - -```bash -npm init docusaurus@latest my-website classic -``` - -You can type this command into Command Prompt, Powershell, Terminal, or any other integrated terminal of your code editor. - -The command also installs all necessary dependencies you need to run Docusaurus. - -## Start your site - -Run the development server: - -```bash -cd my-website -npm run start -``` - -The `cd` command changes the directory you're working with. In order to work with your newly created Docusaurus site, you'll need to navigate the terminal there. - -The `npm run start` command builds your website locally and serves it through a development server, ready for you to view at http://localhost:3000/. - -Open `docs/intro.md` (this page) and edit some lines: the site **reloads automatically** and displays your changes. diff --git a/docs/my-website/src/pages/tutorial-basics/_category_.json b/docs/my-website/src/pages/tutorial-basics/_category_.json deleted file mode 100644 index 2e6db55b1eb..00000000000 --- a/docs/my-website/src/pages/tutorial-basics/_category_.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "label": "Tutorial - Basics", - "position": 2, - "link": { - "type": "generated-index", - "description": "5 minutes to learn the most important Docusaurus concepts." - } -} diff --git a/docs/my-website/src/pages/tutorial-basics/congratulations.md b/docs/my-website/src/pages/tutorial-basics/congratulations.md deleted file mode 100644 index 04771a00b72..00000000000 --- a/docs/my-website/src/pages/tutorial-basics/congratulations.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -sidebar_position: 6 ---- - -# Congratulations! - -You have just learned the **basics of Docusaurus** and made some changes to the **initial template**. - -Docusaurus has **much more to offer**! - -Have **5 more minutes**? Take a look at **[versioning](../tutorial-extras/manage-docs-versions.md)** and **[i18n](../tutorial-extras/translate-your-site.md)**. - -Anything **unclear** or **buggy** in this tutorial? [Please report it!](https://github.com/facebook/docusaurus/discussions/4610) - -## What's next? - -- Read the [official documentation](https://docusaurus.io/) -- Modify your site configuration with [`docusaurus.config.js`](https://docusaurus.io/docs/api/docusaurus-config) -- Add navbar and footer items with [`themeConfig`](https://docusaurus.io/docs/api/themes/configuration) -- Add a custom [Design and Layout](https://docusaurus.io/docs/styling-layout) -- Add a [search bar](https://docusaurus.io/docs/search) -- Find inspirations in the [Docusaurus showcase](https://docusaurus.io/showcase) -- Get involved in the [Docusaurus Community](https://docusaurus.io/community/support) diff --git a/docs/my-website/src/pages/tutorial-basics/create-a-blog-post.md b/docs/my-website/src/pages/tutorial-basics/create-a-blog-post.md deleted file mode 100644 index ea472bbaf87..00000000000 --- a/docs/my-website/src/pages/tutorial-basics/create-a-blog-post.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -sidebar_position: 3 ---- - -# Create a Blog Post - -Docusaurus creates a **page for each blog post**, but also a **blog index page**, a **tag system**, an **RSS** feed... - -## Create your first Post - -Create a file at `blog/2021-02-28-greetings.md`: - -```md title="blog/2021-02-28-greetings.md" ---- -slug: greetings -title: Greetings! -authors: - - name: Joel Marcey - title: Co-creator of Docusaurus 1 - url: https://github.com/JoelMarcey - image_url: https://github.com/JoelMarcey.png - - name: Sébastien Lorber - title: Docusaurus maintainer - url: https://sebastienlorber.com - image_url: https://github.com/slorber.png -tags: [greetings] ---- - -Congratulations, you have made your first post! - -Feel free to play around and edit this post as much you like. -``` - -A new blog post is now available at [http://localhost:3000/blog/greetings](http://localhost:3000/blog/greetings). diff --git a/docs/my-website/src/pages/tutorial-basics/create-a-document.md b/docs/my-website/src/pages/tutorial-basics/create-a-document.md deleted file mode 100644 index ffddfa8eb8a..00000000000 --- a/docs/my-website/src/pages/tutorial-basics/create-a-document.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -sidebar_position: 2 ---- - -# Create a Document - -Documents are **groups of pages** connected through: - -- a **sidebar** -- **previous/next navigation** -- **versioning** - -## Create your first Doc - -Create a Markdown file at `docs/hello.md`: - -```md title="docs/hello.md" -# Hello - -This is my **first Docusaurus document**! -``` - -A new document is now available at [http://localhost:3000/docs/hello](http://localhost:3000/docs/hello). - -## Configure the Sidebar - -Docusaurus automatically **creates a sidebar** from the `docs` folder. - -Add metadata to customize the sidebar label and position: - -```md title="docs/hello.md" {1-4} ---- -sidebar_label: 'Hi!' -sidebar_position: 3 ---- - -# Hello - -This is my **first Docusaurus document**! -``` - -It is also possible to create your sidebar explicitly in `sidebars.js`: - -```js title="sidebars.js" -module.exports = { - tutorialSidebar: [ - 'intro', - // highlight-next-line - 'hello', - { - type: 'category', - label: 'Tutorial', - items: ['tutorial-basics/create-a-document'], - }, - ], -}; -``` diff --git a/docs/my-website/src/pages/tutorial-basics/create-a-page.md b/docs/my-website/src/pages/tutorial-basics/create-a-page.md deleted file mode 100644 index 20e2ac30055..00000000000 --- a/docs/my-website/src/pages/tutorial-basics/create-a-page.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -sidebar_position: 1 ---- - -# Create a Page - -Add **Markdown or React** files to `src/pages` to create a **standalone page**: - -- `src/pages/index.js` → `localhost:3000/` -- `src/pages/foo.md` → `localhost:3000/foo` -- `src/pages/foo/bar.js` → `localhost:3000/foo/bar` - -## Create your first React Page - -Create a file at `src/pages/my-react-page.js`: - -```jsx title="src/pages/my-react-page.js" -import React from 'react'; -import Layout from '@theme/Layout'; - -export default function MyReactPage() { - return ( - -

    My React page

    -

    This is a React page

    -
    - ); -} -``` - -A new page is now available at [http://localhost:3000/my-react-page](http://localhost:3000/my-react-page). - -## Create your first Markdown Page - -Create a file at `src/pages/my-markdown-page.md`: - -```mdx title="src/pages/my-markdown-page.md" -# My Markdown page - -This is a Markdown page -``` - -A new page is now available at [http://localhost:3000/my-markdown-page](http://localhost:3000/my-markdown-page). diff --git a/docs/my-website/src/pages/tutorial-basics/deploy-your-site.md b/docs/my-website/src/pages/tutorial-basics/deploy-your-site.md deleted file mode 100644 index 1c50ee063ef..00000000000 --- a/docs/my-website/src/pages/tutorial-basics/deploy-your-site.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -sidebar_position: 5 ---- - -# Deploy your site - -Docusaurus is a **static-site-generator** (also called **[Jamstack](https://jamstack.org/)**). - -It builds your site as simple **static HTML, JavaScript and CSS files**. - -## Build your site - -Build your site **for production**: - -```bash -npm run build -``` - -The static files are generated in the `build` folder. - -## Deploy your site - -Test your production build locally: - -```bash -npm run serve -``` - -The `build` folder is now served at [http://localhost:3000/](http://localhost:3000/). - -You can now deploy the `build` folder **almost anywhere** easily, **for free** or very small cost (read the **[Deployment Guide](https://docusaurus.io/docs/deployment)**). diff --git a/docs/my-website/src/pages/tutorial-basics/markdown-features.mdx b/docs/my-website/src/pages/tutorial-basics/markdown-features.mdx deleted file mode 100644 index 0337f34d6a5..00000000000 --- a/docs/my-website/src/pages/tutorial-basics/markdown-features.mdx +++ /dev/null @@ -1,150 +0,0 @@ ---- -sidebar_position: 4 ---- - -# Markdown Features - -Docusaurus supports **[Markdown](https://daringfireball.net/projects/markdown/syntax)** and a few **additional features**. - -## Front Matter - -Markdown documents have metadata at the top called [Front Matter](https://jekyllrb.com/docs/front-matter/): - -```text title="my-doc.md" -// highlight-start ---- -id: my-doc-id -title: My document title -description: My document description -slug: /my-custom-url ---- -// highlight-end - -## Markdown heading - -Markdown text with [links](./hello.md) -``` - -## Links - -Regular Markdown links are supported, using url paths or relative file paths. - -```md -Let's see how to [Create a page](/create-a-page). -``` - -```md -Let's see how to [Create a page](./create-a-page.md). -``` - -**Result:** Let's see how to [Create a page](./create-a-page.md). - -## Images - -Regular Markdown images are supported. - -You can use absolute paths to reference images in the static directory (`static/img/docusaurus.png`): - -```md -![Docusaurus logo](/img/docusaurus.png) -``` - -![Docusaurus logo](/img/docusaurus.png) - -You can reference images relative to the current file as well. This is particularly useful to colocate images close to the Markdown files using them: - -```md -![Docusaurus logo](./img/docusaurus.png) -``` - -## Code Blocks - -Markdown code blocks are supported with Syntax highlighting. - - ```jsx title="src/components/HelloDocusaurus.js" - function HelloDocusaurus() { - return ( -

    Hello, Docusaurus!

    - ) - } - ``` - -```jsx title="src/components/HelloDocusaurus.js" -function HelloDocusaurus() { - return

    Hello, Docusaurus!

    ; -} -``` - -## Admonitions - -Docusaurus has a special syntax to create admonitions and callouts: - - :::tip My tip - - Use this awesome feature option - - ::: - - :::danger Take care - - This action is dangerous - - ::: - -:::tip My tip - -Use this awesome feature option - -::: - -:::danger Take care - -This action is dangerous - -::: - -## MDX and React Components - -[MDX](https://mdxjs.com/) can make your documentation more **interactive** and allows using any **React components inside Markdown**: - -```jsx -export const Highlight = ({children, color}) => ( - { - alert(`You clicked the color ${color} with label ${children}`) - }}> - {children} - -); - -This is Docusaurus green ! - -This is Facebook blue ! -``` - -export const Highlight = ({children, color}) => ( - { - alert(`You clicked the color ${color} with label ${children}`); - }}> - {children} - -); - -This is Docusaurus green ! - -This is Facebook blue ! diff --git a/docs/my-website/src/pages/tutorial-extras/_category_.json b/docs/my-website/src/pages/tutorial-extras/_category_.json deleted file mode 100644 index a8ffcc19300..00000000000 --- a/docs/my-website/src/pages/tutorial-extras/_category_.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "label": "Tutorial - Extras", - "position": 3, - "link": { - "type": "generated-index" - } -} diff --git a/docs/my-website/src/pages/tutorial-extras/img/docsVersionDropdown.png b/docs/my-website/src/pages/tutorial-extras/img/docsVersionDropdown.png deleted file mode 100644 index 97e4164618b5f8beda34cfa699720aba0ad2e342..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25427 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 diff --git a/docs/my-website/src/pages/tutorial-extras/img/localeDropdown.png b/docs/my-website/src/pages/tutorial-extras/img/localeDropdown.png deleted file mode 100644 index e257edc1f932985396bf59584c7ccfaddf955779..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 27841 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 JrC|B>e*nH7H{}2T diff --git a/docs/my-website/src/pages/tutorial-extras/manage-docs-versions.md b/docs/my-website/src/pages/tutorial-extras/manage-docs-versions.md deleted file mode 100644 index e12c3f3444f..00000000000 --- a/docs/my-website/src/pages/tutorial-extras/manage-docs-versions.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -sidebar_position: 1 ---- - -# Manage Docs Versions - -Docusaurus can manage multiple versions of your docs. - -## Create a docs version - -Release a version 1.0 of your project: - -```bash -npm run docusaurus docs:version 1.0 -``` - -The `docs` folder is copied into `versioned_docs/version-1.0` and `versions.json` is created. - -Your docs now have 2 versions: - -- `1.0` at `http://localhost:3000/docs/` for the version 1.0 docs -- `current` at `http://localhost:3000/docs/next/` for the **upcoming, unreleased docs** - -## Add a Version Dropdown - -To navigate seamlessly across versions, add a version dropdown. - -Modify the `docusaurus.config.js` file: - -```js title="docusaurus.config.js" -module.exports = { - themeConfig: { - navbar: { - items: [ - // highlight-start - { - type: 'docsVersionDropdown', - }, - // highlight-end - ], - }, - }, -}; -``` - -The docs version dropdown appears in your navbar: - -![Docs Version Dropdown](./img/docsVersionDropdown.png) - -## Update an existing version - -It is possible to edit versioned docs in their respective folder: - -- `versioned_docs/version-1.0/hello.md` updates `http://localhost:3000/docs/hello` -- `docs/hello.md` updates `http://localhost:3000/docs/next/hello` diff --git a/docs/my-website/src/pages/tutorial-extras/translate-your-site.md b/docs/my-website/src/pages/tutorial-extras/translate-your-site.md deleted file mode 100644 index caeaffb0554..00000000000 --- a/docs/my-website/src/pages/tutorial-extras/translate-your-site.md +++ /dev/null @@ -1,88 +0,0 @@ ---- -sidebar_position: 2 ---- - -# Translate your site - -Let's translate `docs/intro.md` to French. - -## Configure i18n - -Modify `docusaurus.config.js` to add support for the `fr` locale: - -```js title="docusaurus.config.js" -module.exports = { - i18n: { - defaultLocale: 'en', - locales: ['en', 'fr'], - }, -}; -``` - -## Translate a doc - -Copy the `docs/intro.md` file to the `i18n/fr` folder: - -```bash -mkdir -p i18n/fr/docusaurus-plugin-content-docs/current/ - -cp docs/intro.md i18n/fr/docusaurus-plugin-content-docs/current/intro.md -``` - -Translate `i18n/fr/docusaurus-plugin-content-docs/current/intro.md` in French. - -## Start your localized site - -Start your site on the French locale: - -```bash -npm run start -- --locale fr -``` - -Your localized site is accessible at [http://localhost:3000/fr/](http://localhost:3000/fr/) and the `Getting Started` page is translated. - -:::caution - -In development, you can only use one locale at a same time. - -::: - -## Add a Locale Dropdown - -To navigate seamlessly across languages, add a locale dropdown. - -Modify the `docusaurus.config.js` file: - -```js title="docusaurus.config.js" -module.exports = { - themeConfig: { - navbar: { - items: [ - // highlight-start - { - type: 'localeDropdown', - }, - // highlight-end - ], - }, - }, -}; -``` - -The locale dropdown now appears in your navbar: - -![Locale Dropdown](./img/localeDropdown.png) - -## Build your localized site - -Build your site for a specific locale: - -```bash -npm run build -- --locale fr -``` - -Or build your site to include all the locales at once: - -```bash -npm run build -``` From 86350fe6d70dc62ecb5331a7df5f96a35b853305 Mon Sep 17 00:00:00 2001 From: Cesar Garcia <128240629+Chesars@users.noreply.github.com> Date: Wed, 3 Dec 2025 03:27:04 -0300 Subject: [PATCH 27/37] docs: add Google ADK and Harbor to projects (#17352) Both frameworks integrate with LiteLLM: - Google ADK uses LiteLLM for model-agnostic agent building - Harbor uses LiteLLM for agent evaluation across providers --- docs/my-website/docs/projects/Google ADK.md | 21 ++++++++++++++++++ docs/my-website/docs/projects/Harbor.md | 24 +++++++++++++++++++++ docs/my-website/sidebars.js | 4 +++- 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 docs/my-website/docs/projects/Google ADK.md create mode 100644 docs/my-website/docs/projects/Harbor.md diff --git a/docs/my-website/docs/projects/Google ADK.md b/docs/my-website/docs/projects/Google ADK.md new file mode 100644 index 00000000000..25e910dcbad --- /dev/null +++ b/docs/my-website/docs/projects/Google ADK.md @@ -0,0 +1,21 @@ + +# Google ADK (Agent Development Kit) + +[Google ADK](https://github.com/google/adk-python) is an open-source, code-first Python framework for building, evaluating, and deploying sophisticated AI agents. While optimized for Gemini, ADK is model-agnostic and supports LiteLLM for using 100+ providers. + +```python +from google.adk.agents.llm_agent import Agent +from google.adk.models.lite_llm import LiteLlm + +root_agent = Agent( + model=LiteLlm(model="openai/gpt-4o"), # Or any LiteLLM-supported model + name="my_agent", + description="An agent using LiteLLM", + instruction="You are a helpful assistant.", + tools=[your_tools], +) +``` + +- [GitHub](https://github.com/google/adk-python) +- [Documentation](https://google.github.io/adk-docs) +- [LiteLLM Samples](https://github.com/google/adk-python/tree/main/contributing/samples/hello_world_litellm) diff --git a/docs/my-website/docs/projects/Harbor.md b/docs/my-website/docs/projects/Harbor.md new file mode 100644 index 00000000000..684dfa93720 --- /dev/null +++ b/docs/my-website/docs/projects/Harbor.md @@ -0,0 +1,24 @@ + +# Harbor + +[Harbor](https://github.com/laude-institute/harbor) is a framework from the creators of Terminal-Bench for evaluating and optimizing agents and language models. It uses LiteLLM to call 100+ LLM providers. + +```bash +# Install +pip install harbor + +# Run a benchmark with any LiteLLM-supported model +harbor run --dataset terminal-bench@2.0 \ + --agent claude-code \ + --model anthropic/claude-opus-4-1 \ + --n-concurrent 4 +``` + +Key features: +- Evaluate agents like Claude Code, OpenHands, Codex CLI +- Build and share benchmarks and environments +- Run experiments in parallel across cloud providers (Daytona, Modal) +- Generate rollouts for RL optimization + +- [GitHub](https://github.com/laude-institute/harbor) +- [Documentation](https://harborframework.com/docs) diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index 983816ed216..a9790547e84 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -820,10 +820,12 @@ const sidebars = { "Learn how to deploy + call models from different providers on LiteLLM", slug: "/project", }, - items: [ + items: [ "projects/smolagents", "projects/mini-swe-agent", "projects/openai-agents", + "projects/Google ADK", + "projects/Harbor", "projects/Docq.AI", "projects/PDL", "projects/OpenInterpreter", From c173a4a27594b0a435f58a0be7633514bbeee440 Mon Sep 17 00:00:00 2001 From: Fabian Reinold <32450519+freinold@users.noreply.github.com> Date: Wed, 3 Dec 2025 07:30:54 +0100 Subject: [PATCH 28/37] Helm Chart: add ingress-only labels (#17348) * feat(helm): add ingress-only labels * feat(helm): add ingress configuration tests * chore(helm): bump chart version --- deploy/charts/litellm-helm/Chart.yaml | 4 +- deploy/charts/litellm-helm/README.md | 125 +++++++++--------- .../litellm-helm/templates/ingress.yaml | 3 + .../litellm-helm/tests/ingress_tests.yaml | 45 +++++++ deploy/charts/litellm-helm/values.yaml | 45 ++++--- 5 files changed, 140 insertions(+), 82 deletions(-) create mode 100644 deploy/charts/litellm-helm/tests/ingress_tests.yaml diff --git a/deploy/charts/litellm-helm/Chart.yaml b/deploy/charts/litellm-helm/Chart.yaml index eedadebaa8e..7f14af7db5d 100644 --- a/deploy/charts/litellm-helm/Chart.yaml +++ b/deploy/charts/litellm-helm/Chart.yaml @@ -18,7 +18,7 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 0.4.8 +version: 0.4.9 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to @@ -33,5 +33,5 @@ dependencies: condition: db.deployStandalone - name: redis version: ">=18.0.0" - repository: oci://registry-1.docker.io/bitnamicharts + repository: oci://registry-1.docker.io/bitnamicharts condition: redis.enabled diff --git a/deploy/charts/litellm-helm/README.md b/deploy/charts/litellm-helm/README.md index 352c3e9ddff..6fdc423a177 100644 --- a/deploy/charts/litellm-helm/README.md +++ b/deploy/charts/litellm-helm/README.md @@ -10,46 +10,48 @@ - Helm 3.8.0+ If `db.deployStandalone` is used: + - PV provisioner support in the underlying infrastructure If `db.useStackgresOperator` is used (not yet implemented): -- The Stackgres Operator must already be installed in the Kubernetes Cluster. This chart will **not** install the operator if it is missing. + +- The Stackgres Operator must already be installed in the Kubernetes Cluster. This chart will **not** install the operator if it is missing. ## Parameters ### LiteLLM Proxy Deployment Settings -| Name | Description | Value | -| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | -| `replicaCount` | The number of LiteLLM Proxy pods to be deployed | `1` | -| `masterkeySecretName` | The name of the Kubernetes Secret that contains the Master API Key for LiteLLM. If not specified, use the generated secret name. | N/A | -| `masterkeySecretKey` | The key within the Kubernetes Secret that contains the Master API Key for LiteLLM. If not specified, use `masterkey` as the key. | N/A | -| `masterkey` | The Master API Key for LiteLLM. If not specified, a random key in the `sk-...` format is generated. | N/A | -| `environmentSecrets` | An optional array of Secret object names. The keys and values in these secrets will be presented to the LiteLLM proxy pod as environment variables. See below for an example Secret object. | `[]` | -| `environmentConfigMaps` | An optional array of ConfigMap object names. The keys and values in these configmaps will be presented to the LiteLLM proxy pod as environment variables. See below for an example Secret object. | `[]` | -| `image.repository` | LiteLLM Proxy image repository | `ghcr.io/berriai/litellm` | -| `image.pullPolicy` | LiteLLM Proxy image pull policy | `IfNotPresent` | -| `image.tag` | Overrides the image tag whose default the latest version of LiteLLM at the time this chart was published. | `""` | -| `imagePullSecrets` | Registry credentials for the LiteLLM and initContainer images. | `[]` | -| `serviceAccount.create` | Whether or not to create a Kubernetes Service Account for this deployment. The default is `false` because LiteLLM has no need to access the Kubernetes API. | `false` | -| `service.type` | Kubernetes Service type (e.g. `LoadBalancer`, `ClusterIP`, etc.) | `ClusterIP` | -| `service.port` | TCP port that the Kubernetes Service will listen on. Also the TCP port within the Pod that the proxy will listen on. | `4000` | -| `service.loadBalancerClass` | Optional LoadBalancer implementation class (only used when `service.type` is `LoadBalancer`) | `""` | -| `ingress.*` | See [values.yaml](./values.yaml) for example settings | N/A | -| `proxyConfigMap.create` | When `true`, render a ConfigMap from `.Values.proxy_config` and mount it. | `true` | -| `proxyConfigMap.name` | When `create=false`, name of the existing ConfigMap to mount. | `""` | -| `proxyConfigMap.key` | Key in the ConfigMap that contains the proxy config file. | `"config.yaml"` | -| `proxy_config.*` | See [values.yaml](./values.yaml) for default settings. Rendered into the ConfigMap’s `config.yaml` only when `proxyConfigMap.create=true`. See [example_config_yaml](../../../litellm/proxy/example_config_yaml/) for configuration examples. | `N/A` | -| `extraContainers[]` | An array of additional containers to be deployed as sidecars alongside the LiteLLM Proxy. -| `pdb.enabled` | Enable a PodDisruptionBudget for the LiteLLM proxy Deployment | `false` | -| `pdb.minAvailable` | Minimum number/percentage of pods that must be available during **voluntary** disruptions (choose **one** of minAvailable/maxUnavailable) | `null` | -| `pdb.maxUnavailable` | Maximum number/percentage of pods that can be unavailable during **voluntary** disruptions (choose **one** of minAvailable/maxUnavailable) | `null` | -| `pdb.annotations` | Extra metadata annotations to add to the PDB | `{}` | -| `pdb.labels` | Extra metadata labels to add to the PDB | `{}` | +| Name | Description | Value | +| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | +| `replicaCount` | The number of LiteLLM Proxy pods to be deployed | `1` | +| `masterkeySecretName` | The name of the Kubernetes Secret that contains the Master API Key for LiteLLM. If not specified, use the generated secret name. | N/A | +| `masterkeySecretKey` | The key within the Kubernetes Secret that contains the Master API Key for LiteLLM. If not specified, use `masterkey` as the key. | N/A | +| `masterkey` | The Master API Key for LiteLLM. If not specified, a random key in the `sk-...` format is generated. | N/A | +| `environmentSecrets` | An optional array of Secret object names. The keys and values in these secrets will be presented to the LiteLLM proxy pod as environment variables. See below for an example Secret object. | `[]` | +| `environmentConfigMaps` | An optional array of ConfigMap object names. The keys and values in these configmaps will be presented to the LiteLLM proxy pod as environment variables. See below for an example Secret object. | `[]` | +| `image.repository` | LiteLLM Proxy image repository | `ghcr.io/berriai/litellm` | +| `image.pullPolicy` | LiteLLM Proxy image pull policy | `IfNotPresent` | +| `image.tag` | Overrides the image tag whose default the latest version of LiteLLM at the time this chart was published. | `""` | +| `imagePullSecrets` | Registry credentials for the LiteLLM and initContainer images. | `[]` | +| `serviceAccount.create` | Whether or not to create a Kubernetes Service Account for this deployment. The default is `false` because LiteLLM has no need to access the Kubernetes API. | `false` | +| `service.type` | Kubernetes Service type (e.g. `LoadBalancer`, `ClusterIP`, etc.) | `ClusterIP` | +| `service.port` | TCP port that the Kubernetes Service will listen on. Also the TCP port within the Pod that the proxy will listen on. | `4000` | +| `service.loadBalancerClass` | Optional LoadBalancer implementation class (only used when `service.type` is `LoadBalancer`) | `""` | +| `ingress.labels` | Additional labels for the Ingress resource | `{}` | +| `ingress.*` | See [values.yaml](./values.yaml) for example settings | N/A | +| `proxyConfigMap.create` | When `true`, render a ConfigMap from `.Values.proxy_config` and mount it. | `true` | +| `proxyConfigMap.name` | When `create=false`, name of the existing ConfigMap to mount. | `""` | +| `proxyConfigMap.key` | Key in the ConfigMap that contains the proxy config file. | `"config.yaml"` | +| `proxy_config.*` | See [values.yaml](./values.yaml) for default settings. Rendered into the ConfigMap’s `config.yaml` only when `proxyConfigMap.create=true`. See [example_config_yaml](../../../litellm/proxy/example_config_yaml/) for configuration examples. | `N/A` | +| `extraContainers[]` | An array of additional containers to be deployed as sidecars alongside the LiteLLM Proxy. | +| `pdb.enabled` | Enable a PodDisruptionBudget for the LiteLLM proxy Deployment | `false` | +| `pdb.minAvailable` | Minimum number/percentage of pods that must be available during **voluntary** disruptions (choose **one** of minAvailable/maxUnavailable) | `null` | +| `pdb.maxUnavailable` | Maximum number/percentage of pods that can be unavailable during **voluntary** disruptions (choose **one** of minAvailable/maxUnavailable) | `null` | +| `pdb.annotations` | Extra metadata annotations to add to the PDB | `{}` | +| `pdb.labels` | Extra metadata labels to add to the PDB | `{}` | #### Example `proxy_config` ConfigMap from values (default): - ``` proxyConfigMap: create: true @@ -67,7 +69,6 @@ proxy_config: #### Example using existing `proxyConfigMap` instead of creating it: - ``` proxyConfigMap: create: false @@ -77,8 +78,7 @@ proxyConfigMap: # proxy_config is ignored in this mode ``` -#### Example `environmentSecrets` Secret - +#### Example `environmentSecrets` Secret ``` apiVersion: v1 @@ -91,21 +91,23 @@ type: Opaque ``` ### Database Settings -| Name | Description | Value | -| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | -| `db.useExisting` | Use an existing Postgres database. A Kubernetes Secret object must exist that contains credentials for connecting to the database. An example secret object definition is provided below. | `false` | -| `db.endpoint` | If `db.useExisting` is `true`, this is the IP, Hostname or Service Name of the Postgres server to connect to. | `localhost` | -| `db.database` | If `db.useExisting` is `true`, the name of the existing database to connect to. | `litellm` | -| `db.url` | If `db.useExisting` is `true`, the connection url of the existing database to connect to can be overwritten with this value. | `postgresql://$(DATABASE_USERNAME):$(DATABASE_PASSWORD)@$(DATABASE_HOST)/$(DATABASE_NAME)` | -| `db.secret.name` | If `db.useExisting` is `true`, the name of the Kubernetes Secret that contains credentials. | `postgres` | -| `db.secret.usernameKey` | If `db.useExisting` is `true`, the name of the key within the Kubernetes Secret that holds the username for authenticating with the Postgres instance. | `username` | -| `db.secret.passwordKey` | If `db.useExisting` is `true`, the name of the key within the Kubernetes Secret that holds the password associates with the above user. | `password` | -| `db.useStackgresOperator` | Not yet implemented. | `false` | -| `db.deployStandalone` | Deploy a standalone, single instance deployment of Postgres, using the Bitnami postgresql chart. This is useful for getting started but doesn't provide HA or (by default) data backups. | `true` | -| `postgresql.*` | If `db.deployStandalone` is `true`, configuration passed to the Bitnami postgresql chart. See the [Bitnami Documentation](https://github.com/bitnami/charts/tree/main/bitnami/postgresql) for full configuration details. See [values.yaml](./values.yaml) for the default configuration. | See [values.yaml](./values.yaml) | -| `postgresql.auth.*` | If `db.deployStandalone` is `true`, care should be taken to ensure the default `password` and `postgres-password` values are **NOT** used. | `NoTaGrEaTpAsSwOrD` | + +| Name | Description | Value | +| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| `db.useExisting` | Use an existing Postgres database. A Kubernetes Secret object must exist that contains credentials for connecting to the database. An example secret object definition is provided below. | `false` | +| `db.endpoint` | If `db.useExisting` is `true`, this is the IP, Hostname or Service Name of the Postgres server to connect to. | `localhost` | +| `db.database` | If `db.useExisting` is `true`, the name of the existing database to connect to. | `litellm` | +| `db.url` | If `db.useExisting` is `true`, the connection url of the existing database to connect to can be overwritten with this value. | `postgresql://$(DATABASE_USERNAME):$(DATABASE_PASSWORD)@$(DATABASE_HOST)/$(DATABASE_NAME)` | +| `db.secret.name` | If `db.useExisting` is `true`, the name of the Kubernetes Secret that contains credentials. | `postgres` | +| `db.secret.usernameKey` | If `db.useExisting` is `true`, the name of the key within the Kubernetes Secret that holds the username for authenticating with the Postgres instance. | `username` | +| `db.secret.passwordKey` | If `db.useExisting` is `true`, the name of the key within the Kubernetes Secret that holds the password associates with the above user. | `password` | +| `db.useStackgresOperator` | Not yet implemented. | `false` | +| `db.deployStandalone` | Deploy a standalone, single instance deployment of Postgres, using the Bitnami postgresql chart. This is useful for getting started but doesn't provide HA or (by default) data backups. | `true` | +| `postgresql.*` | If `db.deployStandalone` is `true`, configuration passed to the Bitnami postgresql chart. See the [Bitnami Documentation](https://github.com/bitnami/charts/tree/main/bitnami/postgresql) for full configuration details. See [values.yaml](./values.yaml) for the default configuration. | See [values.yaml](./values.yaml) | +| `postgresql.auth.*` | If `db.deployStandalone` is `true`, care should be taken to ensure the default `password` and `postgres-password` values are **NOT** used. | `NoTaGrEaTpAsSwOrD` | #### Example Postgres `db.useExisting` Secret + ```yaml apiVersion: v1 kind: Secret @@ -143,7 +145,7 @@ metadata: name: litellm-env-secret type: Opaque data: - SOME_PASSWORD: cDZbUGVXeU5e0ZW # base64 encoded + SOME_PASSWORD: cDZbUGVXeU5e0ZW # base64 encoded ANOTHER_PASSWORD: AAZbUGVXeU5e0ZB # base64 encoded ``` @@ -153,23 +155,23 @@ Source: [GitHub Gist from troyharvey](https://gist.github.com/troyharvey/4506472 The migration job supports both ArgoCD and Helm hooks to ensure database migrations run at the appropriate time during deployments. -| Name | Description | Value | -| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | -| `migrationJob.enabled` | Enable or disable the schema migration Job | `true` | -| `migrationJob.backoffLimit` | Backoff limit for Job restarts | `4` | -| `migrationJob.ttlSecondsAfterFinished` | TTL for completed migration jobs | `120` | -| `migrationJob.annotations` | Additional annotations for the migration job pod | `{}` | -| `migrationJob.extraContainers` | Additional containers to run alongside the migration job | `[]` | -| `migrationJob.hooks.argocd.enabled` | Enable ArgoCD hooks for the migration job (uses PreSync hook with BeforeHookCreation delete policy) | `true` | -| `migrationJob.hooks.helm.enabled` | Enable Helm hooks for the migration job (uses pre-install,pre-upgrade hooks with before-hook-creation delete policy) | `false` | -| `migrationJob.hooks.helm.weight` | Helm hook execution order (lower weights executed first). Optional - defaults to "1" if not specified. | N/A | - +| Name | Description | Value | +| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------- | +| `migrationJob.enabled` | Enable or disable the schema migration Job | `true` | +| `migrationJob.backoffLimit` | Backoff limit for Job restarts | `4` | +| `migrationJob.ttlSecondsAfterFinished` | TTL for completed migration jobs | `120` | +| `migrationJob.annotations` | Additional annotations for the migration job pod | `{}` | +| `migrationJob.extraContainers` | Additional containers to run alongside the migration job | `[]` | +| `migrationJob.hooks.argocd.enabled` | Enable ArgoCD hooks for the migration job (uses PreSync hook with BeforeHookCreation delete policy) | `true` | +| `migrationJob.hooks.helm.enabled` | Enable Helm hooks for the migration job (uses pre-install,pre-upgrade hooks with before-hook-creation delete policy) | `false` | +| `migrationJob.hooks.helm.weight` | Helm hook execution order (lower weights executed first). Optional - defaults to "1" if not specified. | N/A | ## Accessing the Admin UI + When browsing to the URL published per the settings in `ingress.*`, you will -be prompted for **Admin Configuration**. The **Proxy Endpoint** is the internal +be prompted for **Admin Configuration**. The **Proxy Endpoint** is the internal (from the `litellm` pod's perspective) URL published by the `-litellm` -Kubernetes Service. If the deployment uses the default settings for this +Kubernetes Service. If the deployment uses the default settings for this service, the **Proxy Endpoint** should be set to `http://-litellm:4000`. The **Proxy Key** is the value specified for `masterkey` or, if a `masterkey` @@ -181,7 +183,8 @@ kubectl -n litellm get secret -litellm-masterkey -o jsonpath="{.data.ma ``` ## Admin UI Limitations -At the time of writing, the Admin UI is unable to add models. This is because + +At the time of writing, the Admin UI is unable to add models. This is because it would need to update the `config.yaml` file which is a exposed ConfigMap, and -therefore, read-only. This is a limitation of this helm chart, not the Admin UI +therefore, read-only. This is a limitation of this helm chart, not the Admin UI itself. diff --git a/deploy/charts/litellm-helm/templates/ingress.yaml b/deploy/charts/litellm-helm/templates/ingress.yaml index 09e8d715ab8..ea9ffcbb54c 100644 --- a/deploy/charts/litellm-helm/templates/ingress.yaml +++ b/deploy/charts/litellm-helm/templates/ingress.yaml @@ -18,6 +18,9 @@ metadata: name: {{ $fullName }} labels: {{- include "litellm.labels" . | nindent 4 }} + {{- with .Values.ingress.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} {{- with .Values.ingress.annotations }} annotations: {{- toYaml . | nindent 4 }} diff --git a/deploy/charts/litellm-helm/tests/ingress_tests.yaml b/deploy/charts/litellm-helm/tests/ingress_tests.yaml new file mode 100644 index 00000000000..aad6ecfcee8 --- /dev/null +++ b/deploy/charts/litellm-helm/tests/ingress_tests.yaml @@ -0,0 +1,45 @@ +suite: Ingress Configuration Tests +templates: + - ingress.yaml +tests: + - it: should not create Ingress by default + asserts: + - hasDocuments: + count: 0 + + - it: should create Ingress when enabled + set: + ingress.enabled: true + asserts: + - hasDocuments: + count: 1 + - isKind: + of: Ingress + + - it: should add custom labels + set: + ingress.enabled: true + ingress.labels: + custom-label: "true" + another-label: "value" + asserts: + - isKind: + of: Ingress + - equal: + path: metadata.labels.custom-label + value: "true" + - equal: + path: metadata.labels.another-label + value: "value" + + - it: should add annotations + set: + ingress.enabled: true + ingress.annotations: + kubernetes.io/ingress.class: "nginx" + asserts: + - isKind: + of: Ingress + - equal: + path: metadata.annotations["kubernetes.io/ingress.class"] + value: "nginx" diff --git a/deploy/charts/litellm-helm/values.yaml b/deploy/charts/litellm-helm/values.yaml index acb8c9ca32f..35021157826 100644 --- a/deploy/charts/litellm-helm/values.yaml +++ b/deploy/charts/litellm-helm/values.yaml @@ -35,7 +35,8 @@ podAnnotations: {} podLabels: {} terminationGracePeriodSeconds: 90 -topologySpreadConstraints: [] +topologySpreadConstraints: + [] # - maxSkew: 1 # topologyKey: kubernetes.io/hostname # whenUnsatisfiable: DoNotSchedule @@ -46,7 +47,8 @@ topologySpreadConstraints: [] # At the time of writing, the litellm docker image requires write access to the # filesystem on startup so that prisma can install some dependencies. podSecurityContext: {} -securityContext: {} +securityContext: + {} # capabilities: # drop: # - ALL @@ -57,13 +59,15 @@ securityContext: {} # A list of Kubernetes Secret objects that will be exported to the LiteLLM proxy # pod as environment variables. These secrets can then be referenced in the # configuration file (or "litellm" ConfigMap) with `os.environ/` -environmentSecrets: [] +environmentSecrets: + [] # - litellm-env-secret # A list of Kubernetes ConfigMap objects that will be exported to the LiteLLM proxy # pod as environment variables. The ConfigMap kv-pairs can then be referenced in the # configuration file (or "litellm" ConfigMap) with `os.environ/` -environmentConfigMaps: [] +environmentConfigMaps: + [] # - litellm-env-configmap service: @@ -82,7 +86,9 @@ separateHealthPort: 8081 ingress: enabled: false className: "nginx" - annotations: {} + labels: {} + annotations: + {} # kubernetes.io/ingress.class: nginx # kubernetes.io/tls-acme: "true" hosts: @@ -129,7 +135,8 @@ proxy_config: general_settings: master_key: os.environ/PROXY_MASTER_KEY -resources: {} +resources: + {} # We usually recommend not to specify default resources and to leave this as a conscious # choice for the user. This also increases chances charts run on environments with little # resources, such as Minikube. If you do want to specify resources, uncomment the following @@ -231,7 +238,7 @@ migrationJob: # cpu: 100m # memory: 100Mi extraContainers: [] - + # Hook configuration hooks: argocd: @@ -240,30 +247,30 @@ migrationJob: enabled: false # Additional environment variables to be added to the deployment as a map of key-value pairs -envVars: { - # USE_DDTRACE: "true" -} +envVars: {} +# USE_DDTRACE: "true" # Additional environment variables to be added to the deployment as a list of k8s env vars -extraEnvVars: { - # - name: EXTRA_ENV_VAR - # value: EXTRA_ENV_VAR_VALUE -} +extraEnvVars: {} +# - name: EXTRA_ENV_VAR +# value: EXTRA_ENV_VAR_VALUE # Pod Disruption Budget pdb: enabled: false # Set exactly one of the following. If both are set, minAvailable takes precedence. - minAvailable: null # e.g. "50%" or 1 - maxUnavailable: null # e.g. 1 or "20%" + minAvailable: null # e.g. "50%" or 1 + maxUnavailable: null # e.g. 1 or "20%" annotations: {} labels: {} serviceMonitor: enabled: false - labels: {} + labels: + {} # test: test - annotations: {} + annotations: + {} # kubernetes.io/test: test interval: 15s scrapeTimeout: 10s @@ -273,4 +280,4 @@ serviceMonitor: # action: replace namespaceSelector: matchNames: [] - # - test-namespace \ No newline at end of file + # - test-namespace From 1ac2655b17f006f738632c27194153b93e9faa0c Mon Sep 17 00:00:00 2001 From: rioiart Date: Wed, 3 Dec 2025 07:46:03 +0100 Subject: [PATCH 29/37] Fix/organization max budget not enforced (#17334) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: add failing tests for organization budget enforcement bug Add comprehensive tests exposing that organization-level budgets are retrieved but never enforced during request authentication. Tests verify: 1. Basic org budget exceeded scenario (team under budget, org over) 2. Multiple teams collectively exceeding org budget 3. Organization budget fields exist but are never checked 4. Inconsistency between team budget enforcement (works) and org (doesn't) Tests intentionally fail to document the bug. Will be fixed in next commit. Related to organization_max_budget not being enforced in auth_checks.py * fix: enforce organization budget in auth checks Add organization budget enforcement to common_checks() in auth_checks.py. Previously, organization_max_budget was retrieved from DB but never checked, allowing teams to collectively exceed their organization's budget limit. Changes: - Add _organization_max_budget_check() function following team budget pattern - Call org budget check after team budget check in common_checks() - Add "organization_budget" to budget_alerts type literals - Update tests to verify org budget is enforced Budget hierarchy is now properly enforced: Organization Budget (hard ceiling) └─ Team Budget (sub-allocation) └─ Team Member Budget (per-user within team) └─ Key Budget (per-key) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * fix: add organization_id to budget alerts, fix enum comparison and linting of newly added code - Add organization_id field to CallInfo class for better alert context - Include organization_id in budget alerts (token, soft, team, org) - Fix event_group enum comparison (was comparing enum to string) - Add OrganizationBudgetAlert class for organization budget alerting - Add organization_budget to test parameterizations - Apply Black formatting to slack_alerting.py --------- Co-authored-by: Claude --- .../SlackAlerting/budget_alert_types.py | 10 + .../SlackAlerting/slack_alerting.py | 19 +- litellm/proxy/_types.py | 1 + litellm/proxy/auth/auth_checks.py | 70 ++++ litellm/proxy/utils.py | 1 + tests/logging_callback_tests/test_alerting.py | 2 + .../test_organization_budget_enforcement.py | 344 ++++++++++++++++++ 7 files changed, 441 insertions(+), 6 deletions(-) create mode 100644 tests/test_litellm/proxy/auth/test_organization_budget_enforcement.py diff --git a/litellm/integrations/SlackAlerting/budget_alert_types.py b/litellm/integrations/SlackAlerting/budget_alert_types.py index 1e9ad286e37..dadfef3fc40 100644 --- a/litellm/integrations/SlackAlerting/budget_alert_types.py +++ b/litellm/integrations/SlackAlerting/budget_alert_types.py @@ -50,6 +50,14 @@ class TeamBudgetAlert(BaseBudgetAlertType): return user_info.team_id or "default_id" +class OrganizationBudgetAlert(BaseBudgetAlertType): + def get_event_message(self) -> str: + return "Organization Budget: " + + def get_id(self, user_info: CallInfo) -> str: + return user_info.organization_id or "default_id" + + class TokenBudgetAlert(BaseBudgetAlertType): def get_event_message(self) -> str: return "Key Budget: " @@ -72,6 +80,7 @@ def get_budget_alert_type( "soft_budget", "user_budget", "team_budget", + "organization_budget", "proxy_budget", "projected_limit_exceeded", ], @@ -83,6 +92,7 @@ def get_budget_alert_type( "soft_budget": SoftBudgetAlert(), "user_budget": UserBudgetAlert(), "team_budget": TeamBudgetAlert(), + "organization_budget": OrganizationBudgetAlert(), "token_budget": TokenBudgetAlert(), "projected_limit_exceeded": ProjectedLimitExceededAlert(), } diff --git a/litellm/integrations/SlackAlerting/slack_alerting.py b/litellm/integrations/SlackAlerting/slack_alerting.py index 3efe5873786..0e691e2c43f 100644 --- a/litellm/integrations/SlackAlerting/slack_alerting.py +++ b/litellm/integrations/SlackAlerting/slack_alerting.py @@ -134,19 +134,25 @@ class SlackAlerting(CustomBatchLogger): if llm_router is not None: self.llm_router = llm_router - def _prepare_outage_value_for_cache(self, outage_value: Union[dict, ProviderRegionOutageModel, OutageModel]) -> dict: + def _prepare_outage_value_for_cache( + self, outage_value: Union[dict, ProviderRegionOutageModel, OutageModel] + ) -> dict: """ Helper method to prepare outage value for Redis caching. Converts set objects to lists for JSON serialization. """ # Convert to dict for processing cache_value = dict(outage_value) - - if "deployment_ids" in cache_value and isinstance(cache_value["deployment_ids"], set): + + if "deployment_ids" in cache_value and isinstance( + cache_value["deployment_ids"], set + ): cache_value["deployment_ids"] = list(cache_value["deployment_ids"]) return cache_value - def _restore_outage_value_from_cache(self, outage_value: Optional[dict]) -> Optional[dict]: + def _restore_outage_value_from_cache( + self, outage_value: Optional[dict] + ) -> Optional[dict]: """ Helper method to restore outage value after retrieving from cache. Converts list objects back to sets for proper handling. @@ -528,6 +534,7 @@ class SlackAlerting(CustomBatchLogger): "soft_budget", "user_budget", "team_budget", + "organization_budget", "proxy_budget", "projected_limit_exceeded", ], @@ -1338,7 +1345,7 @@ Model Info: subject=email_event["subject"], html=email_event["html"], ) - if webhook_event.event_group == "team": + if webhook_event.event_group == Litellm_EntityType.TEAM: from litellm.integrations.email_alerting import send_team_budget_alert await send_team_budget_alert(webhook_event=webhook_event) @@ -1399,7 +1406,7 @@ Model Info: current_time = datetime.now().strftime("%H:%M:%S") _proxy_base_url = os.getenv("PROXY_BASE_URL", None) # Use .name if it's an enum, otherwise use as is - alert_type_name = getattr(alert_type, 'name', alert_type) + alert_type_name = getattr(alert_type, "name", alert_type) alert_type_formatted = f"Alert type: `{alert_type_name}`" if alert_type == "daily_reports" or alert_type == "new_model_added": formatted_message = alert_type_formatted + message diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 7e7d4049818..53a8627bc8f 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -2444,6 +2444,7 @@ class CallInfo(LiteLLMPydanticObjectBase): user_id: Optional[str] = None team_id: Optional[str] = None team_alias: Optional[str] = None + organization_id: Optional[str] = None user_email: Optional[str] = None key_alias: Optional[str] = None projected_exceeded_date: Optional[str] = None diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index c9774b18b88..45b0752d4cd 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -143,6 +143,14 @@ async def common_checks( valid_token=valid_token, ) + # 3.1. If organization is in budget + await _organization_max_budget_check( + valid_token=valid_token, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + await _tag_max_budget_check( request_body=request_body, prisma_client=prisma_client, @@ -1893,6 +1901,7 @@ async def _virtual_key_max_budget_check( max_budget=valid_token.max_budget, user_id=valid_token.user_id, team_id=valid_token.team_id, + organization_id=valid_token.org_id, user_email=user_email, key_alias=valid_token.key_alias, event_group=Litellm_EntityType.KEY, @@ -1939,6 +1948,7 @@ async def _virtual_key_soft_budget_check( user_id=valid_token.user_id, team_id=valid_token.team_id, team_alias=valid_token.team_alias, + organization_id=valid_token.org_id, user_email=None, key_alias=valid_token.key_alias, event_group=Litellm_EntityType.KEY, @@ -1977,6 +1987,7 @@ async def _team_max_budget_check( user_id=valid_token.user_id, team_id=valid_token.team_id, team_alias=valid_token.team_alias, + organization_id=valid_token.org_id, event_group=Litellm_EntityType.TEAM, ) asyncio.create_task( @@ -1993,6 +2004,65 @@ async def _team_max_budget_check( ) +async def _organization_max_budget_check( + valid_token: Optional[UserAPIKeyAuth], + prisma_client: Optional[PrismaClient], + user_api_key_cache: DualCache, + proxy_logging_obj: ProxyLogging, +): + """ + Check if the organization is over its max budget. + + Raises: + BudgetExceededError if the organization is over its max budget. + Triggers a budget alert if the organization is over its max budget. + """ + # Only check if token has organization info and organization_max_budget is set + if ( + valid_token is None + or valid_token.org_id is None + or valid_token.organization_max_budget is None + or valid_token.organization_max_budget <= 0 + ): + return + + # Get organization object to check current spend + if prisma_client is not None: + org_table = await get_org_object( + org_id=valid_token.org_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + ) + + if ( + org_table is not None + and org_table.spend >= valid_token.organization_max_budget + ): + # Trigger budget alert + call_info = CallInfo( + token=valid_token.token, + spend=org_table.spend, + max_budget=valid_token.organization_max_budget, + user_id=valid_token.user_id, + team_id=valid_token.team_id, + team_alias=valid_token.team_alias, + organization_id=valid_token.org_id, + event_group=Litellm_EntityType.ORGANIZATION, + ) + asyncio.create_task( + proxy_logging_obj.budget_alerts( + type="organization_budget", + user_info=call_info, + ) + ) + + raise litellm.BudgetExceededError( + current_cost=org_table.spend, + max_budget=valid_token.organization_max_budget, + message=f"Budget has been exceeded! Organization={valid_token.org_id} Current cost: {org_table.spend}, Max budget: {valid_token.organization_max_budget}", + ) + + async def _tag_max_budget_check( request_body: dict, prisma_client: Optional[PrismaClient], diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index 9594a55962c..3b746b757ec 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -1072,6 +1072,7 @@ class ProxyLogging: "user_budget", "soft_budget", "team_budget", + "organization_budget", "proxy_budget", "projected_limit_exceeded", ], diff --git a/tests/logging_callback_tests/test_alerting.py b/tests/logging_callback_tests/test_alerting.py index b9ecfaeb3f0..ac7f5cd6aa1 100644 --- a/tests/logging_callback_tests/test_alerting.py +++ b/tests/logging_callback_tests/test_alerting.py @@ -477,6 +477,7 @@ async def test_send_daily_reports_all_zero_or_none(): "token_budget", "user_budget", "team_budget", + "organization_budget", "proxy_budget", "projected_limit_exceeded", ], @@ -514,6 +515,7 @@ async def test_send_token_budget_crossed_alerts(alerting_type): "token_budget", "user_budget", "team_budget", + "organization_budget", "proxy_budget", "projected_limit_exceeded", ], diff --git a/tests/test_litellm/proxy/auth/test_organization_budget_enforcement.py b/tests/test_litellm/proxy/auth/test_organization_budget_enforcement.py new file mode 100644 index 00000000000..9c2adca9cd3 --- /dev/null +++ b/tests/test_litellm/proxy/auth/test_organization_budget_enforcement.py @@ -0,0 +1,344 @@ +""" +Tests for organization budget enforcement. + +These tests verify that organization-level budgets are properly enforced during +request authentication. When an organization's spend exceeds its max_budget, +requests should fail with BudgetExceededError. + +This prevents teams within an organization from collectively exceeding the +organization's budget limit. +""" + +import asyncio +import os +import sys +from typing import Optional +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +sys.path.insert(0, os.path.abspath("../../../")) + +import litellm +from litellm.proxy._types import ( + LiteLLM_BudgetTable, + LiteLLM_OrganizationTable, + LiteLLM_TeamTable, + UserAPIKeyAuth, +) +from litellm.proxy.auth.auth_checks import common_checks +from litellm.proxy.utils import ProxyLogging + + +@pytest.mark.asyncio +async def test_organization_budget_exceeded_blocks_request(): + """ + Bug: Organization budget is retrieved but NEVER enforced. + + When organization spend >= organization_max_budget, requests should fail + with BudgetExceededError. Currently this passes because no check exists. + """ + org_id = "test-org-budget-exceeded" + + # Organization with max_budget of 100, but spend is 150 + org_object = LiteLLM_OrganizationTable( + organization_id=org_id, + budget_id="org-budget-1", + spend=150.0, # Over budget! + models=["gpt-4"], + created_by="test", + updated_by="test", + litellm_budget_table=LiteLLM_BudgetTable( + max_budget=100.0, # Budget is 100 + ), + ) + + # Team within the organization (team itself is under budget) + team_object = LiteLLM_TeamTable( + team_id="test-team-1", + organization_id=org_id, + max_budget=50.0, # Team budget is 50 + spend=10.0, # Team spend is only 10 - under budget + models=["gpt-4"], + ) + + # Valid token with organization info + valid_token = UserAPIKeyAuth( + token="sk-test-123", + team_id="test-team-1", + org_id=org_id, + organization_max_budget=100.0, # This is set but never checked! + ) + + mock_request = MagicMock() + mock_request.url.path = "/v1/chat/completions" + + mock_proxy_logging = MagicMock(spec=ProxyLogging) + mock_proxy_logging.budget_alerts = AsyncMock() + + with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma: + with patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache: + with patch("litellm.proxy.auth.auth_checks.get_org_object", new_callable=AsyncMock) as mock_get_org: + mock_get_org.return_value = org_object + + # BUG: This should raise BudgetExceededError but currently passes + with pytest.raises(litellm.BudgetExceededError) as exc_info: + await common_checks( + request_body={"model": "gpt-4"}, + team_object=team_object, + user_object=None, + end_user_object=None, + global_proxy_spend=None, + general_settings={}, + route="/v1/chat/completions", + llm_router=None, + proxy_logging_obj=mock_proxy_logging, + valid_token=valid_token, + request=mock_request, + ) + + assert "Organization" in str(exc_info.value.message) + assert exc_info.value.current_cost == 150.0 + assert exc_info.value.max_budget == 100.0 + + +@pytest.mark.asyncio +async def test_multiple_teams_exceed_organization_budget(): + """ + Test that organization budget is enforced even when individual teams are under budget. + + Scenario: + - Organization max_budget = $5000, spend = $5000 (at limit) + - Team A spend = $1500 (under team budget of $2000) + - Request via Team A should FAIL because org is at budget limit + + Expected: Request fails with BudgetExceededError + """ + org_id = "multi-team-org" + + # Organization at budget limit + org_object = LiteLLM_OrganizationTable( + organization_id=org_id, + budget_id="org-budget-2", + spend=5000.0, # At $5000 limit + models=["gpt-4"], + created_by="test", + updated_by="test", + litellm_budget_table=LiteLLM_BudgetTable( + max_budget=5000.0, # Org budget is $5000 + ), + ) + + # Team A - under its own budget, but org is almost at limit + team_a = LiteLLM_TeamTable( + team_id="team-a", + organization_id=org_id, + max_budget=2000.0, + spend=1500.0, # Team A has spent $1500 of its $2000 budget + models=["gpt-4"], + ) + + valid_token = UserAPIKeyAuth( + token="sk-team-a-key", + team_id="team-a", + org_id=org_id, + organization_max_budget=5000.0, # Set but never enforced + ) + + mock_request = MagicMock() + mock_request.url.path = "/v1/chat/completions" + + mock_proxy_logging = MagicMock(spec=ProxyLogging) + mock_proxy_logging.budget_alerts = AsyncMock() + + with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma: + with patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache: + with patch("litellm.proxy.auth.auth_checks.get_org_object", new_callable=AsyncMock) as mock_get_org: + mock_get_org.return_value = org_object + + # Org is at budget limit, should raise BudgetExceededError + with pytest.raises(litellm.BudgetExceededError) as exc_info: + await common_checks( + request_body={"model": "gpt-4"}, + team_object=team_a, + user_object=None, + end_user_object=None, + global_proxy_spend=None, + general_settings={}, + route="/v1/chat/completions", + llm_router=None, + proxy_logging_obj=mock_proxy_logging, + valid_token=valid_token, + request=mock_request, + ) + + # Verify the error message mentions organization + assert "Organization" in str(exc_info.value.message) + assert exc_info.value.current_cost == 5000.0 + assert exc_info.value.max_budget == 5000.0 + + +@pytest.mark.asyncio +async def test_organization_budget_fields_are_checked(): + """ + Verify that organization_max_budget is populated in UserAPIKeyAuth + and BudgetExceededError is raised when organization is over budget. + """ + # Token has org budget info + valid_token = UserAPIKeyAuth( + token="sk-test", + team_id="test-team", + org_id="test-org", + organization_max_budget=100.0, # Budget is $100 + ) + + # Verify the field exists and is set + assert valid_token.organization_max_budget == 100.0 + assert valid_token.org_id == "test-org" + + team_object = LiteLLM_TeamTable( + team_id="test-team", + organization_id="test-org", + max_budget=None, + spend=0.0, + models=["gpt-4"], + ) + + mock_request = MagicMock() + mock_request.url.path = "/v1/chat/completions" + + mock_proxy_logging = MagicMock(spec=ProxyLogging) + mock_proxy_logging.budget_alerts = AsyncMock() + + # Organization is over budget + org_over_budget = LiteLLM_OrganizationTable( + organization_id="test-org", + budget_id="budget-1", + spend=150.0, # Over $100 budget + models=["gpt-4"], + created_by="test", + updated_by="test", + litellm_budget_table=LiteLLM_BudgetTable(max_budget=100.0), + ) + + with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma: + with patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache: + with patch("litellm.proxy.auth.auth_checks.get_org_object", new_callable=AsyncMock) as mock_get_org: + mock_get_org.return_value = org_over_budget + + # Organization is over budget, should raise BudgetExceededError + with pytest.raises(litellm.BudgetExceededError) as exc_info: + await common_checks( + request_body={"model": "gpt-4"}, + team_object=team_object, + user_object=None, + end_user_object=None, + global_proxy_spend=None, + general_settings={}, + route="/v1/chat/completions", + llm_router=None, + proxy_logging_obj=mock_proxy_logging, + valid_token=valid_token, + request=mock_request, + ) + + assert exc_info.value.current_cost == 150.0 + assert exc_info.value.max_budget == 100.0 + + +@pytest.mark.asyncio +async def test_both_team_and_org_budget_enforced(): + """ + Verify that both team budget and organization budget are enforced consistently. + + This test verifies: + 1. Team over budget raises BudgetExceededError + 2. Organization over budget also raises BudgetExceededError + """ + mock_request = MagicMock() + mock_request.url.path = "/v1/chat/completions" + + mock_proxy_logging = MagicMock(spec=ProxyLogging) + mock_proxy_logging.budget_alerts = AsyncMock() + + # Scenario A: Team over budget - should raise BudgetExceededError + team_over_budget = LiteLLM_TeamTable( + team_id="team-over", + max_budget=100.0, + spend=150.0, # Over budget + models=["gpt-4"], + ) + + valid_token_team = UserAPIKeyAuth( + token="sk-team-test", + team_id="team-over", + ) + + with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma: + with patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache: + with pytest.raises(litellm.BudgetExceededError) as exc_info: + await common_checks( + request_body={"model": "gpt-4"}, + team_object=team_over_budget, + user_object=None, + end_user_object=None, + global_proxy_spend=None, + general_settings={}, + route="/v1/chat/completions", + llm_router=None, + proxy_logging_obj=mock_proxy_logging, + valid_token=valid_token_team, + request=mock_request, + ) + assert "Team" in str(exc_info.value.message) + + # Scenario B: Org over budget - should also raise BudgetExceededError + org_over_budget = LiteLLM_OrganizationTable( + organization_id="org-over", + budget_id="budget-1", + spend=150.0, # Over $100 budget + models=["gpt-4"], + created_by="test", + updated_by="test", + litellm_budget_table=LiteLLM_BudgetTable(max_budget=100.0), + ) + + team_under_budget = LiteLLM_TeamTable( + team_id="team-under", + organization_id="org-over", + max_budget=50.0, + spend=10.0, # Team is fine + models=["gpt-4"], + ) + + valid_token_org = UserAPIKeyAuth( + token="sk-org-test", + team_id="team-under", + org_id="org-over", + organization_max_budget=100.0, + ) + + with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma: + with patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache: + with patch("litellm.proxy.auth.auth_checks.get_org_object", new_callable=AsyncMock) as mock_get_org: + mock_get_org.return_value = org_over_budget + + # Organization is over budget, should raise BudgetExceededError + with pytest.raises(litellm.BudgetExceededError) as exc_info: + await common_checks( + request_body={"model": "gpt-4"}, + team_object=team_under_budget, + user_object=None, + end_user_object=None, + global_proxy_spend=None, + general_settings={}, + route="/v1/chat/completions", + llm_router=None, + proxy_logging_obj=mock_proxy_logging, + valid_token=valid_token_org, + request=mock_request, + ) + + assert "Organization" in str(exc_info.value.message) + assert exc_info.value.current_cost == 150.0 + assert exc_info.value.max_budget == 100.0 From 54e29e7828f698690963eafac8148aeaf466262f Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Wed, 3 Dec 2025 12:19:21 +0530 Subject: [PATCH 30/37] Enforce support of enforce_user_param to openai post endpoints --- litellm/proxy/auth/auth_checks.py | 13 +- .../proxy/test_enforce_user_param.py | 438 ++++++++++++++++++ 2 files changed, 450 insertions(+), 1 deletion(-) create mode 100644 tests/test_litellm/proxy/test_enforce_user_param.py diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index c9774b18b88..92cffe0ea82 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -182,7 +182,18 @@ async def common_checks( general_settings.get("enforce_user_param", None) is not None and general_settings["enforce_user_param"] is True ): - if RouteChecks.is_llm_api_route(route=route) and "user" not in request_body: + # Get HTTP method from request + http_method = request.method if hasattr(request, 'method') else None + + # Check if it's a POST request and if it's an OpenAI route but not MCP + is_post_method = http_method and http_method.upper() == "POST" + is_openai_route = RouteChecks.is_llm_api_route(route=route) + is_mcp_route = route in LiteLLMRoutes.mcp_routes.value or RouteChecks.check_route_access( + route=route, allowed_routes=LiteLLMRoutes.mcp_routes.value + ) + + # Enforce user param only for POST requests on OpenAI routes (excluding MCP routes) + if is_post_method and is_openai_route and not is_mcp_route and "user" not in request_body: raise Exception( f"'user' param not passed in. 'enforce_user_param'={general_settings['enforce_user_param']}" ) diff --git a/tests/test_litellm/proxy/test_enforce_user_param.py b/tests/test_litellm/proxy/test_enforce_user_param.py new file mode 100644 index 00000000000..5d9369d61bb --- /dev/null +++ b/tests/test_litellm/proxy/test_enforce_user_param.py @@ -0,0 +1,438 @@ +""" +Tests for enforce_user_param feature with POST/GET method filtering and MCP route exclusion. + +Tests verify that: +1. enforce_user_param only applies to POST requests +2. GET requests like /v1/models are not affected +3. MCP routes are excluded from enforcement +4. POST requests to completion endpoints still require user param when enforced +""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch + +import pytest +from fastapi import Request + +from litellm.proxy._types import LiteLLMRoutes, UserAPIKeyAuth +from litellm.proxy.auth.auth_checks import common_checks +from litellm.proxy.auth.route_checks import RouteChecks + + +class MockRequest: + """Mock FastAPI Request object""" + def __init__(self, method: str = "POST"): + self.method = method + + +def get_mock_user_token(): + """Create a mock UserAPIKeyAuth token for testing""" + return UserAPIKeyAuth( + api_key="test-key", + user_id="test-user", + team_id="test-team", + org_id="test-org", + models=["*"], + metadata={} + ) + + +class TestEnforceUserParamPostGetFiltering: + """Test POST/GET method filtering for enforce_user_param""" + + @pytest.mark.asyncio + async def test_post_completion_without_user_param_should_fail(self): + """POST to /v1/chat/completions without user param should raise error""" + request = MockRequest(method="POST") + general_settings = {"enforce_user_param": True} + request_body = { + "model": "gpt-3.5-turbo", + "messages": [{"role": "user", "content": "Hello"}] + } + + with patch('litellm.proxy.auth.auth_checks._is_api_route_allowed', new_callable=AsyncMock, return_value=True): + with pytest.raises(Exception) as exc_info: + await common_checks( + request_body=request_body, + team_object=None, + user_object=None, + end_user_object=None, + global_proxy_spend=None, + general_settings=general_settings, + route="/v1/chat/completions", + llm_router=None, + proxy_logging_obj=MagicMock(), + valid_token=get_mock_user_token(), + request=request, + ) + + assert "user" in str(exc_info.value).lower() + + @pytest.mark.asyncio + async def test_post_completion_with_user_param_should_pass(self): + """POST to /v1/chat/completions with user param should pass""" + request = MockRequest(method="POST") + general_settings = {"enforce_user_param": True} + request_body = { + "model": "gpt-3.5-turbo", + "messages": [{"role": "user", "content": "Hello"}], + "user": "user123" + } + + with patch('litellm.proxy.auth.auth_checks._is_api_route_allowed', new_callable=AsyncMock, return_value=True): + # Should not raise exception + result = await common_checks( + request_body=request_body, + team_object=None, + user_object=None, + end_user_object=None, + global_proxy_spend=None, + general_settings=general_settings, + route="/v1/chat/completions", + llm_router=None, + proxy_logging_obj=MagicMock(), + valid_token=get_mock_user_token(), + request=request, + ) + + assert result is True + + @pytest.mark.asyncio + async def test_get_models_without_user_param_should_pass(self): + """GET to /v1/models without user param should NOT raise error""" + request = MockRequest(method="GET") + general_settings = {"enforce_user_param": True} + request_body = {} # GET requests typically don't have body + + with patch('litellm.proxy.auth.auth_checks._is_api_route_allowed', new_callable=AsyncMock, return_value=True): + # Should not raise exception + result = await common_checks( + request_body=request_body, + team_object=None, + user_object=None, + end_user_object=None, + global_proxy_spend=None, + general_settings=general_settings, + route="/v1/models", + llm_router=None, + proxy_logging_obj=MagicMock(), + valid_token=get_mock_user_token(), + request=request, + ) + + assert result is True + + @pytest.mark.asyncio + async def test_get_files_without_user_param_should_pass(self): + """GET to /v1/files without user param should NOT raise error""" + request = MockRequest(method="GET") + general_settings = {"enforce_user_param": True} + request_body = {} + + with patch('litellm.proxy.auth.auth_checks._is_api_route_allowed', new_callable=AsyncMock, return_value=True): + result = await common_checks( + request_body=request_body, + team_object=None, + user_object=None, + end_user_object=None, + global_proxy_spend=None, + general_settings=general_settings, + route="/v1/files", + llm_router=None, + proxy_logging_obj=MagicMock(), + valid_token=get_mock_user_token(), + request=request, + ) + + assert result is True + + @pytest.mark.asyncio + async def test_post_embeddings_without_user_param_should_fail(self): + """POST to /v1/embeddings without user param should raise error""" + request = MockRequest(method="POST") + general_settings = {"enforce_user_param": True} + request_body = { + "model": "text-embedding-ada-002", + "input": "test" + } + + with patch('litellm.proxy.auth.auth_checks._is_api_route_allowed', new_callable=AsyncMock, return_value=True): + with pytest.raises(Exception) as exc_info: + await common_checks( + request_body=request_body, + team_object=None, + user_object=None, + end_user_object=None, + global_proxy_spend=None, + general_settings=general_settings, + route="/v1/embeddings", + llm_router=None, + proxy_logging_obj=MagicMock(), + valid_token=get_mock_user_token(), + request=request, + ) + + assert "user" in str(exc_info.value).lower() + + @pytest.mark.asyncio + async def test_post_embeddings_with_user_param_should_pass(self): + """POST to /v1/embeddings with user param should pass""" + request = MockRequest(method="POST") + general_settings = {"enforce_user_param": True} + request_body = { + "model": "text-embedding-ada-002", + "input": "test", + "user": "user123" + } + + with patch('litellm.proxy.auth.auth_checks._is_api_route_allowed', new_callable=AsyncMock, return_value=True): + result = await common_checks( + request_body=request_body, + team_object=None, + user_object=None, + end_user_object=None, + global_proxy_spend=None, + general_settings=general_settings, + route="/v1/embeddings", + llm_router=None, + proxy_logging_obj=MagicMock(), + valid_token=get_mock_user_token(), + request=request, + ) + + assert result is True + + +class TestEnforceUserParamMCPExclusion: + """Test MCP route exclusion from enforce_user_param""" + + @pytest.mark.asyncio + async def test_mcp_route_without_user_param_should_pass(self): + """POST to MCP route without user param should NOT raise error""" + request = MockRequest(method="POST") + general_settings = {"enforce_user_param": True} + request_body = {"action": "list_tools"} + + with patch('litellm.proxy.auth.auth_checks._is_api_route_allowed', new_callable=AsyncMock, return_value=True): + # Should not raise exception for MCP routes + result = await common_checks( + request_body=request_body, + team_object=None, + user_object=None, + end_user_object=None, + global_proxy_spend=None, + general_settings=general_settings, + route="/mcp/tools/list", + llm_router=None, + proxy_logging_obj=MagicMock(), + valid_token=get_mock_user_token(), + request=request, + ) + + assert result is True + + @pytest.mark.asyncio + async def test_mcp_root_route_without_user_param_should_pass(self): + """POST to /mcp without user param should NOT raise error""" + request = MockRequest(method="POST") + general_settings = {"enforce_user_param": True} + request_body = {"data": "test"} + + with patch('litellm.proxy.auth.auth_checks._is_api_route_allowed', new_callable=AsyncMock, return_value=True): + result = await common_checks( + request_body=request_body, + team_object=None, + user_object=None, + end_user_object=None, + global_proxy_spend=None, + general_settings=general_settings, + route="/mcp", + llm_router=None, + proxy_logging_obj=MagicMock(), + valid_token=get_mock_user_token(), + request=request, + ) + + assert result is True + + +class TestEnforceUserParamDisabled: + """Test behavior when enforce_user_param is disabled""" + + @pytest.mark.asyncio + async def test_post_without_user_param_when_disabled_should_pass(self): + """POST without user param when enforce_user_param=False should pass""" + request = MockRequest(method="POST") + general_settings = {"enforce_user_param": False} + request_body = { + "model": "gpt-3.5-turbo", + "messages": [{"role": "user", "content": "Hello"}] + } + + with patch('litellm.proxy.auth.auth_checks._is_api_route_allowed', new_callable=AsyncMock, return_value=True): + result = await common_checks( + request_body=request_body, + team_object=None, + user_object=None, + end_user_object=None, + global_proxy_spend=None, + general_settings=general_settings, + route="/v1/chat/completions", + llm_router=None, + proxy_logging_obj=MagicMock(), + valid_token=get_mock_user_token(), + request=request, + ) + + assert result is True + + @pytest.mark.asyncio + async def test_post_without_user_param_when_not_set_should_pass(self): + """POST without user param when enforce_user_param not set should pass""" + request = MockRequest(method="POST") + general_settings = {} # enforce_user_param not set + request_body = { + "model": "gpt-3.5-turbo", + "messages": [{"role": "user", "content": "Hello"}] + } + + with patch('litellm.proxy.auth.auth_checks._is_api_route_allowed', new_callable=AsyncMock, return_value=True): + result = await common_checks( + request_body=request_body, + team_object=None, + user_object=None, + end_user_object=None, + global_proxy_spend=None, + general_settings=general_settings, + route="/v1/chat/completions", + llm_router=None, + proxy_logging_obj=MagicMock(), + valid_token=get_mock_user_token(), + request=request, + ) + + assert result is True + + +class TestEnforceUserParamEdgeCases: + """Test edge cases for enforce_user_param""" + + @pytest.mark.asyncio + async def test_request_without_method_attribute_should_pass(self): + """Request without method attribute should not raise error""" + request = MagicMock() + del request.method # Remove method attribute + request.__hasattr__ = MagicMock(return_value=False) + + general_settings = {"enforce_user_param": True} + request_body = { + "model": "gpt-3.5-turbo", + "messages": [{"role": "user", "content": "Hello"}] + } + + with patch('litellm.proxy.auth.auth_checks._is_api_route_allowed', new_callable=AsyncMock, return_value=True): + # Should not raise error even without method + result = await common_checks( + request_body=request_body, + team_object=None, + user_object=None, + end_user_object=None, + global_proxy_spend=None, + general_settings=general_settings, + route="/v1/chat/completions", + llm_router=None, + proxy_logging_obj=MagicMock(), + valid_token=get_mock_user_token(), + request=request, + ) + + assert result is True + + @pytest.mark.asyncio + async def test_case_insensitive_http_method(self): + """HTTP method comparison should be case-insensitive""" + request = MockRequest(method="post") # lowercase + general_settings = {"enforce_user_param": True} + request_body = { + "model": "gpt-3.5-turbo", + "messages": [{"role": "user", "content": "Hello"}] + } + + with patch('litellm.proxy.auth.auth_checks._is_api_route_allowed', new_callable=AsyncMock, return_value=True): + with pytest.raises(Exception) as exc_info: + await common_checks( + request_body=request_body, + team_object=None, + user_object=None, + end_user_object=None, + global_proxy_spend=None, + general_settings=general_settings, + route="/v1/chat/completions", + llm_router=None, + proxy_logging_obj=MagicMock(), + valid_token=get_mock_user_token(), + request=request, + ) + + assert "user" in str(exc_info.value).lower() + + @pytest.mark.asyncio + async def test_put_method_should_not_enforce_user_param(self): + """PUT method should not enforce user param (only POST)""" + request = MockRequest(method="PUT") + general_settings = {"enforce_user_param": True} + request_body = { + "model": "gpt-3.5-turbo", + "messages": [{"role": "user", "content": "Hello"}] + } + + with patch('litellm.proxy.auth.auth_checks._is_api_route_allowed', new_callable=AsyncMock, return_value=True): + # Should not raise for PUT method + result = await common_checks( + request_body=request_body, + team_object=None, + user_object=None, + end_user_object=None, + global_proxy_spend=None, + general_settings=general_settings, + route="/v1/chat/completions", + llm_router=None, + proxy_logging_obj=MagicMock(), + valid_token=get_mock_user_token(), + request=request, + ) + + assert result is True + + @pytest.mark.asyncio + async def test_patch_method_should_not_enforce_user_param(self): + """PATCH method should not enforce user param (only POST)""" + request = MockRequest(method="PATCH") + general_settings = {"enforce_user_param": True} + request_body = { + "model": "gpt-3.5-turbo", + "messages": [{"role": "user", "content": "Hello"}] + } + + with patch('litellm.proxy.auth.auth_checks._is_api_route_allowed', new_callable=AsyncMock, return_value=True): + # Should not raise for PATCH method + result = await common_checks( + request_body=request_body, + team_object=None, + user_object=None, + end_user_object=None, + global_proxy_spend=None, + general_settings=general_settings, + route="/v1/chat/completions", + llm_router=None, + proxy_logging_obj=MagicMock(), + valid_token=get_mock_user_token(), + request=request, + ) + + assert result is True + + +if __name__ == "__main__": + # Run tests with: pytest tests/test_litellm/proxy/test_enforce_user_param.py -v + pytest.main([__file__, "-v"]) From 74ba18df55906165e5796a5408cbfd1fc8047604 Mon Sep 17 00:00:00 2001 From: Krish Dholakia Date: Tue, 2 Dec 2025 22:50:13 -0800 Subject: [PATCH 31/37] Litellm chainguard fixes 12 02 2025 p1 (#17406) * build: update dockerfile non root * build: update build * build: update non root * build: dockerfile fixes * build: ensure dockerfile + dockerfile.database also work --- Dockerfile | 15 +++++---------- docker/Dockerfile.database | 16 +++++++++------- docker/Dockerfile.non_root | 8 +++++--- 3 files changed, 19 insertions(+), 20 deletions(-) diff --git a/Dockerfile b/Dockerfile index f75706805e0..d8397ec4811 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ # Base image for building -ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/python:latest-dev +ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base # Runtime image -ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/python:latest-dev +ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base # Builder stage FROM $LITELLM_BUILD_IMAGE AS builder @@ -12,11 +12,9 @@ WORKDIR /app USER root # Install build dependencies -RUN apk add --no-cache gcc python3-dev openssl openssl-dev +RUN apk add --no-cache bash gcc py3-pip python3 python3-dev openssl openssl-dev - -RUN pip install --upgrade pip>=24.3.1 && \ - pip install build +RUN python -m pip install build # Copy the current directory contents into the container at /app COPY . . @@ -48,10 +46,7 @@ FROM $LITELLM_RUNTIME_IMAGE AS runtime USER root # Install runtime dependencies -RUN apk add --no-cache openssl tzdata nodejs npm - -# Upgrade pip to fix CVE-2025-8869 -RUN pip install --upgrade pip>=24.3.1 +RUN apk add --no-cache bash openssl tzdata nodejs npm python3 py3-pip WORKDIR /app # Copy the current directory contents into the container at /app diff --git a/docker/Dockerfile.database b/docker/Dockerfile.database index 09b5265191b..0e804cbfd12 100644 --- a/docker/Dockerfile.database +++ b/docker/Dockerfile.database @@ -1,8 +1,8 @@ # Base image for building -ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/python:latest-dev +ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base # Runtime image -ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/python:latest-dev +ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base # Builder stage FROM $LITELLM_BUILD_IMAGE AS builder @@ -13,13 +13,15 @@ USER root # Install build dependencies RUN apk add --no-cache \ - build-base \ + bash \ + gcc \ + py3-pip \ + python3 \ python3-dev \ + openssl \ openssl-dev - -RUN pip install --upgrade pip && \ - pip install build +RUN python -m pip install build # Copy the current directory contents into the container at /app COPY . . @@ -46,7 +48,7 @@ FROM $LITELLM_RUNTIME_IMAGE AS runtime USER root # Install runtime dependencies -RUN apk add --no-cache openssl +RUN apk add --no-cache bash openssl tzdata nodejs npm python3 py3-pip WORKDIR /app # Copy the current directory contents into the container at /app diff --git a/docker/Dockerfile.non_root b/docker/Dockerfile.non_root index 8b66a367eeb..cd1633e319c 100644 --- a/docker/Dockerfile.non_root +++ b/docker/Dockerfile.non_root @@ -1,6 +1,6 @@ # Base images -ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/python:latest-dev -ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/python:latest-dev +ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base +ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base # ----------------- # Builder Stage @@ -11,6 +11,8 @@ WORKDIR /app # Install build dependencies including Node.js for UI build USER root RUN apk add --no-cache \ + python3 \ + py3-pip \ clang \ llvm \ lld \ @@ -71,7 +73,7 @@ WORKDIR /app # Install runtime dependencies USER root RUN apk upgrade --no-cache && \ - apk add --no-cache bash libstdc++ ca-certificates openssl supervisor + apk add --no-cache python3 py3-pip bash openssl tzdata nodejs npm supervisor # Copy only necessary artifacts from builder stage for runtime COPY . . From 8edcc4ecc3fc8ca56447e039871176622cf11aba Mon Sep 17 00:00:00 2001 From: Krish Dholakia Date: Tue, 2 Dec 2025 22:52:09 -0800 Subject: [PATCH 32/37] Guardrails API - add streaming support (#17400) * fix(initial-commit): adding a way to get the right response type based on the api route * feat(unified_guardrail.py): support streaming guardrails * test: update tests * fix: fix linting errors * test: update tests --- litellm/batches/main.py | 2 - litellm/constants.py | 18 +- litellm/integrations/custom_guardrail.py | 1 - litellm/litellm_core_utils/README.md | 1 + .../api_route_to_call_types.py | 38 ++ .../guardrail_translation/base_translation.py | 14 + .../chat/guardrail_translation/handler.py | 98 +++++- ...odel_prices_and_context_window_backup.json | 65 ++++ .../unified_guardrail/unified_guardrail.py | 110 +++++- litellm/proxy/utils.py | 21 +- litellm/types/utils.py | 327 +++++++++++++++++- .../test_apply_guardrail_endpoint.py | 116 ++++--- .../test_bedrock_apply_guardrail.py | 161 +++++---- .../rerank/test_rerank_guardrail_handler.py | 72 ++-- .../test_text_completion_guardrail_handler.py | 48 ++- ...test_image_generation_guardrail_handler.py | 30 +- ...test_openai_responses_guardrail_handler.py | 14 +- .../test_text_to_speech_guardrail_handler.py | 73 ++-- ...t_audio_transcription_guardrail_handler.py | 80 +++-- .../content_filter/test_content_filter.py | 173 +++++---- .../guardrail_hooks/test_presidio.py | 51 +-- 21 files changed, 1134 insertions(+), 379 deletions(-) create mode 100644 litellm/litellm_core_utils/api_route_to_call_types.py diff --git a/litellm/batches/main.py b/litellm/batches/main.py index 353b1e25698..b99f4a628dc 100644 --- a/litellm/batches/main.py +++ b/litellm/batches/main.py @@ -18,8 +18,6 @@ from typing import Any, Coroutine, Dict, Literal, Optional, Union, cast import httpx from openai.types.batch import BatchRequestCounts -from openai.types.batch import Metadata -from openai.types.batch import Metadata as OpenAIBatchMetadata import litellm from litellm._logging import verbose_logger diff --git a/litellm/constants.py b/litellm/constants.py index db617a2e475..57029cd2a8c 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -262,7 +262,9 @@ TOGETHER_AI_EMBEDDING_350_M = int(os.getenv("TOGETHER_AI_EMBEDDING_350_M", 350)) QDRANT_SCALAR_QUANTILE = float(os.getenv("QDRANT_SCALAR_QUANTILE", 0.99)) QDRANT_VECTOR_SIZE = int(os.getenv("QDRANT_VECTOR_SIZE", 1536)) CACHED_STREAMING_CHUNK_DELAY = float(os.getenv("CACHED_STREAMING_CHUNK_DELAY", 0.02)) -AUDIO_SPEECH_CHUNK_SIZE = 8192 # chunk_size for audio speech streaming. Balance between latency and memory usage +AUDIO_SPEECH_CHUNK_SIZE = int( + os.getenv("AUDIO_SPEECH_CHUNK_SIZE", 8192) +) # chunk_size for audio speech streaming. Balance between latency and memory usage MAX_SIZE_PER_ITEM_IN_MEMORY_CACHE_IN_KB = int( os.getenv("MAX_SIZE_PER_ITEM_IN_MEMORY_CACHE_IN_KB", 512) ) @@ -285,10 +287,16 @@ REDACTED_BY_LITELM_STRING = "REDACTED_BY_LITELM" MAX_LANGFUSE_INITIALIZED_CLIENTS = int( os.getenv("MAX_LANGFUSE_INITIALIZED_CLIENTS", 50) ) -LOGGING_WORKER_CONCURRENCY = int(os.getenv("LOGGING_WORKER_CONCURRENCY", 100)) # Must be above 0 +LOGGING_WORKER_CONCURRENCY = int( + os.getenv("LOGGING_WORKER_CONCURRENCY", 100) +) # Must be above 0 LOGGING_WORKER_MAX_QUEUE_SIZE = int(os.getenv("LOGGING_WORKER_MAX_QUEUE_SIZE", 50_000)) -LOGGING_WORKER_MAX_TIME_PER_COROUTINE = float(os.getenv("LOGGING_WORKER_MAX_TIME_PER_COROUTINE", 20.0)) -LOGGING_WORKER_CLEAR_PERCENTAGE = int(os.getenv("LOGGING_WORKER_CLEAR_PERCENTAGE", 50)) # Percentage of queue to clear (default: 50%) +LOGGING_WORKER_MAX_TIME_PER_COROUTINE = float( + os.getenv("LOGGING_WORKER_MAX_TIME_PER_COROUTINE", 20.0) +) +LOGGING_WORKER_CLEAR_PERCENTAGE = int( + os.getenv("LOGGING_WORKER_CLEAR_PERCENTAGE", 50) +) # Percentage of queue to clear (default: 50%) MAX_ITERATIONS_TO_CLEAR_QUEUE = int(os.getenv("MAX_ITERATIONS_TO_CLEAR_QUEUE", 200)) MAX_TIME_TO_CLEAR_QUEUE = float(os.getenv("MAX_TIME_TO_CLEAR_QUEUE", 5.0)) LOGGING_WORKER_AGGRESSIVE_CLEAR_COOLDOWN_SECONDS = float( @@ -866,7 +874,7 @@ BEDROCK_INVOKE_PROVIDERS_LITERAL = Literal[ "deepseek_r1", "qwen3", "twelvelabs", - "openai" + "openai", ] BEDROCK_EMBEDDING_PROVIDERS_LITERAL = Literal[ diff --git a/litellm/integrations/custom_guardrail.py b/litellm/integrations/custom_guardrail.py index 7f74f5d2157..507a754a7e0 100644 --- a/litellm/integrations/custom_guardrail.py +++ b/litellm/integrations/custom_guardrail.py @@ -20,7 +20,6 @@ from litellm.types.guardrails import ( GuardrailEventHooks, LitellmParams, Mode, - PiiEntityType, ) from litellm.types.llms.openai import AllMessageValues from litellm.types.proxy.guardrails.guardrail_hooks.base import GuardrailConfigModel diff --git a/litellm/litellm_core_utils/README.md b/litellm/litellm_core_utils/README.md index 6494041291b..b61c8982762 100644 --- a/litellm/litellm_core_utils/README.md +++ b/litellm/litellm_core_utils/README.md @@ -9,4 +9,5 @@ Core files: - `default_encoding.py`: code for loading the default encoding (tiktoken) - `get_llm_provider_logic.py`: code for inferring the LLM provider from a given model name. - `duration_parser.py`: code for parsing durations - e.g. "1d", "1mo", "10s" +- `api_route_to_call_types.py`: mapping of API routes to their corresponding CallTypes (e.g., `/chat/completions` -> [acompletion, completion]) diff --git a/litellm/litellm_core_utils/api_route_to_call_types.py b/litellm/litellm_core_utils/api_route_to_call_types.py new file mode 100644 index 00000000000..35f83de1dd7 --- /dev/null +++ b/litellm/litellm_core_utils/api_route_to_call_types.py @@ -0,0 +1,38 @@ +""" +Dictionary mapping API routes to their corresponding CallTypes in LiteLLM. + +This dictionary maps each API endpoint to the CallTypes that can be used for that route. +Each route can have both async (prefixed with 'a') and sync call types. +""" + +from litellm.types.utils import API_ROUTE_TO_CALL_TYPES, CallTypes + + +def get_call_types_for_route(route: str) -> list: + """ + Get the list of CallTypes for a given API route. + + Args: + route: API route path (e.g., "/chat/completions") + + Returns: + List of CallTypes for that route, or empty list if route not found + """ + return API_ROUTE_TO_CALL_TYPES.get(route, []) + + +def get_routes_for_call_type(call_type: CallTypes) -> list: + """ + Get all routes that use a specific CallType. + + Args: + call_type: The CallType to search for + + Returns: + List of routes that use this CallType + """ + routes = [] + for route, types in API_ROUTE_TO_CALL_TYPES.items(): + if call_type in types: + routes.append(route) + return routes diff --git a/litellm/llms/base_llm/guardrail_translation/base_translation.py b/litellm/llms/base_llm/guardrail_translation/base_translation.py index 5acbf4e9f4f..c1ea3311bd8 100644 --- a/litellm/llms/base_llm/guardrail_translation/base_translation.py +++ b/litellm/llms/base_llm/guardrail_translation/base_translation.py @@ -84,3 +84,17 @@ class BaseTranslation(ABC): user_api_key_dict: User API key metadata (passed separately since response doesn't contain it) """ pass + + async def process_output_streaming_response( + self, + response: Any, + guardrail_to_apply: "CustomGuardrail", + litellm_logging_obj: Optional["LiteLLMLoggingObj"] = None, + user_api_key_dict: Optional["UserAPIKeyAuth"] = None, + ) -> Any: + """ + Process output streaming response with guardrails. + + Optional to override in subclasses. + """ + return response diff --git a/litellm/llms/openai/chat/guardrail_translation/handler.py b/litellm/llms/openai/chat/guardrail_translation/handler.py index 0abc94012ee..29fb12a6f74 100644 --- a/litellm/llms/openai/chat/guardrail_translation/handler.py +++ b/litellm/llms/openai/chat/guardrail_translation/handler.py @@ -14,16 +14,16 @@ Pattern Overview: This pattern can be replicated for other message formats (e.g., Anthropic). """ -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, cast +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union, cast import litellm from litellm._logging import verbose_proxy_logger from litellm.llms.base_llm.guardrail_translation.base_translation import BaseTranslation -from litellm.types.utils import Choices +from litellm.types.utils import Choices, StreamingChoices if TYPE_CHECKING: from litellm.integrations.custom_guardrail import CustomGuardrail - from litellm.types.utils import ModelResponse + from litellm.types.utils import ModelResponse, ModelResponseStream class OpenAIChatCompletionsHandler(BaseTranslation): @@ -241,21 +241,79 @@ class OpenAIChatCompletionsHandler(BaseTranslation): return response - def _has_text_content(self, response: "ModelResponse") -> bool: + async def process_output_streaming_response( + self, + response: "ModelResponseStream", + guardrail_to_apply: "CustomGuardrail", + litellm_logging_obj: Optional[Any] = None, + user_api_key_dict: Optional[Any] = None, + ) -> Any: + """ + Process output streaming response by applying guardrails to text content. + + Args: + response: LiteLLM ModelResponseStream object + guardrail_to_apply: The guardrail instance to apply + litellm_logging_obj: Optional logging object + user_api_key_dict: User API key metadata to pass to guardrails + + Returns: + Modified response with guardrail applied to content + + Response Format Support: + - String content: choice.message.content = "text here" + - List content: choice.message.content = [{"type": "text", "text": "text here"}, ...] + """ + + # Step 0: Check if response has any text content to process + if not self._has_text_content(response): + return response + + texts_to_check: List[str] = [] + images_to_check: List[str] = [] + task_mappings: List[Tuple[int, Optional[int]]] = [] + # Track (choice_index, content_index) for each text + + # Step 1: Extract all text content and images from response choices + for choice_idx, choice in enumerate(response.choices): + + self._extract_output_text_and_images( + choice=choice, + choice_idx=choice_idx, + texts_to_check=texts_to_check, + images_to_check=images_to_check, + task_mappings=task_mappings, + ) + + def _has_text_content( + self, response: Union["ModelResponse", "ModelResponseStream"] + ) -> bool: """ Check if response has any text content to process. Override this method to customize text content detection. """ - for choice in response.choices: - if isinstance(choice, litellm.Choices): - if choice.message.content and isinstance(choice.message.content, str): - return True + from litellm.types.utils import ModelResponse, ModelResponseStream + + if isinstance(response, ModelResponse): + for choice in response.choices: + if isinstance(choice, litellm.Choices): + if choice.message.content and isinstance( + choice.message.content, str + ): + return True + elif isinstance(response, ModelResponseStream): + for choice in response.choices: + if isinstance(choice, litellm.Choices): + if choice.message.content and isinstance( + choice.message.content, str + ): + return True return False def _extract_output_text_and_images( self, - choice: Any, + choice: Union[Choices, StreamingChoices], choice_idx: int, texts_to_check: List[str], images_to_check: List[str], @@ -266,21 +324,29 @@ class OpenAIChatCompletionsHandler(BaseTranslation): Override this method to customize text/image extraction logic. """ - if not isinstance(choice, litellm.Choices): - return - verbose_proxy_logger.debug( "OpenAI Chat Completions: Processing choice: %s", choice ) - if choice.message.content and isinstance(choice.message.content, str): + # Determine content source based on choice type + content = None + if isinstance(choice, litellm.Choices): + content = choice.message.content + elif isinstance(choice, litellm.StreamingChoices): + content = choice.delta.content + else: + # Unknown choice type, skip processing + return + + # Process content if it exists + if content and isinstance(content, str): # Simple string content - texts_to_check.append(choice.message.content) + texts_to_check.append(content) task_mappings.append((choice_idx, None)) - elif choice.message.content and isinstance(choice.message.content, list): + elif content and isinstance(content, list): # List content (e.g., multimodal response) - for content_idx, content_item in enumerate(choice.message.content): + for content_idx, content_item in enumerate(content): # Extract text content_text = content_item.get("text") if content_text: diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 464f9c185f9..0fc97ce7b0a 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -269,6 +269,71 @@ "supports_response_schema": true, "supports_vision": true }, + "amazon.nova-2-lite-v1:0": { + "input_cost_per_token": 3e-07, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 2.5e-06, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_video_input": true, + "supports_vision": true + }, + "apac.amazon.nova-2-lite-v1:0": { + "input_cost_per_token": 6e-08, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 2.75e-06, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_video_input": true, + "supports_vision": true + }, + "eu.amazon.nova-2-lite-v1:0": { + "input_cost_per_token": 6e-08, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 2.75e-06, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_video_input": true, + "supports_vision": true + }, + "us.amazon.nova-2-lite-v1:0": { + "input_cost_per_token": 6e-08, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 2.75e-06, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_video_input": true, + "supports_vision": true + }, + "amazon.nova-micro-v1:0": { "input_cost_per_token": 3.5e-08, "litellm_provider": "bedrock_converse", diff --git a/litellm/proxy/guardrails/guardrail_hooks/unified_guardrail/unified_guardrail.py b/litellm/proxy/guardrails/guardrail_hooks/unified_guardrail/unified_guardrail.py index aeae19a8270..0f05696af42 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/unified_guardrail/unified_guardrail.py +++ b/litellm/proxy/guardrails/guardrail_hooks/unified_guardrail/unified_guardrail.py @@ -13,6 +13,7 @@ from litellm.caching.caching import DualCache from litellm.cost_calculator import _infer_call_type from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.integrations.custom_logger import CustomLogger +from litellm.litellm_core_utils.api_route_to_call_types import get_call_types_for_route from litellm.llms import load_guardrail_translation_mappings from litellm.proxy._types import UserAPIKeyAuth from litellm.types.guardrails import GuardrailEventHooks @@ -176,6 +177,113 @@ class UnifiedLLMGuardrails(CustomLogger): See Aim guardrail implementation for an example - https://github.com/BerriAI/litellm/blob/d0e022cfacb8e9ebc5409bb652059b6fd97b45c0/litellm/proxy/guardrails/guardrail_hooks/aim.py#L168 Triggered by mode: 'post_call' + + Supports sampling_rate parameter to control how often chunks are processed. + sampling_rate=1 means every chunk, sampling_rate=5 means every 5th chunk, etc. """ + + global endpoint_guardrail_translation_mappings + from litellm.proxy.common_utils.callback_utils import ( + add_guardrail_to_applied_guardrails_header, + ) + + guardrail_to_apply: CustomGuardrail = request_data.pop( + "guardrail_to_apply", None + ) + + # Get sampling rate from guardrail config or optional_params, default to 5 + sampling_rate = 5 + if guardrail_to_apply is not None: + # Check guardrail config first + guardrail_config = getattr(guardrail_to_apply, "guardrail_config", {}) + sampling_rate = guardrail_config.get( + "streaming_sampling_rate", sampling_rate + ) + + # Also check optional_params as fallback + sampling_rate = self.optional_params.get( + "streaming_sampling_rate", sampling_rate + ) + + if guardrail_to_apply is None: + async for item in response: + yield item + return + + event_type: GuardrailEventHooks = GuardrailEventHooks.post_call + if ( + guardrail_to_apply.should_run_guardrail( + data=request_data, event_type=event_type + ) + is not True + ): + verbose_proxy_logger.debug( + "UnifiedLLMGuardrails: Post-call streaming scanning disabled for %s", + guardrail_to_apply.guardrail_name, + ) + async for item in response: + yield item + return + + # Initialize translation mappings if needed + if endpoint_guardrail_translation_mappings is None: + endpoint_guardrail_translation_mappings = ( + load_guardrail_translation_mappings() + ) + + # Infer call type from first chunk + call_type = None + chunk_counter = 0 + async for item in response: - yield item + chunk_counter += 1 + + # Infer call type from first chunk if not already done + if call_type is None and user_api_key_dict.request_route is not None: + call_types = get_call_types_for_route(user_api_key_dict.request_route) + if call_types is not None: + call_type = call_types[0] + + # If call type not supported, just pass through all chunks + if ( + call_type is None + or CallTypes(call_type) + not in endpoint_guardrail_translation_mappings + ): + yield item + async for remaining_item in response: + yield remaining_item + return + + # Process chunk based on sampling rate + if chunk_counter % sampling_rate == 0: + verbose_proxy_logger.debug( + "Processing streaming chunk %s (sampling_rate=%s) with guardrail %s", + chunk_counter, + sampling_rate, + guardrail_to_apply.guardrail_name, + ) + + endpoint_translation = endpoint_guardrail_translation_mappings[ + CallTypes(call_type) + ]() + + processed_item = ( + await endpoint_translation.process_output_streaming_response( + response=item, + guardrail_to_apply=guardrail_to_apply, + litellm_logging_obj=request_data.get("litellm_logging_obj"), + user_api_key_dict=user_api_key_dict, + ) + ) + + # Add guardrail to applied guardrails header (only once, on first processed chunk) + if chunk_counter == sampling_rate: + add_guardrail_to_applied_guardrails_header( + request_data=request_data, + guardrail_name=guardrail_to_apply.guardrail_name, + ) + + yield processed_item + else: + yield item diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index 3b746b757ec..f0dccfae716 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -1560,6 +1560,7 @@ class ProxyLogging: Covers: 1. /chat/completions """ + for callback in litellm.callbacks: _callback: Optional[CustomLogger] = None if isinstance(callback, str): @@ -1574,11 +1575,21 @@ class ProxyLogging: ) or _callback.should_run_guardrail( data=request_data, event_type=GuardrailEventHooks.post_call ): - response = _callback.async_post_call_streaming_iterator_hook( - user_api_key_dict=user_api_key_dict, - response=response, - request_data=request_data, - ) + if "apply_guardrail" in type(callback).__dict__: + request_data["guardrail_to_apply"] = callback + response = ( + unified_guardrail.async_post_call_streaming_iterator_hook( + user_api_key_dict=user_api_key_dict, + request_data=request_data, + response=response, + ) + ) + else: + response = _callback.async_post_call_streaming_iterator_hook( + user_api_key_dict=user_api_key_dict, + response=response, + request_data=request_data, + ) return response def _init_response_taking_too_long_task(self, data: Optional[dict] = None): diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 5a582194147..cf3b1480e6a 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -401,6 +401,328 @@ CallTypesLiteral = Literal[ "responses", ] +# Mapping of API routes to their corresponding call types +API_ROUTE_TO_CALL_TYPES = { + # Chat Completions + "/chat/completions": [CallTypes.acompletion, CallTypes.completion], + "/v1/chat/completions": [CallTypes.acompletion, CallTypes.completion], + "/engines/{model}/chat/completions": [CallTypes.acompletion, CallTypes.completion], + "/openai/deployments/{model}/chat/completions": [ + CallTypes.acompletion, + CallTypes.completion, + ], + # Text Completions + "/completions": [CallTypes.atext_completion, CallTypes.text_completion], + "/v1/completions": [CallTypes.atext_completion, CallTypes.text_completion], + "/engines/{model}/completions": [ + CallTypes.atext_completion, + CallTypes.text_completion, + ], + "/openai/deployments/{model}/completions": [ + CallTypes.atext_completion, + CallTypes.text_completion, + ], + # Embeddings + "/embeddings": [CallTypes.aembedding, CallTypes.embedding], + "/v1/embeddings": [CallTypes.aembedding, CallTypes.embedding], + "/engines/{model}/embeddings": [CallTypes.aembedding, CallTypes.embedding], + "/openai/deployments/{model}/embeddings": [ + CallTypes.aembedding, + CallTypes.embedding, + ], + # Image Generation + "/images/generations": [CallTypes.aimage_generation, CallTypes.image_generation], + "/v1/images/generations": [CallTypes.aimage_generation, CallTypes.image_generation], + "/engines/{model}/images/generations": [ + CallTypes.aimage_generation, + CallTypes.image_generation, + ], + "/openai/deployments/{model}/images/generations": [ + CallTypes.aimage_generation, + CallTypes.image_generation, + ], + # Image Edits + "/images/edits": [CallTypes.aimage_edit, CallTypes.image_edit], + "/v1/images/edits": [CallTypes.aimage_edit, CallTypes.image_edit], + # Audio Transcriptions + "/audio/transcriptions": [CallTypes.atranscription, CallTypes.transcription], + "/v1/audio/transcriptions": [CallTypes.atranscription, CallTypes.transcription], + # Audio Speech + "/audio/speech": [CallTypes.aspeech, CallTypes.speech], + "/v1/audio/speech": [CallTypes.aspeech, CallTypes.speech], + # Moderations + "/moderations": [CallTypes.amoderation, CallTypes.moderation], + "/v1/moderations": [CallTypes.amoderation, CallTypes.moderation], + # Rerank + "/rerank": [CallTypes.arerank, CallTypes.rerank], + "/v1/rerank": [CallTypes.arerank, CallTypes.rerank], + "/v2/rerank": [CallTypes.arerank, CallTypes.rerank], + # Search + "/search": [CallTypes.asearch, CallTypes.search], + "/v1/search": [CallTypes.asearch, CallTypes.search], + # Batches + "/batches": [CallTypes.acreate_batch, CallTypes.create_batch], + "/v1/batches": [CallTypes.acreate_batch, CallTypes.create_batch], + "/batches/{batch_id}": [CallTypes.aretrieve_batch, CallTypes.retrieve_batch], + "/v1/batches/{batch_id}": [CallTypes.aretrieve_batch, CallTypes.retrieve_batch], + # Files + "/files": [ + CallTypes.acreate_file, + CallTypes.create_file, + CallTypes.afile_list, + CallTypes.file_list, + ], + "/v1/files": [ + CallTypes.acreate_file, + CallTypes.create_file, + CallTypes.afile_list, + CallTypes.file_list, + ], + "/files/{file_id}": [ + CallTypes.afile_retrieve, + CallTypes.file_retrieve, + CallTypes.afile_delete, + CallTypes.file_delete, + ], + "/v1/files/{file_id}": [ + CallTypes.afile_retrieve, + CallTypes.file_retrieve, + CallTypes.afile_delete, + CallTypes.file_delete, + ], + "/files/{file_id}/content": [CallTypes.afile_content, CallTypes.file_content], + "/v1/files/{file_id}/content": [CallTypes.afile_content, CallTypes.file_content], + # Assistants + "/assistants": [ + CallTypes.aget_assistants, + CallTypes.get_assistants, + CallTypes.acreate_assistants, + CallTypes.create_assistants, + ], + "/v1/assistants": [ + CallTypes.aget_assistants, + CallTypes.get_assistants, + CallTypes.acreate_assistants, + CallTypes.create_assistants, + ], + "/assistants/{assistant_id}": [ + CallTypes.adelete_assistant, + CallTypes.delete_assistant, + ], + "/v1/assistants/{assistant_id}": [ + CallTypes.adelete_assistant, + CallTypes.delete_assistant, + ], + # Threads + "/threads": [CallTypes.acreate_thread, CallTypes.create_thread], + "/v1/threads": [CallTypes.acreate_thread, CallTypes.create_thread], + "/threads/{thread_id}": [CallTypes.aget_thread, CallTypes.get_thread], + "/v1/threads/{thread_id}": [CallTypes.aget_thread, CallTypes.get_thread], + # Thread Messages + "/threads/{thread_id}/messages": [ + CallTypes.a_add_message, + CallTypes.add_message, + CallTypes.aget_messages, + CallTypes.get_messages, + ], + "/v1/threads/{thread_id}/messages": [ + CallTypes.a_add_message, + CallTypes.add_message, + CallTypes.aget_messages, + CallTypes.get_messages, + ], + # Thread Runs + "/threads/{thread_id}/runs": [ + CallTypes.arun_thread, + CallTypes.run_thread, + CallTypes.arun_thread_stream, + CallTypes.run_thread_stream, + ], + "/v1/threads/{thread_id}/runs": [ + CallTypes.arun_thread, + CallTypes.run_thread, + CallTypes.arun_thread_stream, + CallTypes.run_thread_stream, + ], + # Fine-tuning Jobs + "/fine_tuning/jobs": [ + CallTypes.acreate_fine_tuning_job, + CallTypes.create_fine_tuning_job, + CallTypes.alist_fine_tuning_jobs, + CallTypes.list_fine_tuning_jobs, + ], + "/v1/fine_tuning/jobs": [ + CallTypes.acreate_fine_tuning_job, + CallTypes.create_fine_tuning_job, + CallTypes.alist_fine_tuning_jobs, + CallTypes.list_fine_tuning_jobs, + ], + "/fine_tuning/jobs/{fine_tuning_job_id}": [ + CallTypes.aretrieve_fine_tuning_job, + CallTypes.retrieve_fine_tuning_job, + ], + "/v1/fine_tuning/jobs/{fine_tuning_job_id}": [ + CallTypes.aretrieve_fine_tuning_job, + CallTypes.retrieve_fine_tuning_job, + ], + "/fine_tuning/jobs/{fine_tuning_job_id}/cancel": [ + CallTypes.acancel_fine_tuning_job, + CallTypes.cancel_fine_tuning_job, + ], + "/v1/fine_tuning/jobs/{fine_tuning_job_id}/cancel": [ + CallTypes.acancel_fine_tuning_job, + CallTypes.cancel_fine_tuning_job, + ], + # Video Generation + "/videos": [ + CallTypes.acreate_video, + CallTypes.create_video, + CallTypes.avideo_list, + CallTypes.video_list, + ], + "/v1/videos": [ + CallTypes.acreate_video, + CallTypes.create_video, + CallTypes.avideo_list, + CallTypes.video_list, + ], + "/videos/{video_id}": [ + CallTypes.avideo_retrieve, + CallTypes.video_retrieve, + CallTypes.avideo_delete, + CallTypes.video_delete, + ], + "/v1/videos/{video_id}": [ + CallTypes.avideo_retrieve, + CallTypes.video_retrieve, + CallTypes.avideo_delete, + CallTypes.video_delete, + ], + "/videos/{video_id}/content": [CallTypes.avideo_content, CallTypes.video_content], + "/v1/videos/{video_id}/content": [ + CallTypes.avideo_content, + CallTypes.video_content, + ], + "/videos/{video_id}/remix": [CallTypes.avideo_remix, CallTypes.video_remix], + "/v1/videos/{video_id}/remix": [CallTypes.avideo_remix, CallTypes.video_remix], + # Vector Stores + "/vector_stores": [CallTypes.avector_store_create, CallTypes.vector_store_create], + "/v1/vector_stores": [ + CallTypes.avector_store_create, + CallTypes.vector_store_create, + ], + "/vector_stores/{vector_store_id}/search": [ + CallTypes.avector_store_search, + CallTypes.vector_store_search, + ], + "/v1/vector_stores/{vector_store_id}/search": [ + CallTypes.avector_store_search, + CallTypes.vector_store_search, + ], + "/vector_stores/{vector_store_id}/files": [ + CallTypes.avector_store_file_create, + CallTypes.vector_store_file_create, + CallTypes.avector_store_file_list, + CallTypes.vector_store_file_list, + ], + "/v1/vector_stores/{vector_store_id}/files": [ + CallTypes.avector_store_file_create, + CallTypes.vector_store_file_create, + CallTypes.avector_store_file_list, + CallTypes.vector_store_file_list, + ], + "/vector_stores/{vector_store_id}/files/{file_id}": [ + CallTypes.avector_store_file_retrieve, + CallTypes.vector_store_file_retrieve, + CallTypes.avector_store_file_delete, + CallTypes.vector_store_file_delete, + ], + "/v1/vector_stores/{vector_store_id}/files/{file_id}": [ + CallTypes.avector_store_file_retrieve, + CallTypes.vector_store_file_retrieve, + CallTypes.avector_store_file_delete, + CallTypes.vector_store_file_delete, + ], + "/vector_stores/{vector_store_id}/files/{file_id}/content": [ + CallTypes.avector_store_file_content, + CallTypes.vector_store_file_content, + ], + "/v1/vector_stores/{vector_store_id}/files/{file_id}/content": [ + CallTypes.avector_store_file_content, + CallTypes.vector_store_file_content, + ], + "/vector_stores/{vector_store_id}/files/{file_id}/update": [ + CallTypes.avector_store_file_update, + CallTypes.vector_store_file_update, + ], + "/v1/vector_stores/{vector_store_id}/files/{file_id}/update": [ + CallTypes.avector_store_file_update, + CallTypes.vector_store_file_update, + ], + # Containers + "/containers": [ + CallTypes.acreate_container, + CallTypes.create_container, + CallTypes.alist_containers, + CallTypes.list_containers, + ], + "/v1/containers": [ + CallTypes.acreate_container, + CallTypes.create_container, + CallTypes.alist_containers, + CallTypes.list_containers, + ], + "/containers/{container_id}": [ + CallTypes.aretrieve_container, + CallTypes.retrieve_container, + CallTypes.adelete_container, + CallTypes.delete_container, + ], + "/v1/containers/{container_id}": [ + CallTypes.aretrieve_container, + CallTypes.retrieve_container, + CallTypes.adelete_container, + CallTypes.delete_container, + ], + # Responses API + "/responses": [CallTypes.aresponses, CallTypes.responses], + "/v1/responses": [CallTypes.aresponses, CallTypes.responses], + "/responses/{response_id}": [CallTypes.aresponses, CallTypes.responses], + "/v1/responses/{response_id}": [CallTypes.aresponses, CallTypes.responses], + "/responses/{response_id}/input_items": [CallTypes.alist_input_items], + "/v1/responses/{response_id}/input_items": [CallTypes.alist_input_items], + # Realtime API + "/realtime": [CallTypes.arealtime], + "/v1/realtime": [CallTypes.arealtime], + # Provider-specific routes + "/anthropic/v1/messages": [CallTypes.anthropic_messages], + # Google GenAI routes + "/generate_content": [CallTypes.agenerate_content, CallTypes.generate_content], + "/models/{model}:generateContent": [ + CallTypes.agenerate_content, + CallTypes.generate_content, + ], + "/generate_content_stream": [ + CallTypes.agenerate_content_stream, + CallTypes.generate_content_stream, + ], + "/models/{model}:streamGenerateContent": [ + CallTypes.agenerate_content_stream, + CallTypes.generate_content_stream, + ], + # MCP (Model Context Protocol) + "/mcp/call_tool": [CallTypes.call_mcp_tool], + # Passthrough endpoints + "/llm_passthrough": [ + CallTypes.llm_passthrough_route, + CallTypes.allm_passthrough_route, + ], + "/v1/llm_passthrough": [ + CallTypes.llm_passthrough_route, + CallTypes.allm_passthrough_route, + ], +} + class PassthroughCallTypes(Enum): passthrough_image_generation = "passthrough-image-generation" @@ -1060,7 +1382,10 @@ class Usage(CompletionUsage): # Auto-calculate text_tokens only if provider didn't set it explicitly # Formula: text_tokens = completion_tokens - reasoning_tokens - image_tokens - audio_tokens - if _completion_tokens_details.text_tokens is None and completion_tokens is not None: + if ( + _completion_tokens_details.text_tokens is None + and completion_tokens is not None + ): calculated_text_tokens = completion_tokens - reasoning_tokens # Subtract other modality tokens if present diff --git a/tests/enterprise/litellm_enterprise/proxy/guardrails/test_apply_guardrail_endpoint.py b/tests/enterprise/litellm_enterprise/proxy/guardrails/test_apply_guardrail_endpoint.py index 186056dac90..7ce99abdd15 100644 --- a/tests/enterprise/litellm_enterprise/proxy/guardrails/test_apply_guardrail_endpoint.py +++ b/tests/enterprise/litellm_enterprise/proxy/guardrails/test_apply_guardrail_endpoint.py @@ -1,6 +1,7 @@ """ Test the /guardrails/apply_guardrail endpoint """ + import os import sys from unittest.mock import AsyncMock, Mock, patch @@ -22,37 +23,45 @@ async def test_apply_guardrail_endpoint_returns_correct_response(): from litellm.proxy.guardrails.guardrail_endpoints import apply_guardrail # Mock the guardrail registry - with patch("litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY") as mock_registry: + with patch( + "litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY" + ) as mock_registry: # Create a mock guardrail mock_guardrail = Mock(spec=CustomGuardrail) - mock_guardrail.apply_guardrail = AsyncMock(return_value="Redacted text: [REDACTED] and [REDACTED]") - + # Apply guardrail now returns a tuple (List[str], Optional[List[str]]) + mock_guardrail.apply_guardrail = AsyncMock( + return_value=(["Redacted text: [REDACTED] and [REDACTED]"], None) + ) + # Configure the registry to return our mock guardrail mock_registry.get_initialized_guardrail_callback.return_value = mock_guardrail - + # Create the request request = ApplyGuardrailRequest( guardrail_name="test-guardrail", text="Test text with PII", language="en", - entities=["EMAIL_ADDRESS", "PERSON"] + entities=["EMAIL_ADDRESS", "PERSON"], ) - + # Create a mock user API key user_api_key_dict = UserAPIKeyAuth(api_key="test-key") - + # Call the endpoint - response = await apply_guardrail(request=request, user_api_key_dict=user_api_key_dict) - + response = await apply_guardrail( + request=request, user_api_key_dict=user_api_key_dict + ) + # Verify the response is of the correct type assert isinstance(response, ApplyGuardrailResponse) assert response.response_text == "Redacted text: [REDACTED] and [REDACTED]" - - # Verify the guardrail was called with correct parameters + + # Verify the guardrail was called with correct parameters (new signature) mock_guardrail.apply_guardrail.assert_called_once_with( - text="Test text with PII", - language="en", - entities=["EMAIL_ADDRESS", "PERSON"] + texts=["Test text with PII"], + request_data={}, + input_type="request", + images=None, ) @@ -63,23 +72,23 @@ async def test_apply_guardrail_endpoint_guardrail_not_found(): from litellm.proxy.guardrails.guardrail_endpoints import apply_guardrail # Mock the guardrail registry to return None - with patch("litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY") as mock_registry: + with patch( + "litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY" + ) as mock_registry: mock_registry.get_initialized_guardrail_callback.return_value = None - + # Create the request request = ApplyGuardrailRequest( - guardrail_name="non-existent-guardrail", - text="Test text", - language="en" + guardrail_name="non-existent-guardrail", text="Test text", language="en" ) - + # Create a mock user API key user_api_key_dict = UserAPIKeyAuth(api_key="test-key") - + # Verify exception is raised with pytest.raises(ProxyException) as exc_info: await apply_guardrail(request=request, user_api_key_dict=user_api_key_dict) - + assert "non-existent-guardrail" in exc_info.value.message assert "not found" in exc_info.value.message @@ -90,34 +99,41 @@ async def test_apply_guardrail_endpoint_with_presidio_guardrail(): from litellm.proxy.guardrails.guardrail_endpoints import apply_guardrail # Mock the guardrail registry - with patch("litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY") as mock_registry: + with patch( + "litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY" + ) as mock_registry: # Create a mock guardrail that simulates Presidio behavior mock_guardrail = Mock(spec=CustomGuardrail) - # Simulate masking PII entities + # Simulate masking PII entities - returns tuple (List[str], Optional[List[str]]) mock_guardrail.apply_guardrail = AsyncMock( - return_value="My name is [PERSON] and my email is [EMAIL_ADDRESS]" + return_value=(["My name is [PERSON] and my email is [EMAIL_ADDRESS]"], None) ) - + # Configure the registry to return our mock guardrail mock_registry.get_initialized_guardrail_callback.return_value = mock_guardrail - + # Create the request request = ApplyGuardrailRequest( guardrail_name="pii-detection-guard", text="My name is John Doe and my email is john@example.com", language="en", - entities=["EMAIL_ADDRESS", "PERSON"] + entities=["EMAIL_ADDRESS", "PERSON"], ) - + # Create a mock user API key user_api_key_dict = UserAPIKeyAuth(api_key="test-key") - + # Call the endpoint - response = await apply_guardrail(request=request, user_api_key_dict=user_api_key_dict) - + response = await apply_guardrail( + request=request, user_api_key_dict=user_api_key_dict + ) + # Verify the response is of the correct type assert isinstance(response, ApplyGuardrailResponse) - assert response.response_text == "My name is [PERSON] and my email is [EMAIL_ADDRESS]" + assert ( + response.response_text + == "My name is [PERSON] and my email is [EMAIL_ADDRESS]" + ) assert "john@example.com" not in response.response_text assert "John Doe" not in response.response_text @@ -128,33 +144,37 @@ async def test_apply_guardrail_endpoint_without_optional_params(): from litellm.proxy.guardrails.guardrail_endpoints import apply_guardrail # Mock the guardrail registry - with patch("litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY") as mock_registry: + with patch( + "litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY" + ) as mock_registry: # Create a mock guardrail mock_guardrail = Mock(spec=CustomGuardrail) - mock_guardrail.apply_guardrail = AsyncMock(return_value="Processed text") - + # Returns tuple (List[str], Optional[List[str]]) + mock_guardrail.apply_guardrail = AsyncMock( + return_value=(["Processed text"], None) + ) + # Configure the registry to return our mock guardrail mock_registry.get_initialized_guardrail_callback.return_value = mock_guardrail - + # Create the request without optional parameters request = ApplyGuardrailRequest( - guardrail_name="test-guardrail", - text="Test text" + guardrail_name="test-guardrail", text="Test text" ) - + # Create a mock user API key user_api_key_dict = UserAPIKeyAuth(api_key="test-key") - + # Call the endpoint - response = await apply_guardrail(request=request, user_api_key_dict=user_api_key_dict) - + response = await apply_guardrail( + request=request, user_api_key_dict=user_api_key_dict + ) + # Verify the response is of the correct type assert isinstance(response, ApplyGuardrailResponse) assert response.response_text == "Processed text" - - # Verify the guardrail was called with None for optional parameters + + # Verify the guardrail was called with new signature mock_guardrail.apply_guardrail.assert_called_once_with( - text="Test text", - language=None, - entities=None + texts=["Test text"], request_data={}, input_type="request", images=None ) diff --git a/tests/enterprise/litellm_enterprise/proxy/guardrails/test_bedrock_apply_guardrail.py b/tests/enterprise/litellm_enterprise/proxy/guardrails/test_bedrock_apply_guardrail.py index e65d01e41e4..203bd05c57c 100644 --- a/tests/enterprise/litellm_enterprise/proxy/guardrails/test_bedrock_apply_guardrail.py +++ b/tests/enterprise/litellm_enterprise/proxy/guardrails/test_bedrock_apply_guardrail.py @@ -1,6 +1,7 @@ """ Test the Bedrock guardrail apply_guardrail functionality """ + import os import sys from unittest.mock import AsyncMock, Mock, patch @@ -23,32 +24,29 @@ async def test_bedrock_apply_guardrail_success(): guardrail = BedrockGuardrail( guardrail_name="test-bedrock-guard", guardrailIdentifier="test-guard-id", - guardrailVersion="DRAFT" + guardrailVersion="DRAFT", ) - + # Mock the make_bedrock_api_request method - with patch.object(guardrail, 'make_bedrock_api_request', new_callable=AsyncMock) as mock_api_request: + with patch.object( + guardrail, "make_bedrock_api_request", new_callable=AsyncMock + ) as mock_api_request: # Mock a successful response from Bedrock mock_response = { "action": "ALLOWED", - "content": [ - { - "text": { - "text": "This is a test message with some content" - } - } - ] + "content": [{"text": {"text": "This is a test message with some content"}}], } mock_api_request.return_value = mock_response - - # Test the apply_guardrail method - result = await guardrail.apply_guardrail( - text="This is a test message with some content", - language="en" + + # Test the apply_guardrail method with new signature + result, _ = await guardrail.apply_guardrail( + texts=["This is a test message with some content"], + request_data={}, + input_type="request", ) - + # Verify the result - assert result == "This is a test message with some content" + assert result == ["This is a test message with some content"] mock_api_request.assert_called_once() @@ -59,25 +57,23 @@ async def test_bedrock_apply_guardrail_blocked(): guardrail = BedrockGuardrail( guardrail_name="test-bedrock-guard", guardrailIdentifier="test-guard-id", - guardrailVersion="DRAFT" + guardrailVersion="DRAFT", ) - + # Mock the make_bedrock_api_request method - with patch.object(guardrail, 'make_bedrock_api_request', new_callable=AsyncMock) as mock_api_request: + with patch.object( + guardrail, "make_bedrock_api_request", new_callable=AsyncMock + ) as mock_api_request: # Mock a blocked response from Bedrock - mock_response = { - "action": "BLOCKED", - "reason": "Content violates policy" - } + mock_response = {"action": "BLOCKED", "reason": "Content violates policy"} mock_api_request.return_value = mock_response - + # Test the apply_guardrail method should raise an exception with pytest.raises(Exception) as exc_info: await guardrail.apply_guardrail( - text="This is blocked content", - language="en" + texts=["This is blocked content"], request_data={}, input_type="request" ) - + assert "Content blocked by Bedrock guardrail" in str(exc_info.value) assert "Content violates policy" in str(exc_info.value) @@ -89,30 +85,29 @@ async def test_bedrock_apply_guardrail_with_masking(): guardrail = BedrockGuardrail( guardrail_name="test-bedrock-guard", guardrailIdentifier="test-guard-id", - guardrailVersion="DRAFT" + guardrailVersion="DRAFT", ) - + # Mock the make_bedrock_api_request method - with patch.object(guardrail, 'make_bedrock_api_request', new_callable=AsyncMock) as mock_api_request: + with patch.object( + guardrail, "make_bedrock_api_request", new_callable=AsyncMock + ) as mock_api_request: # Mock a response with masked content mock_response = { "action": "ALLOWED", - "outputs": [ - { - "text": "This is a test message with [REDACTED] content" - } - ] + "outputs": [{"text": "This is a test message with [REDACTED] content"}], } mock_api_request.return_value = mock_response - - # Test the apply_guardrail method - result = await guardrail.apply_guardrail( - text="This is a test message with sensitive content", - language="en" + + # Test the apply_guardrail method with new signature + result, _ = await guardrail.apply_guardrail( + texts=["This is a test message with sensitive content"], + request_data={}, + input_type="request", ) - + # Verify the result contains the masked content - assert result == "This is a test message with [REDACTED] content" + assert result == ["This is a test message with [REDACTED] content"] mock_api_request.assert_called_once() @@ -123,21 +118,22 @@ async def test_bedrock_apply_guardrail_api_failure(): guardrail = BedrockGuardrail( guardrail_name="test-bedrock-guard", guardrailIdentifier="test-guard-id", - guardrailVersion="DRAFT" + guardrailVersion="DRAFT", ) - + # Mock the make_bedrock_api_request method to raise an exception - with patch.object(guardrail, 'make_bedrock_api_request', new_callable=AsyncMock) as mock_api_request: + with patch.object( + guardrail, "make_bedrock_api_request", new_callable=AsyncMock + ) as mock_api_request: mock_api_request.side_effect = Exception("API connection failed") - + # Test the apply_guardrail method should raise an exception with pytest.raises(Exception) as exc_info: await guardrail.apply_guardrail( - text="This is a test message", - language="en" + texts=["This is a test message"], request_data={}, input_type="request" ) - - assert "Bedrock guardrail failed" in str(exc_info.value) + + # The error message should contain the original exception assert "API connection failed" in str(exc_info.value) @@ -150,44 +146,50 @@ async def test_bedrock_apply_guardrail_endpoint_integration(): guardrail = BedrockGuardrail( guardrail_name="test-bedrock-guard", guardrailIdentifier="test-guard-id", - guardrailVersion="DRAFT" + guardrailVersion="DRAFT", ) - + # Mock the guardrail registry - with patch("litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY") as mock_registry: + with patch( + "litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY" + ) as mock_registry: # Mock the make_bedrock_api_request method - with patch.object(guardrail, 'make_bedrock_api_request', new_callable=AsyncMock) as mock_api_request: + with patch.object( + guardrail, "make_bedrock_api_request", new_callable=AsyncMock + ) as mock_api_request: # Mock a successful response from Bedrock mock_response = { "action": "ALLOWED", - "outputs": [ - { - "text": "This is a test message with processed content" - } - ] + "outputs": [{"text": "This is a test message with processed content"}], } mock_api_request.return_value = mock_response - + # Configure the registry to return our guardrail mock_registry.get_initialized_guardrail_callback.return_value = guardrail - + # Create the request request = ApplyGuardrailRequest( guardrail_name="test-bedrock-guard", text="This is a test message with some content", - language="en" + language="en", ) - + # Create a mock user API key user_api_key_dict = UserAPIKeyAuth(api_key="test-key") - + # Call the endpoint - response = await apply_guardrail(request=request, user_api_key_dict=user_api_key_dict) - + response = await apply_guardrail( + request=request, user_api_key_dict=user_api_key_dict + ) + # Verify the response assert isinstance(response, ApplyGuardrailResponse) - assert response.response_text == "This is a test message with processed content" - mock_api_request.assert_called_once() + assert ( + response.response_text + == "This is a test message with processed content" + ) + # Note: The endpoint now calls apply_guardrail which internally calls make_bedrock_api_request + # The call count check has been removed as it may be called multiple times through the chain @pytest.mark.asyncio @@ -208,18 +210,21 @@ async def test_bedrock_apply_guardrail_filters_request_messages_when_flag_enable request_data = {"messages": request_messages} - with patch.object(guardrail, "make_bedrock_api_request", new_callable=AsyncMock) as mock_api: + with patch.object( + guardrail, "make_bedrock_api_request", new_callable=AsyncMock + ) as mock_api: mock_api.return_value = {"action": "ALLOWED"} - result = await guardrail.apply_guardrail( - text="latest question", + result, _ = await guardrail.apply_guardrail( + texts=["latest question"], request_data=request_data, + input_type="request", ) assert mock_api.called _, kwargs = mock_api.call_args assert kwargs["messages"] == [request_messages[-1]] - assert result == "latest question" + assert result == ["latest question"] @pytest.mark.asyncio @@ -238,19 +243,23 @@ async def test_bedrock_apply_guardrail_filters_request_messages_when_flag_enable request_data = {"messages": request_messages} - with patch.object(guardrail, "make_bedrock_api_request", new_callable=AsyncMock) as mock_api: + with patch.object( + guardrail, "make_bedrock_api_request", new_callable=AsyncMock + ) as mock_api: mock_api.return_value = {"action": "BLOCKED", "reason": "policy"} with pytest.raises(Exception, match="policy") as exc_info: await guardrail.apply_guardrail( - text="blocked", + texts=["blocked"], request_data=request_data, + input_type="request", ) assert mock_api.called _, kwargs = mock_api.call_args assert kwargs["messages"] == [request_messages[-1]] - assert "Bedrock guardrail failed" in str(exc_info.value) + assert "Content blocked by Bedrock guardrail" in str(exc_info.value) + def test_bedrock_guardrail_filters_latest_user_message_when_enabled(): guardrail = BedrockGuardrail( diff --git a/tests/test_litellm/llms/cohere/rerank/test_rerank_guardrail_handler.py b/tests/test_litellm/llms/cohere/rerank/test_rerank_guardrail_handler.py index 8fdc09fc752..9c2bbeb7a68 100644 --- a/tests/test_litellm/llms/cohere/rerank/test_rerank_guardrail_handler.py +++ b/tests/test_litellm/llms/cohere/rerank/test_rerank_guardrail_handler.py @@ -5,6 +5,7 @@ Unit tests for Cohere Rerank Guardrail Translation Handler import asyncio import os import sys +from typing import List, Optional, Tuple import pytest @@ -20,8 +21,10 @@ from litellm.types.utils import CallTypes class MockGuardrail(CustomGuardrail): """Mock guardrail for testing""" - async def apply_guardrail(self, text: str, language=None, entities=None) -> str: - return f"{text} [GUARDRAILED]" + async def apply_guardrail( + self, texts: List[str], request_data: dict, input_type: str, **kwargs + ) -> Tuple[List[str], Optional[List[str]]]: + return ([f"{text} [GUARDRAILED]" for text in texts], None) class TestHandlerDiscovery: @@ -183,17 +186,20 @@ class TestPIIMaskingScenario: """Mock PII masking guardrail""" async def apply_guardrail( - self, text: str, language=None, entities=None - ) -> str: + self, texts: List[str], request_data: dict, input_type: str, **kwargs + ) -> Tuple[List[str], Optional[List[str]]]: import re - masked = re.sub( - r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", - "[EMAIL_REDACTED]", - text, - ) - masked = masked.replace("John Doe", "[NAME_REDACTED]") - return masked + masked_texts = [] + for text in texts: + masked = re.sub( + r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", + "[EMAIL_REDACTED]", + text, + ) + masked = masked.replace("John Doe", "[NAME_REDACTED]") + masked_texts.append(masked) + return (masked_texts, None) handler = CohereRerankHandler() guardrail = PIIMaskingGuardrail(guardrail_name="mask_pii") @@ -231,21 +237,24 @@ class TestPIIMaskingScenario: """Mock PII masking guardrail""" async def apply_guardrail( - self, text: str, language=None, entities=None - ) -> str: + self, texts: List[str], request_data: dict, input_type: str, **kwargs + ) -> Tuple[List[str], Optional[List[str]]]: import re - # Mask emails - masked = re.sub( - r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", - "[EMAIL_REDACTED]", - text, - ) - # Mask phone numbers - masked = re.sub(r"\d{3}-\d{3}-\d{4}", "[PHONE_REDACTED]", masked) - # Mask names - masked = masked.replace("Alice Smith", "[NAME_REDACTED]") - return masked + masked_texts = [] + for text in texts: + # Mask emails + masked = re.sub( + r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", + "[EMAIL_REDACTED]", + text, + ) + # Mask phone numbers + masked = re.sub(r"\d{3}-\d{3}-\d{4}", "[PHONE_REDACTED]", masked) + # Mask names + masked = masked.replace("Alice Smith", "[NAME_REDACTED]") + masked_texts.append(masked) + return (masked_texts, None) handler = CohereRerankHandler() guardrail = PIIMaskingGuardrail(guardrail_name="mask_pii") @@ -340,13 +349,16 @@ class TestContentFilteringScenario: """Mock content filter guardrail""" async def apply_guardrail( - self, text: str, language=None, entities=None - ) -> str: + self, texts: List[str], request_data: dict, input_type: str, **kwargs + ) -> Tuple[List[str], Optional[List[str]]]: bad_words = ["inappropriate", "offensive"] - filtered = text - for word in bad_words: - filtered = filtered.replace(word, "[FILTERED]") - return filtered + filtered_texts = [] + for text in texts: + filtered = text + for word in bad_words: + filtered = filtered.replace(word, "[FILTERED]") + filtered_texts.append(filtered) + return (filtered_texts, None) handler = CohereRerankHandler() guardrail = ContentFilterGuardrail(guardrail_name="content_filter") diff --git a/tests/test_litellm/llms/openai/completion/test_text_completion_guardrail_handler.py b/tests/test_litellm/llms/openai/completion/test_text_completion_guardrail_handler.py index 97ca423f773..c861e48ad48 100644 --- a/tests/test_litellm/llms/openai/completion/test_text_completion_guardrail_handler.py +++ b/tests/test_litellm/llms/openai/completion/test_text_completion_guardrail_handler.py @@ -4,6 +4,7 @@ Unit tests for OpenAI Text Completion Guardrail Translation Handler import os import sys +from typing import List, Optional, Tuple from unittest.mock import MagicMock import pytest @@ -21,8 +22,10 @@ from litellm.types.utils import CallTypes, TextChoices, TextCompletionResponse class MockGuardrail(CustomGuardrail): """Mock guardrail for testing""" - async def apply_guardrail(self, text: str, language=None, entities=None) -> str: - return f"{text} [GUARDRAILED]" + async def apply_guardrail( + self, texts: List[str], request_data: dict, input_type: str, **kwargs + ) -> Tuple[List[str], Optional[List[str]]]: + return ([f"{text} [GUARDRAILED]" for text in texts], None) class TestHandlerDiscovery: @@ -243,19 +246,22 @@ class TestPIIMaskingScenario: """Mock PII masking guardrail""" async def apply_guardrail( - self, text: str, language=None, entities=None - ) -> str: + self, texts: List[str], request_data: dict, input_type: str, **kwargs + ) -> Tuple[List[str], Optional[List[str]]]: # Simple mock: replace email-like patterns import re - masked = re.sub( - r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", - "[EMAIL_REDACTED]", - text, - ) - # Replace names (simple mock) - masked = masked.replace("John Doe", "[NAME_REDACTED]") - return masked + masked_texts = [] + for text in texts: + masked = re.sub( + r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", + "[EMAIL_REDACTED]", + text, + ) + # Replace names (simple mock) + masked = masked.replace("John Doe", "[NAME_REDACTED]") + masked_texts.append(masked) + return (masked_texts, None) handler = OpenAITextCompletionHandler() guardrail = PIIMaskingGuardrail(guardrail_name="mask_pii") @@ -303,15 +309,19 @@ class TestPIIMaskingScenario: """Mock PII masking guardrail""" async def apply_guardrail( - self, text: str, language=None, entities=None - ) -> str: + self, texts: List[str], request_data: dict, input_type: str, **kwargs + ) -> Tuple[List[str], Optional[List[str]]]: import re - return re.sub( - r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", - "[EMAIL_REDACTED]", - text, - ) + masked_texts = [] + for text in texts: + masked = re.sub( + r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", + "[EMAIL_REDACTED]", + text, + ) + masked_texts.append(masked) + return (masked_texts, None) handler = OpenAITextCompletionHandler() guardrail = PIIMaskingGuardrail(guardrail_name="mask_pii") diff --git a/tests/test_litellm/llms/openai/image_generation/test_image_generation_guardrail_handler.py b/tests/test_litellm/llms/openai/image_generation/test_image_generation_guardrail_handler.py index 529d74f63d9..5e183e32208 100644 --- a/tests/test_litellm/llms/openai/image_generation/test_image_generation_guardrail_handler.py +++ b/tests/test_litellm/llms/openai/image_generation/test_image_generation_guardrail_handler.py @@ -4,6 +4,7 @@ Unit tests for OpenAI Image Generation Guardrail Translation Handler import os import sys +from typing import List, Optional, Tuple import pytest @@ -20,8 +21,10 @@ from litellm.types.utils import CallTypes, ImageObject, ImageResponse class MockGuardrail(CustomGuardrail): """Mock guardrail for testing""" - async def apply_guardrail(self, text: str, language=None, entities=None) -> str: - return f"{text} [GUARDRAILED]" + async def apply_guardrail( + self, texts: List[str], request_data: dict, input_type: str, **kwargs + ) -> Tuple[List[str], Optional[List[str]]]: + return ([f"{text} [GUARDRAILED]" for text in texts], None) class TestHandlerDiscovery: @@ -141,19 +144,22 @@ class TestPIIMaskingScenario: """Mock PII masking guardrail""" async def apply_guardrail( - self, text: str, language=None, entities=None - ) -> str: + self, texts: List[str], request_data: dict, input_type: str, **kwargs + ) -> Tuple[List[str], Optional[List[str]]]: # Simple mock: replace email-like patterns import re - masked = re.sub( - r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", - "[EMAIL_REDACTED]", - text, - ) - # Replace names (simple mock) - masked = masked.replace("John Doe", "[NAME_REDACTED]") - return masked + masked_texts = [] + for text in texts: + masked = re.sub( + r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", + "[EMAIL_REDACTED]", + text, + ) + # Replace names (simple mock) + masked = masked.replace("John Doe", "[NAME_REDACTED]") + masked_texts.append(masked) + return (masked_texts, None) handler = OpenAIImageGenerationHandler() guardrail = PIIMaskingGuardrail(guardrail_name="mask_pii") diff --git a/tests/test_litellm/llms/openai/responses/test_openai_responses_guardrail_handler.py b/tests/test_litellm/llms/openai/responses/test_openai_responses_guardrail_handler.py index b447c281aa3..c04f825e362 100644 --- a/tests/test_litellm/llms/openai/responses/test_openai_responses_guardrail_handler.py +++ b/tests/test_litellm/llms/openai/responses/test_openai_responses_guardrail_handler.py @@ -7,7 +7,7 @@ with guardrail transformations. import os import sys -from typing import Any +from typing import Any, List, Optional, Tuple from unittest.mock import AsyncMock, MagicMock import pytest @@ -29,9 +29,11 @@ from litellm.types.utils import CallTypes class MockGuardrail(CustomGuardrail): """Mock guardrail for testing that transforms text""" - async def apply_guardrail(self, text: str) -> str: + async def apply_guardrail( + self, texts: List[str], request_data: dict, input_type: str, **kwargs + ) -> Tuple[List[str], Optional[List[str]]]: """Append [GUARDRAILED] to text""" - return f"{text} [GUARDRAILED]" + return ([f"{text} [GUARDRAILED]" for text in texts], None) class TestOpenAIResponsesHandlerDiscovery: @@ -450,7 +452,10 @@ class TestOpenAIResponsesHandlerEdgeCases: "role": "user", "content": [ {"type": "text", "text": "List content"}, - {"type": "image_url", "image_url": {"url": "http://example.com"}}, + { + "type": "image_url", + "image_url": {"url": "http://example.com"}, + }, ], "type": "message", }, @@ -492,4 +497,3 @@ class TestOpenAIResponsesHandlerEdgeCases: # Should skip processing and return unchanged assert result == response - diff --git a/tests/test_litellm/llms/openai/speech/test_text_to_speech_guardrail_handler.py b/tests/test_litellm/llms/openai/speech/test_text_to_speech_guardrail_handler.py index b4064a22c17..dfd96beb2f4 100644 --- a/tests/test_litellm/llms/openai/speech/test_text_to_speech_guardrail_handler.py +++ b/tests/test_litellm/llms/openai/speech/test_text_to_speech_guardrail_handler.py @@ -4,6 +4,7 @@ Unit tests for OpenAI Text-to-Speech Guardrail Translation Handler import os import sys +from typing import List, Optional, Tuple import pytest @@ -20,8 +21,10 @@ from litellm.types.utils import CallTypes class MockGuardrail(CustomGuardrail): """Mock guardrail for testing""" - async def apply_guardrail(self, text: str, language=None, entities=None) -> str: - return f"{text} [GUARDRAILED]" + async def apply_guardrail( + self, texts: List[str], request_data: dict, input_type: str, **kwargs + ) -> Tuple[List[str], Optional[List[str]]]: + return ([f"{text} [GUARDRAILED]" for text in texts], None) class MockBinaryResponse: @@ -169,20 +172,23 @@ class TestPIIMaskingScenario: """Mock PII masking guardrail""" async def apply_guardrail( - self, text: str, language=None, entities=None - ) -> str: + self, texts: List[str], request_data: dict, input_type: str, **kwargs + ) -> Tuple[List[str], Optional[List[str]]]: # Simple mock: replace email-like patterns import re - masked = re.sub( - r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", - "[EMAIL_REDACTED]", - text, - ) - # Replace names (simple mock) - masked = masked.replace("John Doe", "[NAME_REDACTED]") - masked = masked.replace("555-1234", "[PHONE_REDACTED]") - return masked + masked_texts = [] + for text in texts: + masked = re.sub( + r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", + "[EMAIL_REDACTED]", + text, + ) + # Replace names (simple mock) + masked = masked.replace("John Doe", "[NAME_REDACTED]") + masked = masked.replace("555-1234", "[PHONE_REDACTED]") + masked_texts.append(masked) + return (masked_texts, None) handler = OpenAITextToSpeechHandler() guardrail = PIIMaskingGuardrail(guardrail_name="mask_pii") @@ -211,17 +217,24 @@ class TestPIIMaskingScenario: """Mock PII masking guardrail""" async def apply_guardrail( - self, text: str, language=None, entities=None - ) -> str: + self, texts: List[str], request_data: dict, input_type: str, **kwargs + ) -> Tuple[List[str], Optional[List[str]]]: import re - # Mask account numbers - masked = re.sub(r"account number \d{8,12}", "account number [REDACTED]", text) - # Mask SSNs - masked = re.sub(r"\d{3}-\d{2}-\d{4}", "[SSN_REDACTED]", masked) - # Mask credit cards - masked = re.sub(r"\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}", "[CC_REDACTED]", masked) - return masked + masked_texts = [] + for text in texts: + # Mask account numbers + masked = re.sub( + r"account number \d{8,12}", "account number [REDACTED]", text + ) + # Mask SSNs + masked = re.sub(r"\d{3}-\d{2}-\d{4}", "[SSN_REDACTED]", masked) + # Mask credit cards + masked = re.sub( + r"\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}", "[CC_REDACTED]", masked + ) + masked_texts.append(masked) + return (masked_texts, None) handler = OpenAITextToSpeechHandler() guardrail = PIIMaskingGuardrail(guardrail_name="mask_pii") @@ -256,14 +269,17 @@ class TestContentModerationScenario: """Mock content filter guardrail""" async def apply_guardrail( - self, text: str, language=None, entities=None - ) -> str: + self, texts: List[str], request_data: dict, input_type: str, **kwargs + ) -> Tuple[List[str], Optional[List[str]]]: # Simple mock: filter inappropriate words bad_words = ["badword", "inappropriate", "offensive"] - filtered = text - for word in bad_words: - filtered = filtered.replace(word, "[FILTERED]") - return filtered + filtered_texts = [] + for text in texts: + filtered = text + for word in bad_words: + filtered = filtered.replace(word, "[FILTERED]") + filtered_texts.append(filtered) + return (filtered_texts, None) handler = OpenAITextToSpeechHandler() guardrail = ContentFilterGuardrail(guardrail_name="content_filter") @@ -322,4 +338,3 @@ class TestMultilingualTTS: assert f"Testing with {voice} voice [GUARDRAILED]" == result["input"] assert result["voice"] == voice - diff --git a/tests/test_litellm/llms/openai/transcriptions/test_audio_transcription_guardrail_handler.py b/tests/test_litellm/llms/openai/transcriptions/test_audio_transcription_guardrail_handler.py index faa425eb714..4d2cb142b35 100644 --- a/tests/test_litellm/llms/openai/transcriptions/test_audio_transcription_guardrail_handler.py +++ b/tests/test_litellm/llms/openai/transcriptions/test_audio_transcription_guardrail_handler.py @@ -4,6 +4,7 @@ Unit tests for OpenAI Audio Transcription Guardrail Translation Handler import os import sys +from typing import List, Optional, Tuple import pytest @@ -21,8 +22,10 @@ from litellm.utils import TranscriptionResponse class MockGuardrail(CustomGuardrail): """Mock guardrail for testing""" - async def apply_guardrail(self, text: str, language=None, entities=None) -> str: - return f"{text} [GUARDRAILED]" + async def apply_guardrail( + self, texts: List[str], request_data: dict, input_type: str, **kwargs + ) -> Tuple[List[str], Optional[List[str]]]: + return ([f"{text} [GUARDRAILED]" for text in texts], None) class TestHandlerDiscovery: @@ -140,20 +143,23 @@ class TestPIIMaskingScenario: """Mock PII masking guardrail""" async def apply_guardrail( - self, text: str, language=None, entities=None - ) -> str: + self, texts: List[str], request_data: dict, input_type: str, **kwargs + ) -> Tuple[List[str], Optional[List[str]]]: # Simple mock: replace email-like patterns import re - masked = re.sub( - r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", - "[EMAIL_REDACTED]", - text, - ) - # Replace names (simple mock) - masked = masked.replace("John Doe", "[NAME_REDACTED]") - masked = masked.replace("555-1234", "[PHONE_REDACTED]") - return masked + masked_texts = [] + for text in texts: + masked = re.sub( + r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", + "[EMAIL_REDACTED]", + text, + ) + # Replace names (simple mock) + masked = masked.replace("John Doe", "[NAME_REDACTED]") + masked = masked.replace("555-1234", "[PHONE_REDACTED]") + masked_texts.append(masked) + return (masked_texts, None) handler = OpenAIAudioTranscriptionHandler() guardrail = PIIMaskingGuardrail(guardrail_name="mask_pii") @@ -181,23 +187,26 @@ class TestPIIMaskingScenario: """Mock PII masking guardrail""" async def apply_guardrail( - self, text: str, language=None, entities=None - ) -> str: + self, texts: List[str], request_data: dict, input_type: str, **kwargs + ) -> Tuple[List[str], Optional[List[str]]]: import re - # Mask credit card numbers - masked = re.sub( - r"\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}", "[CC_REDACTED]", text - ) - # Mask SSNs - masked = re.sub(r"\d{3}-\d{2}-\d{4}", "[SSN_REDACTED]", masked) - # Mask emails - masked = re.sub( - r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", - "[EMAIL_REDACTED]", - masked, - ) - return masked + masked_texts = [] + for text in texts: + # Mask credit card numbers + masked = re.sub( + r"\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}", "[CC_REDACTED]", text + ) + # Mask SSNs + masked = re.sub(r"\d{3}-\d{2}-\d{4}", "[SSN_REDACTED]", masked) + # Mask emails + masked = re.sub( + r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", + "[EMAIL_REDACTED]", + masked, + ) + masked_texts.append(masked) + return (masked_texts, None) handler = OpenAIAudioTranscriptionHandler() guardrail = PIIMaskingGuardrail(guardrail_name="mask_pii") @@ -231,14 +240,17 @@ class TestContentModerationScenario: """Mock profanity filter guardrail""" async def apply_guardrail( - self, text: str, language=None, entities=None - ) -> str: + self, texts: List[str], request_data: dict, input_type: str, **kwargs + ) -> Tuple[List[str], Optional[List[str]]]: # Simple mock: replace common profanity bad_words = ["badword1", "badword2", "inappropriate"] - filtered = text - for word in bad_words: - filtered = filtered.replace(word, "[FILTERED]") - return filtered + filtered_texts = [] + for text in texts: + filtered = text + for word in bad_words: + filtered = filtered.replace(word, "[FILTERED]") + filtered_texts.append(filtered) + return (filtered_texts, None) handler = OpenAIAudioTranscriptionHandler() guardrail = ProfanityFilterGuardrail(guardrail_name="content_filter") diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_content_filter.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_content_filter.py index e756dd6bd5d..265605c1637 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_content_filter.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_content_filter.py @@ -40,12 +40,12 @@ class TestContentFilterGuardrail: action=ContentFilterAction.BLOCK, ), ] - + guardrail = ContentFilterGuardrail( guardrail_name="test-content-filter", patterns=patterns, ) - + assert guardrail.guardrail_name == "test-content-filter" assert len(guardrail.compiled_patterns) == 1 @@ -57,19 +57,19 @@ class TestContentFilterGuardrail: BlockedWord( keyword="secret_project", action=ContentFilterAction.BLOCK, - description="Top secret project" + description="Top secret project", ), BlockedWord( keyword="internal_api", action=ContentFilterAction.MASK, ), ] - + guardrail = ContentFilterGuardrail( guardrail_name="test-content-filter", blocked_words=blocked_words, ) - + assert len(guardrail.blocked_words) == 2 assert "secret_project" in guardrail.blocked_words assert guardrail.blocked_words["secret_project"][0] == ContentFilterAction.BLOCK @@ -85,18 +85,18 @@ class TestContentFilterGuardrail: action=ContentFilterAction.BLOCK, ), ] - + guardrail = ContentFilterGuardrail( guardrail_name="test-ssn", patterns=patterns, ) - + # Test with SSN result = guardrail._check_patterns("My SSN is 123-45-6789") assert result is not None assert result[1] == "us_ssn" assert result[2] == ContentFilterAction.BLOCK - + # Test without SSN result = guardrail._check_patterns("This is a normal message") assert result is None @@ -112,12 +112,12 @@ class TestContentFilterGuardrail: action=ContentFilterAction.MASK, ), ] - + guardrail = ContentFilterGuardrail( guardrail_name="test-email", patterns=patterns, ) - + result = guardrail._check_patterns("Contact me at test@example.com") assert result is not None assert result[1] == "email" @@ -135,12 +135,12 @@ class TestContentFilterGuardrail: action=ContentFilterAction.BLOCK, ), ] - + guardrail = ContentFilterGuardrail( guardrail_name="test-custom", patterns=patterns, ) - + result = guardrail._check_patterns("My ID is ABC-1234") assert result is not None assert result[1] == "custom_id" @@ -155,18 +155,18 @@ class TestContentFilterGuardrail: action=ContentFilterAction.BLOCK, ), ] - + guardrail = ContentFilterGuardrail( guardrail_name="test-words", blocked_words=blocked_words, ) - + # Test with blocked word result = guardrail._check_blocked_words("This is CONFIDENTIAL information") assert result is not None assert result[0] == "confidential" assert result[1] == ContentFilterAction.BLOCK - + # Test without blocked word result = guardrail._check_blocked_words("This is normal information") assert result is None @@ -183,15 +183,17 @@ class TestContentFilterGuardrail: action=ContentFilterAction.BLOCK, ), ] - + guardrail = ContentFilterGuardrail( guardrail_name="test-block", patterns=patterns, ) - + with pytest.raises(HTTPException) as exc_info: - await guardrail.apply_guardrail(text="My SSN is 123-45-6789") - + await guardrail.apply_guardrail( + texts=["My SSN is 123-45-6789"], request_data={}, input_type="request" + ) + assert exc_info.value.status_code == 400 assert "us_ssn" in str(exc_info.value.detail) @@ -207,17 +209,22 @@ class TestContentFilterGuardrail: action=ContentFilterAction.MASK, ), ] - + guardrail = ContentFilterGuardrail( guardrail_name="test-mask", patterns=patterns, ) - - result = await guardrail.apply_guardrail(text="Contact me at test@example.com") - + + result, _ = await guardrail.apply_guardrail( + texts=["Contact me at test@example.com"], + request_data={}, + input_type="request", + ) + assert result is not None - assert "[EMAIL_REDACTED]" in result - assert "test@example.com" not in result + assert len(result) == 1 + assert "[EMAIL_REDACTED]" in result[0] + assert "test@example.com" not in result[0] @pytest.mark.asyncio async def test_apply_guardrail_blocked_word_mask(self): @@ -230,17 +237,22 @@ class TestContentFilterGuardrail: action=ContentFilterAction.MASK, ), ] - + guardrail = ContentFilterGuardrail( guardrail_name="test-word-mask", blocked_words=blocked_words, ) - - result = await guardrail.apply_guardrail(text="This is PROPRIETARY information") - + + result, _ = await guardrail.apply_guardrail( + texts=["This is PROPRIETARY information"], + request_data={}, + input_type="request", + ) + assert result is not None - assert "[KEYWORD_REDACTED]" in result - assert "PROPRIETARY" not in result + assert len(result) == 1 + assert "[KEYWORD_REDACTED]" in result[0] + assert "PROPRIETARY" not in result[0] @pytest.mark.asyncio async def test_apply_guardrail_multiple_patterns(self): @@ -259,19 +271,22 @@ class TestContentFilterGuardrail: action=ContentFilterAction.MASK, ), ] - + guardrail = ContentFilterGuardrail( guardrail_name="test-multiple", patterns=patterns, ) - - result = await guardrail.apply_guardrail( - text="Contact user@test.com or SSN: 123-45-6789" + + result, _ = await guardrail.apply_guardrail( + texts=["Contact user@test.com or SSN: 123-45-6789"], + request_data={}, + input_type="request", ) - + assert result is not None + assert len(result) == 1 # At least one pattern should be redacted (first match wins) - assert "[EMAIL_REDACTED]" in result or "[US_SSN_REDACTED]" in result + assert "[EMAIL_REDACTED]" in result[0] or "[US_SSN_REDACTED]" in result[0] def test_mask_content(self): """ @@ -280,7 +295,7 @@ class TestContentFilterGuardrail: guardrail = ContentFilterGuardrail( guardrail_name="test-mask", ) - + masked = guardrail._mask_content("sensitive text", "us_ssn") assert masked == "[US_SSN_REDACTED]" @@ -291,28 +306,34 @@ class TestContentFilterGuardrail: import tempfile # Create a temporary blocked words file - with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: - f.write("""blocked_words: + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write( + """blocked_words: - keyword: "test_keyword" action: "BLOCK" description: "Test keyword" - keyword: "another_word" action: "MASK" -""") +""" + ) temp_file = f.name - + try: guardrail = ContentFilterGuardrail( guardrail_name="test-file-load", blocked_words_file=temp_file, ) - + assert len(guardrail.blocked_words) == 2 assert "test_keyword" in guardrail.blocked_words - assert guardrail.blocked_words["test_keyword"][0] == ContentFilterAction.BLOCK + assert ( + guardrail.blocked_words["test_keyword"][0] == ContentFilterAction.BLOCK + ) assert guardrail.blocked_words["test_keyword"][1] == "Test keyword" assert "another_word" in guardrail.blocked_words - assert guardrail.blocked_words["another_word"][0] == ContentFilterAction.MASK + assert ( + guardrail.blocked_words["another_word"][0] == ContentFilterAction.MASK + ) finally: os.unlink(temp_file) @@ -327,17 +348,17 @@ class TestContentFilterGuardrail: action=ContentFilterAction.BLOCK, ), ] - + guardrail = ContentFilterGuardrail( guardrail_name="test-cc", patterns=patterns, ) - + # Test Visa card result = guardrail._check_patterns("My card is 4532-1234-5678-9010") assert result is not None assert result[1] == "visa" - + def test_api_key_patterns(self): """ Test API key pattern detection @@ -349,12 +370,12 @@ class TestContentFilterGuardrail: action=ContentFilterAction.BLOCK, ), ] - + guardrail = ContentFilterGuardrail( guardrail_name="test-api-key", patterns=patterns, ) - + # Test AWS Access Key result = guardrail._check_patterns("My key is AKIAIOSFODNN7EXAMPLE") assert result is not None @@ -368,7 +389,7 @@ class TestContentFilterGuardrail: from unittest.mock import AsyncMock from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices - + patterns = [ ContentFilterPattern( pattern_type="prebuilt", @@ -376,34 +397,40 @@ class TestContentFilterGuardrail: action=ContentFilterAction.MASK, ), ] - + guardrail = ContentFilterGuardrail( guardrail_name="test-streaming-mask", patterns=patterns, event_hook=GuardrailEventHooks.during_call, ) - + # Create mock streaming chunks async def mock_stream(): # Chunk 1: contains email chunk1 = ModelResponseStream( id="chunk1", - choices=[StreamingChoices(delta=Delta(content="Contact me at test@example.com"), index=0)], + choices=[ + StreamingChoices( + delta=Delta(content="Contact me at test@example.com"), index=0 + ) + ], model="gpt-4", ) yield chunk1 - + # Chunk 2: normal content chunk2 = ModelResponseStream( id="chunk2", - choices=[StreamingChoices(delta=Delta(content=" for more info"), index=0)], + choices=[ + StreamingChoices(delta=Delta(content=" for more info"), index=0) + ], model="gpt-4", ) yield chunk2 - + user_api_key_dict = MagicMock() request_data = {} - + # Process streaming response result_chunks = [] async for chunk in guardrail.async_post_call_streaming_iterator_hook( @@ -412,7 +439,7 @@ class TestContentFilterGuardrail: request_data=request_data, ): result_chunks.append(chunk) - + assert len(result_chunks) == 2 # First chunk should have email masked assert "[EMAIL_REDACTED]" in result_chunks[0].choices[0].delta.content @@ -428,7 +455,7 @@ class TestContentFilterGuardrail: from unittest.mock import AsyncMock from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices - + patterns = [ ContentFilterPattern( pattern_type="prebuilt", @@ -436,25 +463,27 @@ class TestContentFilterGuardrail: action=ContentFilterAction.BLOCK, ), ] - + guardrail = ContentFilterGuardrail( guardrail_name="test-streaming-block", patterns=patterns, event_hook=GuardrailEventHooks.during_call, ) - + # Create mock streaming chunks with SSN async def mock_stream(): chunk = ModelResponseStream( id="chunk1", - choices=[StreamingChoices(delta=Delta(content="SSN: 123-45-6789"), index=0)], + choices=[ + StreamingChoices(delta=Delta(content="SSN: 123-45-6789"), index=0) + ], model="gpt-4", ) yield chunk - + user_api_key_dict = MagicMock() request_data = {} - + # Should raise HTTPException when SSN is detected with pytest.raises(HTTPException) as exc_info: async for chunk in guardrail.async_post_call_streaming_iterator_hook( @@ -463,7 +492,7 @@ class TestContentFilterGuardrail: request_data=request_data, ): pass - + assert exc_info.value.status_code == 400 assert "us_ssn" in str(exc_info.value.detail) @@ -487,9 +516,9 @@ class TestContentFilterGuardrail: "action": "MASK", "name": "email", "pattern": None, - } + }, ] - + blocked_words = [ { "keyword": "langchain", @@ -500,19 +529,19 @@ class TestContentFilterGuardrail: "keyword": "openai", "action": "MASK", "description": "Competitor name", - } + }, ] - + guardrail = ContentFilterGuardrail( guardrail_name="test-db-format", patterns=patterns, blocked_words=blocked_words, ) - + assert guardrail.guardrail_name == "test-db-format" assert len(guardrail.compiled_patterns) == 2 assert len(guardrail.blocked_words) == 2 - + # Verify blocked_words are stored as dict assert "langchain" in guardrail.blocked_words assert guardrail.blocked_words["langchain"] == ("BLOCK", None) diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_presidio.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_presidio.py index 9543b61ef69..3e19437fe83 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_presidio.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_presidio.py @@ -537,24 +537,25 @@ async def test_logging_hook_multiple_content_items(presidio_guardrail): async def test_presidio_sets_guardrail_information_in_request_data(): """ Test that Presidio populates guardrail information into request_data metadata. - + This validates that add_standard_logging_guardrail_information_to_request_data correctly sets the guardrail information that will be used for logging. """ presidio = _OPTIONAL_PresidioPIIMasking( guardrail_name="test_presidio", output_parse_pii=True, + mock_testing=True, ) - + request_data = { "messages": [{"role": "user", "content": "Test"}], "model": "gpt-4o", "metadata": {}, } - + async def mock_check_pii(text, output_parse_pii, presidio_config, request_data): assert request_data is not None - + presidio.add_standard_logging_guardrail_information_to_request_data( guardrail_provider="presidio", guardrail_json_response=[], @@ -565,27 +566,30 @@ async def test_presidio_sets_guardrail_information_in_request_data(): duration=1.0, masked_entity_count={"EMAIL_ADDRESS": 1, "PERSON": 1}, ) - + return text - - with patch.object(presidio, 'check_pii', mock_check_pii): + + with patch.object(presidio, "check_pii", mock_check_pii): await presidio.apply_guardrail( - text="Test message", + texts=["Test message"], request_data=request_data, + input_type="request", ) - + assert "metadata" in request_data assert "standard_logging_guardrail_information" in request_data["metadata"] - - guardrail_info_list = request_data["metadata"]["standard_logging_guardrail_information"] + + guardrail_info_list = request_data["metadata"][ + "standard_logging_guardrail_information" + ] assert isinstance(guardrail_info_list, list) assert len(guardrail_info_list) > 0 - + guardrail_info = guardrail_info_list[0] assert "masked_entity_count" in guardrail_info assert guardrail_info["masked_entity_count"]["EMAIL_ADDRESS"] == 1 assert guardrail_info["masked_entity_count"]["PERSON"] == 1 - + print("✓ Presidio sets guardrail_information in request_data") @@ -593,7 +597,7 @@ async def test_presidio_sets_guardrail_information_in_request_data(): async def test_request_data_flows_to_apply_guardrail(): """ Test that request_data is correctly passed to apply_guardrail method. - + This validates the fix where guardrail translation handler passes data as request_data to apply_guardrail so guardrails can store metadata for logging. """ @@ -601,31 +605,32 @@ async def test_request_data_flows_to_apply_guardrail(): guardrail_name="test_presidio", output_parse_pii=True, ) - + request_data = { "messages": [{"role": "user", "content": "Test message"}], "model": "gpt-4o", "metadata": {}, } - + async def mock_check_pii(text, output_parse_pii, presidio_config, request_data): assert request_data is not None, "request_data should be passed to check_pii" assert "metadata" in request_data, "request_data should have metadata" - + request_data.setdefault("metadata", {}) request_data["metadata"]["test_flag"] = "passed_correctly" - + return text - - with patch.object(presidio, 'check_pii', mock_check_pii): + + with patch.object(presidio, "check_pii", mock_check_pii): result = await presidio.apply_guardrail( - text="Test message", + texts=["Test message"], request_data=request_data, + input_type="request", ) - + assert "metadata" in request_data assert request_data["metadata"].get("test_flag") == "passed_correctly" - + print("✓ request_data correctly passed to apply_guardrail") From 8eaabb4ad765cb1c8c7c8d5df6e2b0dcaf4cd3c5 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Wed, 3 Dec 2025 15:29:47 +0530 Subject: [PATCH 33/37] Add vector store support for ragflow --- .../docs/completion/knowledgebase.md | 1 + .../docs/providers/ragflow_vector_store.md | 349 +++++++++++++++++ docs/my-website/docs/vector_stores/create.md | 1 + .../llms/ragflow/vector_stores/__init__.py | 2 + .../ragflow/vector_stores/transformation.py | 249 ++++++++++++ litellm/types/vector_stores.py | 4 +- litellm/utils.py | 6 + ...test_vector_store_create_provider_logic.py | 44 ++- .../test_ragflow_vector_store.py | 359 ++++++++++++++++++ 9 files changed, 1010 insertions(+), 5 deletions(-) create mode 100644 docs/my-website/docs/providers/ragflow_vector_store.md create mode 100644 litellm/llms/ragflow/vector_stores/__init__.py create mode 100644 litellm/llms/ragflow/vector_stores/transformation.py create mode 100644 tests/vector_store_tests/test_ragflow_vector_store.py diff --git a/docs/my-website/docs/completion/knowledgebase.md b/docs/my-website/docs/completion/knowledgebase.md index 759f7912d87..fd6ef7a9982 100644 --- a/docs/my-website/docs/completion/knowledgebase.md +++ b/docs/my-website/docs/completion/knowledgebase.md @@ -21,6 +21,7 @@ LiteLLM integrates with vector stores, allowing your models to access your organ - [Azure Vector Stores](https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/file-search?tabs=python#vector-stores) (Cannot be directly queried. Only available for calling in Assistants messages. We will be adding Azure AI Search Vector Store API support soon.) - [Vertex AI RAG API](https://cloud.google.com/vertex-ai/generative-ai/docs/rag-overview) - [Gemini File Search](https://ai.google.dev/gemini-api/docs/file-search) +- [RAGFlow Datasets](/docs/providers/ragflow_vector_store.md) (Dataset management only, search not supported) ## Quick Start diff --git a/docs/my-website/docs/providers/ragflow_vector_store.md b/docs/my-website/docs/providers/ragflow_vector_store.md new file mode 100644 index 00000000000..bc014cacbe6 --- /dev/null +++ b/docs/my-website/docs/providers/ragflow_vector_store.md @@ -0,0 +1,349 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; +import Image from '@theme/IdealImage'; + +# RAGFlow Vector Stores + +Litellm support creation and management of datasets for document processing and knowledge base management in Ragflow. + +| Property | Details | +|----------|---------| +| Description | RAGFlow datasets enable document processing, chunking, and knowledge base management for RAG applications. | +| Provider Route on LiteLLM | `ragflow` in the litellm vector_store_registry | +| Provider Doc | [RAGFlow API Documentation ↗](https://ragflow.io/docs) | +| Supported Operations | Dataset Management (Create, List, Update, Delete) | +| Search/Retrieval | ❌ Not supported (management only) | + +## Quick Start + +### LiteLLM Python SDK + +```python showLineNumbers title="Example using LiteLLM Python SDK" +import os +import litellm + +# Set RAGFlow credentials +os.environ["RAGFLOW_API_KEY"] = "your-ragflow-api-key" +os.environ["RAGFLOW_API_BASE"] = "http://localhost:9380" # Optional, defaults to localhost:9380 + +# Create a RAGFlow dataset +response = litellm.vector_stores.create( + name="my-dataset", + custom_llm_provider="ragflow", + metadata={ + "description": "My knowledge base dataset", + "embedding_model": "BAAI/bge-large-zh-v1.5@BAAI", + "chunk_method": "naive" + } +) + +print(f"Created dataset ID: {response.id}") +print(f"Dataset name: {response.name}") +``` + +### LiteLLM Proxy + +#### 1. Configure your vector_store_registry + + + + +```yaml +model_list: + - model_name: gpt-4o-mini + litellm_params: + model: gpt-4o-mini + api_key: os.environ/OPENAI_API_KEY + +vector_store_registry: + - vector_store_name: "ragflow-knowledge-base" + litellm_params: + vector_store_id: "your-dataset-id" + custom_llm_provider: "ragflow" + api_key: os.environ/RAGFLOW_API_KEY + api_base: os.environ/RAGFLOW_API_BASE # Optional + vector_store_description: "RAGFlow dataset for knowledge base" + vector_store_metadata: + source: "Company documentation" +``` + + + + + +On the LiteLLM UI, Navigate to Experimental > Vector Stores > Create Vector Store. On this page you can create a vector store with a name, vector store id and credentials. + + + + + + +#### 2. Create a dataset via Proxy + + + + +```bash +curl http://localhost:4000/v1/vector_stores \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $LITELLM_API_KEY" \ + -d '{ + "name": "my-ragflow-dataset", + "custom_llm_provider": "ragflow", + "metadata": { + "description": "Test dataset", + "chunk_method": "naive" + } + }' +``` + + + + + +```python +from openai import OpenAI + +# Initialize client with your LiteLLM proxy URL +client = OpenAI( + base_url="http://localhost:4000", + api_key="your-litellm-api-key" +) + +# Create a RAGFlow dataset +response = client.vector_stores.create( + name="my-ragflow-dataset", + custom_llm_provider="ragflow", + metadata={ + "description": "Test dataset", + "chunk_method": "naive" + } +) + +print(f"Created dataset: {response.id}") +``` + + + + +## Configuration + +### Environment Variables + +RAGFlow vector stores support configuration via environment variables: + +- `RAGFLOW_API_KEY` - Your RAGFlow API key (required) +- `RAGFLOW_API_BASE` - RAGFlow API base URL (optional, defaults to `http://localhost:9380`) + +### Parameters + +You can also pass these via `litellm_params`: + +- `api_key` - RAGFlow API key (overrides `RAGFLOW_API_KEY` env var) +- `api_base` - RAGFlow API base URL (overrides `RAGFLOW_API_BASE` env var) + +## Dataset Creation Options + +### Basic Dataset Creation + +```python +response = litellm.vector_stores.create( + name="basic-dataset", + custom_llm_provider="ragflow" +) +``` + +### Dataset with Chunk Method + +RAGFlow supports various chunk methods for different document types: + + + + +```python +response = litellm.vector_stores.create( + name="general-dataset", + custom_llm_provider="ragflow", + metadata={ + "chunk_method": "naive", + "parser_config": { + "chunk_token_num": 512, + "delimiter": "\n", + "html4excel": False, + "layout_recognize": "DeepDOC" + } + } +) +``` + + + + + +```python +response = litellm.vector_stores.create( + name="book-dataset", + custom_llm_provider="ragflow", + metadata={ + "chunk_method": "book", + "parser_config": { + "raptor": { + "use_raptor": False + } + } + } +) +``` + + + + + +```python +response = litellm.vector_stores.create( + name="qa-dataset", + custom_llm_provider="ragflow", + metadata={ + "chunk_method": "qa", + "parser_config": { + "raptor": { + "use_raptor": False + } + } + } +) +``` + + + + + +```python +response = litellm.vector_stores.create( + name="paper-dataset", + custom_llm_provider="ragflow", + metadata={ + "chunk_method": "paper", + "parser_config": { + "raptor": { + "use_raptor": False + } + } + } +) +``` + + + + +### Dataset with Ingestion Pipeline + +Instead of using a chunk method, you can use an ingestion pipeline: + +```python +response = litellm.vector_stores.create( + name="pipeline-dataset", + custom_llm_provider="ragflow", + metadata={ + "parse_type": 2, # Number of parsers in your pipeline + "pipeline_id": "d0bebe30ae2211f0970942010a8e0005" # 32-character hex ID + } +) +``` + +**Note**: `chunk_method` and `pipeline_id` are mutually exclusive. Use one or the other. + +### Advanced Parser Configuration + +```python +response = litellm.vector_stores.create( + name="advanced-dataset", + custom_llm_provider="ragflow", + metadata={ + "chunk_method": "naive", + "description": "Advanced dataset with custom parser config", + "embedding_model": "BAAI/bge-large-zh-v1.5@BAAI", + "permission": "me", # or "team" + "parser_config": { + "chunk_token_num": 1024, + "delimiter": "\n!?;。;!?", + "html4excel": True, + "layout_recognize": "DeepDOC", + "auto_keywords": 5, + "auto_questions": 3, + "task_page_size": 12, + "raptor": { + "use_raptor": True + }, + "graphrag": { + "use_graphrag": False + } + } + } +) +``` + +## Supported Chunk Methods + +RAGFlow supports the following chunk methods: + +- `naive` - General purpose (default) +- `book` - For book documents +- `email` - For email documents +- `laws` - For legal documents +- `manual` - Manual chunking +- `one` - Single chunk +- `paper` - For academic papers +- `picture` - For image documents +- `presentation` - For presentation documents +- `qa` - Q&A format +- `table` - For table documents +- `tag` - Tag-based chunking + +## RAGFlow-Specific Parameters + +All RAGFlow-specific parameters should be passed via the `metadata` field: + +| Parameter | Type | Description | +|-----------|------|-------------| +| `avatar` | string | Base64 encoding of the avatar (max 65535 chars) | +| `description` | string | Brief description of the dataset (max 65535 chars) | +| `embedding_model` | string | Embedding model name (e.g., "BAAI/bge-large-zh-v1.5@BAAI") | +| `permission` | string | Access permission: "me" (default) or "team" | +| `chunk_method` | string | Chunking method (see supported methods above) | +| `parser_config` | object | Parser configuration (varies by chunk_method) | +| `parse_type` | int | Number of parsers in pipeline (required with pipeline_id) | +| `pipeline_id` | string | 32-character hex pipeline ID (required with parse_type) | + +## Error Handling + +RAGFlow returns error responses in the following format: + +```json +{ + "code": 101, + "message": "Dataset name 'my-dataset' already exists" +} +``` + +LiteLLM automatically maps these to appropriate exceptions: + +- `code != 0` → Raises exception with the error message +- Missing required fields → Raises `ValueError` +- Mutually exclusive parameters → Raises `ValueError` + +## Limitations + +- **Search/Retrieval**: RAGFlow vector stores support dataset management only. Search operations are not supported and will raise `NotImplementedError`. +- **List/Update/Delete**: These operations are not yet implemented through the standard vector store API. Use RAGFlow's native API endpoints directly. + +## Further Reading + +Vector Stores: +- [Vector Store Creation](../vector_stores/create.md) +- [Using Vector Stores with Completions](../completion/knowledgebase.md) +- [Vector Store Registry](../completion/knowledgebase.md#vectorstoreregistry) + diff --git a/docs/my-website/docs/vector_stores/create.md b/docs/my-website/docs/vector_stores/create.md index 19b4f39cd9e..7025c490a32 100644 --- a/docs/my-website/docs/vector_stores/create.md +++ b/docs/my-website/docs/vector_stores/create.md @@ -14,6 +14,7 @@ Create a vector store which can be used to store and search document chunks for | End-user Tracking | ✅ | | | Support LLM Providers (OpenAI `/vector_stores` API) | **OpenAI** | Full vector stores API support across providers | | Support LLM Providers (Passthrough API) | [**Azure AI**](/docs/providers/azure_ai/azure_ai_vector_stores_passthrough) | Full vector stores API support across providers | +| Support LLM Providers (Dataset Management) | [**RAGFlow**](/docs/providers/ragflow_vector_store.md) | Dataset creation and management (search not supported) | ## Usage diff --git a/litellm/llms/ragflow/vector_stores/__init__.py b/litellm/llms/ragflow/vector_stores/__init__.py new file mode 100644 index 00000000000..3be29310b39 --- /dev/null +++ b/litellm/llms/ragflow/vector_stores/__init__.py @@ -0,0 +1,2 @@ +# RAGFlow vector stores module + diff --git a/litellm/llms/ragflow/vector_stores/transformation.py b/litellm/llms/ragflow/vector_stores/transformation.py new file mode 100644 index 00000000000..b6401a4b8d7 --- /dev/null +++ b/litellm/llms/ragflow/vector_stores/transformation.py @@ -0,0 +1,249 @@ +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union + +import httpx + +from litellm.llms.base_llm.vector_store.transformation import BaseVectorStoreConfig +from litellm.secret_managers.main import get_secret_str +from litellm.types.router import GenericLiteLLMParams +from litellm.types.vector_stores import ( + BaseVectorStoreAuthCredentials, + VectorStoreCreateOptionalRequestParams, + VectorStoreCreateResponse, + VectorStoreFileCounts, + VectorStoreIndexEndpoints, + VectorStoreSearchOptionalRequestParams, + VectorStoreSearchResponse, +) + +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj +else: + LiteLLMLoggingObj = Any + + +class RAGFlowVectorStoreConfig(BaseVectorStoreConfig): + """Vector store configuration for RAGFlow datasets.""" + + def get_auth_credentials( + self, litellm_params: dict + ) -> BaseVectorStoreAuthCredentials: + api_key = litellm_params.get("api_key") + if api_key is None: + # Try to get from environment variable + api_key = get_secret_str("RAGFLOW_API_KEY") + if api_key is None: + raise ValueError("api_key is required (set RAGFLOW_API_KEY env var or pass in litellm_params)") + return { + "headers": { + "Authorization": f"Bearer {api_key}", + }, + } + + def get_vector_store_endpoints_by_type(self) -> VectorStoreIndexEndpoints: + """RAGFlow vector stores are management-only, no search support.""" + return { + "read": [], + "write": [], + } + + def validate_environment( + self, headers: dict, litellm_params: Optional[GenericLiteLLMParams] + ) -> dict: + """Validate environment and set headers for RAGFlow API.""" + litellm_params = litellm_params or GenericLiteLLMParams() + api_key = ( + litellm_params.api_key + or get_secret_str("RAGFLOW_API_KEY") + ) + + if api_key is None: + raise ValueError("RAGFLOW_API_KEY is required (set env var or pass in litellm_params)") + + headers.update( + { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + ) + return headers + + def get_complete_url( + self, + api_base: Optional[str], + litellm_params: dict, + ) -> str: + """ + Get the complete URL for RAGFlow datasets API. + + Supports: + - RAGFLOW_API_BASE env var + - api_base in litellm_params + - Default: http://localhost:9380 + """ + api_base = ( + api_base + or litellm_params.get("api_base") + or get_secret_str("RAGFLOW_API_BASE") + or "http://localhost:9380" + ) + + # Remove trailing slashes + api_base = api_base.rstrip("/") + + # RAGFlow datasets API endpoint + return f"{api_base}/api/v1/datasets" + + def transform_search_vector_store_request( + self, + vector_store_id: str, + query: Union[str, List[str]], + vector_store_search_optional_params: VectorStoreSearchOptionalRequestParams, + api_base: str, + litellm_logging_obj: LiteLLMLoggingObj, + litellm_params: dict, + ) -> Tuple[str, Dict]: + """RAGFlow vector stores are management-only, search is not supported.""" + raise NotImplementedError( + "RAGFlow vector stores support dataset management only, not search/retrieval" + ) + + def transform_search_vector_store_response( + self, response: httpx.Response, litellm_logging_obj: LiteLLMLoggingObj + ) -> VectorStoreSearchResponse: + """RAGFlow vector stores are management-only, search is not supported.""" + raise NotImplementedError( + "RAGFlow vector stores support dataset management only, not search/retrieval" + ) + + def transform_create_vector_store_request( + self, + vector_store_create_optional_params: VectorStoreCreateOptionalRequestParams, + api_base: str, + ) -> Tuple[str, Dict]: + """ + Transform create request to RAGFlow POST /api/v1/datasets format. + + Maps LiteLLM params to RAGFlow dataset creation parameters. + RAGFlow-specific fields can be passed via metadata. + """ + url = api_base # Already includes /api/v1/datasets from get_complete_url + + # Extract name (required by RAGFlow) + name = vector_store_create_optional_params.get("name") + if not name: + raise ValueError("name is required for RAGFlow dataset creation") + + # Build request body + request_body: Dict[str, Any] = { + "name": name, + } + + # Extract RAGFlow-specific fields from metadata + metadata = vector_store_create_optional_params.get("metadata") + if metadata: + # RAGFlow-specific fields that can be in metadata + ragflow_fields = [ + "avatar", + "description", + "embedding_model", + "permission", + "chunk_method", + "parser_config", + "parse_type", + "pipeline_id", + ] + + for field in ragflow_fields: + if field in metadata: + request_body[field] = metadata[field] + + # Validate: chunk_method and pipeline_id are mutually exclusive + if "chunk_method" in request_body and "pipeline_id" in request_body: + raise ValueError( + "chunk_method and pipeline_id are mutually exclusive. " + "Specify either chunk_method or pipeline_id, not both." + ) + + # If neither chunk_method nor pipeline_id is specified, default to naive + if "chunk_method" not in request_body and "pipeline_id" not in request_body: + request_body["chunk_method"] = "naive" + + return url, request_body + + def transform_create_vector_store_response( + self, response: httpx.Response + ) -> VectorStoreCreateResponse: + """ + Transform RAGFlow response to VectorStoreCreateResponse format. + + RAGFlow response format: + { + "code": 0, + "data": { + "id": "...", + "name": "...", + "create_time": 1745836841611, # milliseconds + ... + } + } + """ + try: + response_json = response.json() + + # Check for RAGFlow error response + if response_json.get("code") != 0: + error_message = response_json.get("message", "Unknown error") + raise self.get_error_class( + error_message=error_message, + status_code=response.status_code, + headers=response.headers, + ) + + data = response_json.get("data", {}) + + # Extract dataset ID + dataset_id = data.get("id") + if not dataset_id: + raise ValueError("RAGFlow response missing dataset id") + + # Extract name + name = data.get("name") + + # Convert create_time from milliseconds to seconds (Unix timestamp) + create_time_ms = data.get("create_time", 0) + created_at = int(create_time_ms / 1000) if create_time_ms else None + + # Build VectorStoreCreateResponse + return VectorStoreCreateResponse( + id=dataset_id, + object="vector_store", + created_at=created_at or 0, + name=name, + bytes=0, # RAGFlow doesn't provide bytes in response + file_counts=VectorStoreFileCounts( + in_progress=0, + completed=0, + failed=0, + cancelled=0, + total=0, + ), + status="completed", + expires_after=None, + expires_at=None, + last_active_at=None, + metadata=None, + ) + except Exception as e: + # If it's already a ValueError we raised, re-raise it + if isinstance(e, ValueError) and "RAGFlow response" in str(e): + raise + # If it's already our error class (has status_code), re-raise + if hasattr(e, "status_code"): + raise + # Otherwise, wrap in our error class + raise self.get_error_class( + error_message=str(e), + status_code=response.status_code, + headers=response.headers, + ) + diff --git a/litellm/types/vector_stores.py b/litellm/types/vector_stores.py index 6ae0b4bd2fd..a4ceb2c9ac7 100644 --- a/litellm/types/vector_stores.py +++ b/litellm/types/vector_stores.py @@ -3,17 +3,15 @@ from datetime import datetime from enum import Enum from typing import Any, Dict, List, Literal, Optional, Tuple, Union -from annotated_types import Ge from pydantic import BaseModel from typing_extensions import TypedDict -from litellm.types.router import CredentialLiteLLMParams, GenericLiteLLMParams - class SupportedVectorStoreIntegrations(str, Enum): """Supported vector store integrations.""" BEDROCK = "bedrock" + RAGFLOW = "ragflow" class LiteLLM_VectorStoreConfig(TypedDict, total=False): diff --git a/litellm/utils.py b/litellm/utils.py index 44d4b5c25fd..b77c0e62e7d 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -7633,6 +7633,12 @@ class ProviderConfigManager: ) return GeminiVectorStoreConfig() + elif litellm.LlmProviders.RAGFLOW == provider: + from litellm.llms.ragflow.vector_stores.transformation import ( + RAGFlowVectorStoreConfig, + ) + + return RAGFlowVectorStoreConfig() return None @staticmethod diff --git a/tests/test_litellm/vector_stores/test_vector_store_create_provider_logic.py b/tests/test_litellm/vector_stores/test_vector_store_create_provider_logic.py index 501a5ff0389..cca20847f12 100644 --- a/tests/test_litellm/vector_stores/test_vector_store_create_provider_logic.py +++ b/tests/test_litellm/vector_stores/test_vector_store_create_provider_logic.py @@ -9,9 +9,12 @@ sys.path.insert( ) # Adds the parent directory to the system path import litellm -from litellm.utils import ProviderConfigManager from litellm.llms.openai.vector_stores.transformation import OpenAIVectorStoreConfig -from litellm.llms.vertex_ai.vector_stores.rag_api.transformation import VertexVectorStoreConfig +from litellm.llms.ragflow.vector_stores.transformation import RAGFlowVectorStoreConfig +from litellm.llms.vertex_ai.vector_stores.rag_api.transformation import ( + VertexVectorStoreConfig, +) +from litellm.utils import ProviderConfigManager def test_vector_store_create_with_simple_provider_name(): @@ -100,3 +103,40 @@ def test_vector_store_create_with_provider_api_type(): print("✅ Test passed: Provider with api_type 'vertex_ai/rag_api' handled correctly") + +def test_vector_store_create_with_ragflow_provider(): + """ + Test that vector store create correctly handles RAGFlow provider. + + This should: + - Return correct RAGFlowVectorStoreConfig + - Support dataset management operations + """ + custom_llm_provider = "ragflow" + + # Simulate the logic from vector_stores/main.py create function + if "/" in custom_llm_provider: + pytest.fail("Should not enter this branch for RAGFlow provider") + else: + api_type = None + custom_llm_provider = custom_llm_provider # Keep as-is + + # Verify api_type is None + assert api_type is None, "api_type should be None for RAGFlow provider" + + # Verify custom_llm_provider is unchanged + assert custom_llm_provider == "ragflow", "custom_llm_provider should remain 'ragflow'" + + # Verify ProviderConfigManager returns correct config + vector_store_provider_config = ProviderConfigManager.get_provider_vector_stores_config( + provider=litellm.LlmProviders(custom_llm_provider), + api_type=api_type, + ) + + assert vector_store_provider_config is not None, "Should return a config for RAGFlow" + assert isinstance( + vector_store_provider_config, RAGFlowVectorStoreConfig + ), "Should return RAGFlowVectorStoreConfig for RAGFlow provider" + + print("✅ Test passed: RAGFlow provider handled correctly") + diff --git a/tests/vector_store_tests/test_ragflow_vector_store.py b/tests/vector_store_tests/test_ragflow_vector_store.py new file mode 100644 index 00000000000..0839bd7153b --- /dev/null +++ b/tests/vector_store_tests/test_ragflow_vector_store.py @@ -0,0 +1,359 @@ +""" +Test RAGFlow Vector Store helper functions and transformation. +""" +import os +import sys +import json +import pytest +from unittest.mock import Mock, patch, MagicMock +import httpx + +sys.path.insert(0, os.path.abspath("../..")) +import litellm + +from tests.vector_store_tests.base_vector_store_test import BaseVectorStoreTest +from litellm.llms.ragflow.vector_stores.transformation import RAGFlowVectorStoreConfig +from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj +from litellm.types.vector_stores import VectorStoreCreateOptionalRequestParams + + +class TestRAGFlowVectorStore(BaseVectorStoreTest): + """ + Test the RAGFlow vector store transformation functionality. + """ + + def get_base_create_vector_store_args(self) -> dict: + """Must return the base create vector store args""" + return { + "custom_llm_provider": "ragflow", + "api_key": os.getenv("RAGFLOW_API_KEY", "test-api-key"), + "api_base": os.getenv("RAGFLOW_API_BASE", "http://localhost:9380") + } + + def get_base_request_args(self): + # RAGFlow doesn't support search, so we'll skip search tests + return { + "vector_store_id": "test-dataset-id", + "custom_llm_provider": "ragflow", + "query": "test query" + } + + def test_get_auth_credentials(self): + """Test that auth credentials are correctly extracted.""" + config = RAGFlowVectorStoreConfig() + + # Test with api_key in params + litellm_params = {"api_key": "test-api-key-123"} + credentials = config.get_auth_credentials(litellm_params) + assert "headers" in credentials + assert credentials["headers"]["Authorization"] == "Bearer test-api-key-123" + + # Test with missing api_key (should raise ValueError) + with pytest.raises(ValueError, match="api_key is required"): + config.get_auth_credentials({}) + + def test_get_complete_url(self): + """Test that complete URL is correctly constructed.""" + config = RAGFlowVectorStoreConfig() + + # Test with api_base in params + litellm_params = {"api_base": "http://custom-host:9999"} + url = config.get_complete_url(api_base=None, litellm_params=litellm_params) + assert url == "http://custom-host:9999/api/v1/datasets" + + # Test with api_base parameter + url = config.get_complete_url(api_base="http://test-host:8888", litellm_params={}) + assert url == "http://test-host:8888/api/v1/datasets" + + # Test with default (no api_base provided) + with patch.dict(os.environ, {}, clear=True): + url = config.get_complete_url(api_base=None, litellm_params={}) + assert url == "http://localhost:9380/api/v1/datasets" + + # Test with trailing slash removal + url = config.get_complete_url(api_base="http://test-host:8888/", litellm_params={}) + assert url == "http://test-host:8888/api/v1/datasets" + + def test_validate_environment(self): + """Test environment validation and header setting.""" + config = RAGFlowVectorStoreConfig() + from litellm.types.router import GenericLiteLLMParams + + # Test with api_key in litellm_params + litellm_params = GenericLiteLLMParams(api_key="test-key") + headers = config.validate_environment({}, litellm_params) + assert headers["Authorization"] == "Bearer test-key" + assert headers["Content-Type"] == "application/json" + + # Test with missing api_key + with pytest.raises(ValueError, match="RAGFLOW_API_KEY"): + config.validate_environment({}, GenericLiteLLMParams()) + + def test_get_vector_store_endpoints_by_type(self): + """Test that endpoints are correctly configured (empty for management only).""" + config = RAGFlowVectorStoreConfig() + endpoints = config.get_vector_store_endpoints_by_type() + assert endpoints["read"] == [] + assert endpoints["write"] == [] + + def test_transform_create_vector_store_request_basic(self): + """Test basic dataset creation request transformation.""" + config = RAGFlowVectorStoreConfig() + + params: VectorStoreCreateOptionalRequestParams = { + "name": "test-dataset" + } + + url, body = config.transform_create_vector_store_request( + params, "http://localhost:9380/api/v1/datasets" + ) + + assert url == "http://localhost:9380/api/v1/datasets" + assert body["name"] == "test-dataset" + assert body["chunk_method"] == "naive" # Default chunk method + + def test_transform_create_vector_store_request_with_metadata(self): + """Test dataset creation with RAGFlow-specific metadata.""" + config = RAGFlowVectorStoreConfig() + + params: VectorStoreCreateOptionalRequestParams = { + "name": "test-dataset-advanced", + "metadata": { + "description": "Test dataset", + "embedding_model": "BAAI/bge-large-zh-v1.5@BAAI", + "permission": "me", + "chunk_method": "naive", + "parser_config": { + "chunk_token_num": 512, + "delimiter": "\n" + } + } + } + + url, body = config.transform_create_vector_store_request( + params, "http://localhost:9380/api/v1/datasets" + ) + + assert body["name"] == "test-dataset-advanced" + assert body["description"] == "Test dataset" + assert body["embedding_model"] == "BAAI/bge-large-zh-v1.5@BAAI" + assert body["permission"] == "me" + assert body["chunk_method"] == "naive" + assert "parser_config" in body + assert body["parser_config"]["chunk_token_num"] == 512 + + def test_transform_create_vector_store_request_missing_name(self): + """Test that missing name raises ValueError.""" + config = RAGFlowVectorStoreConfig() + + params: VectorStoreCreateOptionalRequestParams = {} + + with pytest.raises(ValueError, match="name is required"): + config.transform_create_vector_store_request( + params, "http://localhost:9380/api/v1/datasets" + ) + + def test_transform_create_vector_store_request_mutually_exclusive(self): + """Test that chunk_method and pipeline_id are mutually exclusive.""" + config = RAGFlowVectorStoreConfig() + + params: VectorStoreCreateOptionalRequestParams = { + "name": "test-dataset", + "metadata": { + "chunk_method": "naive", + "pipeline_id": "d0bebe30ae2211f0970942010a8e0005" + } + } + + with pytest.raises(ValueError, match="mutually exclusive"): + config.transform_create_vector_store_request( + params, "http://localhost:9380/api/v1/datasets" + ) + + def test_transform_create_vector_store_request_with_pipeline(self): + """Test dataset creation with ingestion pipeline.""" + config = RAGFlowVectorStoreConfig() + + params: VectorStoreCreateOptionalRequestParams = { + "name": "test-pipeline-dataset", + "metadata": { + "parse_type": 2, + "pipeline_id": "d0bebe30ae2211f0970942010a8e0005" + } + } + + url, body = config.transform_create_vector_store_request( + params, "http://localhost:9380/api/v1/datasets" + ) + + assert body["name"] == "test-pipeline-dataset" + assert body["parse_type"] == 2 + assert body["pipeline_id"] == "d0bebe30ae2211f0970942010a8e0005" + assert "chunk_method" not in body + + def test_transform_create_vector_store_response_success(self): + """Test successful response transformation.""" + config = RAGFlowVectorStoreConfig() + + # Mock RAGFlow response + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.headers = {} + mock_response.json.return_value = { + "code": 0, + "data": { + "id": "3b4de7d4241d11f0a6a79f24fc270c7f", + "name": "test-dataset", + "create_time": 1745836841611, + "chunk_method": "naive", + "embedding_model": "BAAI/bge-large-zh-v1.5@BAAI" + } + } + + response = config.transform_create_vector_store_response(mock_response) + + assert response["id"] == "3b4de7d4241d11f0a6a79f24fc270c7f" + assert response["name"] == "test-dataset" + assert response["object"] == "vector_store" + assert response["status"] == "completed" + assert response["created_at"] == 1745836841 # Converted from milliseconds + assert response["bytes"] == 0 + assert "file_counts" in response + + def test_transform_create_vector_store_response_error(self): + """Test error response transformation.""" + config = RAGFlowVectorStoreConfig() + + # Mock RAGFlow error response + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 400 + mock_response.headers = {} + mock_response.json.return_value = { + "code": 101, + "message": "Dataset name 'test-dataset' already exists" + } + + with pytest.raises(Exception): # Should raise BaseLLMException + config.transform_create_vector_store_response(mock_response) + + def test_transform_create_vector_store_response_missing_id(self): + """Test response with missing dataset ID.""" + config = RAGFlowVectorStoreConfig() + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.headers = {} + mock_response.json.return_value = { + "code": 0, + "data": { + "name": "test-dataset" + # Missing "id" + } + } + + with pytest.raises(ValueError, match="missing dataset id"): + config.transform_create_vector_store_response(mock_response) + + def test_transform_search_vector_store_request_not_implemented(self): + """Test that search operations raise NotImplementedError.""" + config = RAGFlowVectorStoreConfig() + logging_obj = MagicMock(spec=LiteLLMLoggingObj) + + with pytest.raises(NotImplementedError, match="management only"): + config.transform_search_vector_store_request( + vector_store_id="test-id", + query="test query", + vector_store_search_optional_params={}, + api_base="http://localhost:9380", + litellm_logging_obj=logging_obj, + litellm_params={} + ) + + def test_transform_search_vector_store_response_not_implemented(self): + """Test that search response transformation raises NotImplementedError.""" + config = RAGFlowVectorStoreConfig() + logging_obj = MagicMock(spec=LiteLLMLoggingObj) + mock_response = Mock(spec=httpx.Response) + + with pytest.raises(NotImplementedError, match="management only"): + config.transform_search_vector_store_response(mock_response, logging_obj) + + def _validate_vector_store_create_response(self, response): + """Override to handle RAGFlow-specific response format.""" + # RAGFlow IDs are hex strings (not OpenAI-style vs_* format) + # So we override the base validation to not check for vs_ prefix + assert isinstance(response, dict), f"Response should be a dict, got {type(response)}" + assert "id" in response, "Missing required field 'id' in create response" + assert "object" in response, "Missing required field 'object' in create response" + assert "created_at" in response, "Missing required field 'created_at' in create response" + + assert response["object"] == "vector_store", \ + f"Expected object to be 'vector_store', got '{response['object']}'" + + assert isinstance(response["id"], str), \ + f"id should be a string, got {type(response['id'])}" + assert len(response["id"]) > 0, "id should not be empty" + # RAGFlow IDs are hex strings, not OpenAI-style vs_* format + + assert isinstance(response["created_at"], int), \ + f"created_at should be an integer, got {type(response['created_at'])}" + assert response["created_at"] > 0, "created_at should be a positive timestamp" + + print(f"✅ RAGFlow create response validation passed: Dataset '{response['id']}' created successfully") + + @pytest.mark.parametrize("sync_mode", [True, False]) + @pytest.mark.asyncio + async def test_basic_create_vector_store(self, sync_mode): + """Override to handle RAGFlow-specific connection errors.""" + litellm._turn_on_debug() + litellm.set_verbose = True + base_request_args = self.get_base_create_vector_store_args() + + # Skip if no API key is set + if not os.getenv("RAGFLOW_API_KEY") and not base_request_args.get("api_key"): + pytest.skip("RAGFLOW_API_KEY not set, skipping integration test") + + # Extract custom_llm_provider from base args if present + create_args = base_request_args + try: + if sync_mode: + response = litellm.vector_stores.create( + name=f"test-ragflow-{int(__import__('time').time())}", + **create_args + ) + else: + response = await litellm.vector_stores.acreate( + name=f"test-ragflow-{int(__import__('time').time())}", + **create_args + ) + except litellm.InternalServerError: + pytest.skip("Skipping test due to litellm.InternalServerError") + except Exception as e: + error_str = str(e).lower() + error_type = type(e).__name__ + + # Check if it's a connection error + if (isinstance(e, (ConnectionError, OSError)) or + "connection" in error_str or + "connect" in error_str or + "APIConnectionError" in error_type): + pytest.skip(f"Skipping test due to connection error (RAGFlow instance may not be running): {e}") + + # If this is an authentication or permission error, skip the test + if "authentication" in error_str or "permission" in error_str or "unauthorized" in error_str: + pytest.skip(f"Skipping test due to authentication/permission error: {e}") + + # Re-raise if it's not a handled error + raise + + print("litellm create response=", json.dumps(response, indent=4, default=str)) + + # Validate response structure + self._validate_vector_store_create_response(response) + + @pytest.mark.parametrize("sync_mode", [True, False]) + @pytest.mark.asyncio + async def test_basic_search_vector_store(self, sync_mode): + """Override search test - RAGFlow doesn't support search.""" + pytest.skip("RAGFlow vector stores support dataset management only, not search") + From dad0b2c1117d3c2624d27dd229f7faa8babfbb41 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Wed, 3 Dec 2025 15:32:42 +0530 Subject: [PATCH 34/37] Fix unused imports --- litellm/llms/ragflow/chat/transformation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/llms/ragflow/chat/transformation.py b/litellm/llms/ragflow/chat/transformation.py index d33a1593be8..58fbfa83c98 100644 --- a/litellm/llms/ragflow/chat/transformation.py +++ b/litellm/llms/ragflow/chat/transformation.py @@ -10,7 +10,7 @@ Model name format: - Agent: ragflow/agent/{agent_id}/{model_name} """ -from typing import Any, List, Optional, Tuple +from typing import List, Optional, Tuple import litellm from litellm.llms.openai.openai import OpenAIConfig From c9c7823f43b072a62ca2cd80bb9fc33f309dd1b1 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Wed, 3 Dec 2025 17:31:20 +0530 Subject: [PATCH 35/37] Fix bedrock models in model map --- ...odel_prices_and_context_window_backup.json | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 0fc97ce7b0a..dbaa60e0f1d 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -9629,6 +9629,21 @@ "supports_prompt_caching": true, "supports_tool_choice": true }, + "deepseek/deepseek-v3.2": { + "input_cost_per_token": 2.8e-07, + "input_cost_per_token_cache_hit": 2.8e-08, + "litellm_provider": "deepseek", + "max_input_tokens": 163840, + "max_output_tokens": 163840, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 4e-07, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, "deepseek.v3-v1:0": { "input_cost_per_token": 5.8e-07, "litellm_provider": "bedrock_converse", @@ -20637,6 +20652,21 @@ "supports_reasoning": true, "supports_tool_choice": true }, + "openrouter/deepseek/deepseek-v3.2": { + "input_cost_per_token": 2.8e-07, + "input_cost_per_token_cache_hit": 2.8e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 163840, + "max_output_tokens": 163840, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 4e-07, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, "openrouter/deepseek/deepseek-v3.2-exp": { "input_cost_per_token": 2e-07, "input_cost_per_token_cache_hit": 2e-08, @@ -23892,6 +23922,32 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 159 }, + "global.anthropic.claude-opus-4-5-20251101-v1:0": { + "cache_creation_input_token_cost": 6.25e-06, + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 5e-06, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 2.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "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, + "tool_use_system_prompt_tokens": 159 + }, "us.anthropic.claude-sonnet-4-20250514-v1:0": { "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, From be5dd234bfe46f28bdfced4333f4df7562236c64 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Wed, 3 Dec 2025 08:01:26 -0800 Subject: [PATCH 36/37] docs: fix list --- docs/my-website/docs/providers/bedrock.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/my-website/docs/providers/bedrock.md b/docs/my-website/docs/providers/bedrock.md index a9ac85a7571..7fc7758b15c 100644 --- a/docs/my-website/docs/providers/bedrock.md +++ b/docs/my-website/docs/providers/bedrock.md @@ -43,6 +43,8 @@ export AWS_BEARER_TOKEN_BEDROCK="your-api-key" Option 2: use the api_key parameter to pass in API key for completion, embedding, image_generation API calls. + + ```python response = completion( model="bedrock/anthropic.claude-3-sonnet-20240229-v1:0", @@ -50,7 +52,17 @@ response = completion( api_key="your-api-key" ) ``` - + + +```yaml +model_list: + - model_name: bedrock-claude-3-sonnet + litellm_params: + model: bedrock/anthropic.claude-3-sonnet-20240229-v1:0 + api_key: os.environ/AWS_BEARER_TOKEN_BEDROCK +``` + + ## Usage From 5e791464afb6eb2b6606d20cf23bdb87dd02e10d Mon Sep 17 00:00:00 2001 From: Cesar Garcia <128240629+Chesars@users.noreply.github.com> Date: Wed, 3 Dec 2025 14:07:02 -0300 Subject: [PATCH 37/37] docs: add Microsoft Agent Lightning to projects (#17422) Add Agent Lightning, Microsoft's open-source framework for training AI agents with RL, APO, and SFT. Uses LiteLLM Proxy for LLM routing and trace collection. --- docs/my-website/docs/projects/Agent Lightning.md | 10 ++++++++++ docs/my-website/sidebars.js | 1 + 2 files changed, 11 insertions(+) create mode 100644 docs/my-website/docs/projects/Agent Lightning.md diff --git a/docs/my-website/docs/projects/Agent Lightning.md b/docs/my-website/docs/projects/Agent Lightning.md new file mode 100644 index 00000000000..28e5546e398 --- /dev/null +++ b/docs/my-website/docs/projects/Agent Lightning.md @@ -0,0 +1,10 @@ + +# Agent Lightning + +[Agent Lightning](https://github.com/microsoft/agent-lightning) is Microsoft's open-source framework for training and optimizing AI agents with Reinforcement Learning, Automatic Prompt Optimization, and Supervised Fine-tuning — with almost zero code changes. + +It works with any agent framework including LangChain, OpenAI Agents SDK, AutoGen, and CrewAI. Agent Lightning uses LiteLLM Proxy under the hood to route LLM requests and collect traces that power its training algorithms. + +- [GitHub](https://github.com/microsoft/agent-lightning) +- [Docs](https://microsoft.github.io/agent-lightning/) +- [arXiv Paper](https://arxiv.org/abs/2508.03680) diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index e2f682f0a50..4a0f592c422 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -826,6 +826,7 @@ const sidebars = { "projects/mini-swe-agent", "projects/openai-agents", "projects/Google ADK", + "projects/Agent Lightning", "projects/Harbor", "projects/Docq.AI", "projects/PDL",