diff --git a/litellm/proxy/management_endpoints/cost_tracking_settings.py b/litellm/proxy/management_endpoints/cost_tracking_settings.py new file mode 100644 index 00000000000..328dafc80db --- /dev/null +++ b/litellm/proxy/management_endpoints/cost_tracking_settings.py @@ -0,0 +1,165 @@ +""" +COST TRACKING SETTINGS MANAGEMENT + +Endpoints for managing cost discount configuration + +GET /config/cost_discount_config - Get current cost discount configuration +PATCH /config/cost_discount_config - Update cost discount configuration +""" + +from typing import Dict + +from fastapi import APIRouter, Depends, HTTPException + +import litellm +from litellm._logging import verbose_proxy_logger +from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.types.utils import LlmProvidersSet + +router = APIRouter() + + +@router.get( + "/config/cost_discount_config", + tags=["Cost Tracking"], + dependencies=[Depends(user_api_key_auth)], +) +async def get_cost_discount_config( + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + Get current cost discount configuration. + + Returns the cost_discount_config from litellm_settings. + """ + from litellm.proxy.proxy_server import prisma_client, proxy_config + + if prisma_client is None: + raise HTTPException( + status_code=500, + detail={"error": CommonProxyErrors.db_not_connected_error.value}, + ) + + try: + # Load config from DB + config = await proxy_config.get_config() + + # Get cost_discount_config from litellm_settings + litellm_settings = config.get("litellm_settings", {}) + cost_discount_config = litellm_settings.get("cost_discount_config", {}) + + return {"values": cost_discount_config} + except Exception as e: + verbose_proxy_logger.error( + f"Error fetching cost discount config: {str(e)}" + ) + return {"values": {}} + + +@router.patch( + "/config/cost_discount_config", + tags=["Cost Tracking"], + dependencies=[Depends(user_api_key_auth)], +) +async def update_cost_discount_config( + cost_discount_config: Dict[str, float], + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + Update cost discount configuration. + + Updates the cost_discount_config in litellm_settings. + Discounts should be between 0 and 1 (e.g., 0.05 = 5% discount). + + Example: + ```json + { + "vertex_ai": 0.05, + "gemini": 0.05, + "openai": 0.01 + } + ``` + """ + from litellm.proxy.proxy_server import ( + prisma_client, + proxy_config, + store_model_in_db, + ) + + if prisma_client is None: + raise HTTPException( + status_code=500, + detail={"error": CommonProxyErrors.db_not_connected_error.value}, + ) + + if store_model_in_db is not True: + raise HTTPException( + status_code=500, + detail={ + "error": "Set `'STORE_MODEL_IN_DB='True'` in your env to enable this feature." + }, + ) + + # Validate that all providers are valid LiteLLM providers + invalid_providers = [] + for provider in cost_discount_config.keys(): + if provider not in LlmProvidersSet: + invalid_providers.append(provider) + + if invalid_providers: + raise HTTPException( + status_code=400, + detail={ + "error": f"Invalid provider(s): {', '.join(invalid_providers)}. Must be valid LiteLLM providers. See https://docs.litellm.ai/docs/providers for the full list." + }, + ) + + # Validate discount values are between 0 and 1 + for provider, discount in cost_discount_config.items(): + if not isinstance(discount, (int, float)): + raise HTTPException( + status_code=400, + detail=f"Discount for {provider} must be a number" + ) + if not (0 <= discount <= 1): + raise HTTPException( + status_code=400, + detail=f"Discount for {provider} must be between 0 and 1 (0% to 100%)" + ) + + try: + # Load existing config + config = await proxy_config.get_config() + + # Ensure litellm_settings exists + if "litellm_settings" not in config: + config["litellm_settings"] = {} + + # Update cost_discount_config + config["litellm_settings"]["cost_discount_config"] = cost_discount_config + + # Save the updated config to DB + await proxy_config.save_config(new_config=config) + + # Update in-memory litellm.cost_discount_config + litellm.cost_discount_config = cost_discount_config + + verbose_proxy_logger.info( + f"Updated cost_discount_config: {cost_discount_config}" + ) + + return { + "message": "Cost discount configuration updated successfully", + "status": "success", + "values": cost_discount_config + } + except Exception as e: + verbose_proxy_logger.error( + f"Error updating cost discount config: {str(e)}" + ) + raise HTTPException( + status_code=500, + detail={"error": f"Failed to update cost discount config: {str(e)}"} + ) + diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 8f5a046be03..1106e0ed12f 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -253,13 +253,18 @@ from litellm.proxy.management_endpoints.callback_management_endpoints import ( router as callback_management_endpoints_router, ) from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view +from litellm.proxy.management_endpoints.cost_tracking_settings import ( + router as cost_tracking_settings_router, +) from litellm.proxy.management_endpoints.customer_endpoints import ( router as customer_router, ) from litellm.proxy.management_endpoints.internal_user_endpoints import ( router as internal_user_router, ) -from litellm.proxy.management_endpoints.internal_user_endpoints import user_update +from litellm.proxy.management_endpoints.internal_user_endpoints import ( + user_update, +) from litellm.proxy.management_endpoints.key_management_endpoints import ( delete_verification_tokens, duration_in_seconds, @@ -306,7 +311,9 @@ from litellm.proxy.middleware.prometheus_auth_middleware import PrometheusAuthMi from litellm.proxy.openai_files_endpoints.files_endpoints import ( router as openai_files_router, ) -from litellm.proxy.openai_files_endpoints.files_endpoints import set_files_config +from litellm.proxy.openai_files_endpoints.files_endpoints import ( + set_files_config, +) from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( passthrough_endpoint_router, ) @@ -9799,6 +9806,7 @@ app.include_router(team_callback_router) app.include_router(budget_management_router) app.include_router(model_management_router) app.include_router(tag_management_router) +app.include_router(cost_tracking_settings_router) app.include_router(user_agent_analytics_router) app.include_router(enterprise_router) app.include_router(ui_discovery_endpoints_router) diff --git a/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py b/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py new file mode 100644 index 00000000000..275240dcc9e --- /dev/null +++ b/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py @@ -0,0 +1,272 @@ +""" +Tests for cost tracking settings management endpoints. + +Tests the GET and PATCH endpoints for managing cost discount configuration. +""" +import os +import sys +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +sys.path.insert( + 0, os.path.abspath("../../../..") +) + +import litellm +from litellm.proxy.management_endpoints.cost_tracking_settings import router +from litellm.proxy.proxy_server import app + +client = TestClient(app) + + +class TestCostTrackingSettings: + """Test suite for cost tracking settings endpoints""" + + @pytest.mark.asyncio + async def test_get_cost_discount_config_success(self): + """ + Test GET /config/cost_discount_config endpoint successfully retrieves configuration. + """ + # Mock the proxy_config to return a config with cost_discount_config + mock_proxy_config = AsyncMock() + mock_proxy_config.get_config = AsyncMock( + return_value={ + "litellm_settings": { + "cost_discount_config": { + "vertex_ai": 0.05, + "gemini": 0.05, + "openai": 0.01, + } + } + } + ) + + mock_prisma_client = MagicMock() + + with patch( + "litellm.proxy.proxy_server.prisma_client", + mock_prisma_client, + ), patch( + "litellm.proxy.proxy_server.proxy_config", + mock_proxy_config, + ): + # Make request + response = client.get( + "/config/cost_discount_config", + headers={"Authorization": "Bearer sk-1234"}, + ) + + # Verify response + assert response.status_code == 200 + response_data = response.json() + + assert "values" in response_data + assert response_data["values"]["vertex_ai"] == 0.05 + assert response_data["values"]["gemini"] == 0.05 + assert response_data["values"]["openai"] == 0.01 + + # Verify get_config was called + mock_proxy_config.get_config.assert_called_once() + + @pytest.mark.asyncio + async def test_get_cost_discount_config_empty(self): + """ + Test GET /config/cost_discount_config endpoint returns empty config when not set. + """ + # Mock the proxy_config to return a config without cost_discount_config + mock_proxy_config = AsyncMock() + mock_proxy_config.get_config = AsyncMock( + return_value={"litellm_settings": {}} + ) + + mock_prisma_client = MagicMock() + + with patch( + "litellm.proxy.proxy_server.prisma_client", + mock_prisma_client, + ), patch( + "litellm.proxy.proxy_server.proxy_config", + mock_proxy_config, + ): + # Make request + response = client.get( + "/config/cost_discount_config", + headers={"Authorization": "Bearer sk-1234"}, + ) + + # Verify response + assert response.status_code == 200 + response_data = response.json() + + assert "values" in response_data + assert response_data["values"] == {} + + @pytest.mark.asyncio + async def test_update_cost_discount_config_success(self): + """ + Test PATCH /config/cost_discount_config endpoint successfully updates configuration. + """ + # Mock the proxy_config + mock_proxy_config = AsyncMock() + mock_proxy_config.get_config = AsyncMock( + return_value={"litellm_settings": {}} + ) + mock_proxy_config.save_config = AsyncMock() + + mock_prisma_client = MagicMock() + mock_store_model_in_db = True + + # Test data + test_discount_config = { + "vertex_ai": 0.05, + "gemini": 0.05, + "openai": 0.01, + } + + with patch( + "litellm.proxy.proxy_server.prisma_client", + mock_prisma_client, + ), patch( + "litellm.proxy.proxy_server.proxy_config", + mock_proxy_config, + ), patch( + "litellm.proxy.proxy_server.store_model_in_db", + mock_store_model_in_db, + ), patch.object(litellm, "cost_discount_config", {}): + # Make request + response = client.patch( + "/config/cost_discount_config", + json=test_discount_config, + headers={"Authorization": "Bearer sk-1234"}, + ) + + # Verify response + assert response.status_code == 200 + response_data = response.json() + + assert response_data["status"] == "success" + assert "message" in response_data + assert "values" in response_data + assert response_data["values"]["vertex_ai"] == 0.05 + assert response_data["values"]["gemini"] == 0.05 + assert response_data["values"]["openai"] == 0.01 + + # Verify config was saved + mock_proxy_config.save_config.assert_called_once() + + # Verify litellm.cost_discount_config was updated + assert litellm.cost_discount_config == test_discount_config + + @pytest.mark.asyncio + async def test_update_cost_discount_config_invalid_provider(self): + """ + Test PATCH /config/cost_discount_config endpoint rejects invalid provider names. + """ + mock_proxy_config = AsyncMock() + mock_prisma_client = MagicMock() + mock_store_model_in_db = True + + # Test data with invalid provider + test_discount_config = { + "invalid_provider": 0.05, + "openai": 0.01, + } + + with patch( + "litellm.proxy.proxy_server.prisma_client", + mock_prisma_client, + ), patch( + "litellm.proxy.proxy_server.proxy_config", + mock_proxy_config, + ), patch( + "litellm.proxy.proxy_server.store_model_in_db", + mock_store_model_in_db, + ): + # Make request + response = client.patch( + "/config/cost_discount_config", + json=test_discount_config, + headers={"Authorization": "Bearer sk-1234"}, + ) + + # Verify response - should fail with 400 + assert response.status_code == 400 + response_data = response.json() + assert "error" in response_data["detail"] + assert "invalid_provider" in response_data["detail"]["error"] + + @pytest.mark.asyncio + async def test_update_cost_discount_config_invalid_discount_value(self): + """ + Test PATCH /config/cost_discount_config endpoint rejects discount values outside 0-1 range. + """ + mock_proxy_config = AsyncMock() + mock_prisma_client = MagicMock() + mock_store_model_in_db = True + + # Test data with invalid discount value (> 1) + test_discount_config = { + "openai": 1.5, # Invalid: greater than 1 + } + + with patch( + "litellm.proxy.proxy_server.prisma_client", + mock_prisma_client, + ), patch( + "litellm.proxy.proxy_server.proxy_config", + mock_proxy_config, + ), patch( + "litellm.proxy.proxy_server.store_model_in_db", + mock_store_model_in_db, + ): + # Make request + response = client.patch( + "/config/cost_discount_config", + json=test_discount_config, + headers={"Authorization": "Bearer sk-1234"}, + ) + + # Verify response - should fail with 400 + assert response.status_code == 400 + response_data = response.json() + assert "detail" in response_data + assert "between 0 and 1" in response_data["detail"] + + @pytest.mark.asyncio + async def test_update_cost_discount_config_no_store_model_in_db(self): + """ + Test PATCH /config/cost_discount_config endpoint fails when STORE_MODEL_IN_DB is not enabled. + """ + mock_proxy_config = AsyncMock() + mock_prisma_client = MagicMock() + mock_store_model_in_db = False # Not enabled + + test_discount_config = { + "openai": 0.05, + } + + with patch( + "litellm.proxy.proxy_server.prisma_client", + mock_prisma_client, + ), patch( + "litellm.proxy.proxy_server.proxy_config", + mock_proxy_config, + ), patch( + "litellm.proxy.proxy_server.store_model_in_db", + mock_store_model_in_db, + ): + # Make request + response = client.patch( + "/config/cost_discount_config", + json=test_discount_config, + headers={"Authorization": "Bearer sk-1234"}, + ) + + # Verify response - should fail with 500 + assert response.status_code == 500 + response_data = response.json() + assert "error" in response_data["detail"] + assert "STORE_MODEL_IN_DB" in response_data["detail"]["error"] + diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index 32996fea769..1860ffcb60d 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -35,6 +35,7 @@ import { MCPServers } from "@/components/mcp_tools"; import TagManagement from "@/components/tag_management"; import VectorStoreManagement from "@/components/vector_store_management"; import UIThemeSettings from "@/components/ui_theme_settings"; +import { CostTrackingSettings } from "@/components/CostTrackingSettings"; import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { cx } from "@/lib/cva.config"; import useFeatureFlags from "@/hooks/useFeatureFlags"; @@ -426,6 +427,8 @@ export default function CreateKeyPage() { /> ) : page == "ui-theme" ? ( + ) : page == "cost-tracking-settings" ? ( + ) : page == "model-hub-table" ? ( void; + onDiscountChange: (discount: string) => void; + onAddProvider: () => void; +} + +const AddProviderForm: React.FC = ({ + discountConfig, + selectedProvider, + newDiscount, + onProviderChange, + onDiscountChange, + onAddProvider, +}) => { + return ( +
+ + Provider + + + + + } + rules={[{ required: true, message: "Please select a provider" }]} + > + + String(option?.label ?? "").toLowerCase().includes(input.toLowerCase()) + } + > + {Object.entries(Providers).map(([providerEnum, providerDisplayName]) => { + const providerValue = provider_map[providerEnum as keyof typeof provider_map]; + // Only show providers that don't already have a discount configured + if (providerValue && discountConfig[providerValue]) { + return null; + } + return ( + +
+ {`${providerEnum} handleImageError(e, providerDisplayName)} + /> + {providerDisplayName} +
+
+ ); + })} +
+
+ + + Discount Percentage + + + + + } + rules={[{ required: true, message: "Please enter a discount percentage" }]} + > +
+ + % +
+
+ +
+ +
+
+ ); +}; + +export default AddProviderForm; + diff --git a/ui/litellm-dashboard/src/components/CostTrackingSettings/cost_tracking_settings.tsx b/ui/litellm-dashboard/src/components/CostTrackingSettings/cost_tracking_settings.tsx new file mode 100644 index 00000000000..3c0b7298601 --- /dev/null +++ b/ui/litellm-dashboard/src/components/CostTrackingSettings/cost_tracking_settings.tsx @@ -0,0 +1,301 @@ +import React, { useState, useEffect } from "react"; +import { Card, Title, Text, Subtitle, Grid, Col, Button, TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react"; +import { Modal, Form } from "antd"; +import { getProxyBaseUrl } from "@/components/networking"; +import NotificationsManager from "../molecules/notifications_manager"; +import { Providers, provider_map } from "../provider_info_helpers"; +import { CostTrackingSettingsProps, DiscountConfig } from "./types"; +import { getProviderBackendValue } from "./provider_display_helpers"; +import ProviderDiscountTable from "./provider_discount_table"; +import AddProviderForm from "./add_provider_form"; +import { ExclamationCircleOutlined } from "@ant-design/icons"; +import { DocsMenu } from "../HelpLink"; +import HowItWorks from "./how_it_works"; + +const DOCS_LINKS = [ + { label: "Custom pricing for models", href: "https://docs.litellm.ai/docs/proxy/custom_pricing" }, + { label: "Spend tracking", href: "https://docs.litellm.ai/docs/proxy/cost_tracking" }, +]; + +const CostTrackingSettings: React.FC = ({ + userID, + userRole, + accessToken +}) => { + const [discountConfig, setDiscountConfig] = useState({}); + const [selectedProvider, setSelectedProvider] = useState(undefined); + const [newDiscount, setNewDiscount] = useState(""); + const [isFetching, setIsFetching] = useState(true); + const [isModalVisible, setIsModalVisible] = useState(false); + const [form] = Form.useForm(); + const [modal, contextHolder] = Modal.useModal(); + + useEffect(() => { + if (accessToken) { + fetchDiscountConfig(); + } + }, [accessToken]); + + const fetchDiscountConfig = async () => { + setIsFetching(true); + try { + const proxyBaseUrl = getProxyBaseUrl(); + const url = proxyBaseUrl + ? `${proxyBaseUrl}/config/cost_discount_config` + : "/config/cost_discount_config"; + + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (response.ok) { + const data = await response.json(); + setDiscountConfig(data.values || {}); + } else { + console.error("Failed to fetch discount config"); + } + } catch (error) { + console.error("Error fetching discount config:", error); + NotificationsManager.fromBackend("Failed to fetch discount configuration"); + } finally { + setIsFetching(false); + } + }; + + const saveDiscountConfig = async (config: DiscountConfig) => { + try { + const proxyBaseUrl = getProxyBaseUrl(); + const url = proxyBaseUrl + ? `${proxyBaseUrl}/config/cost_discount_config` + : "/config/cost_discount_config"; + + const response = await fetch(url, { + method: "PATCH", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(config), + }); + + if (response.ok) { + NotificationsManager.success("Discount configuration updated successfully"); + await fetchDiscountConfig(); + } else { + const errorData = await response.json(); + const errorMessage = errorData.detail?.error || errorData.detail || "Failed to update settings"; + NotificationsManager.fromBackend(errorMessage); + } + } catch (error) { + console.error("Error updating discount config:", error); + NotificationsManager.fromBackend("Failed to update discount configuration"); + } + }; + + const handleAddProvider = async () => { + if (!selectedProvider || !newDiscount) { + NotificationsManager.fromBackend("Please select a provider and enter discount percentage"); + return; + } + + const percentageValue = parseFloat(newDiscount); + if (isNaN(percentageValue) || percentageValue < 0 || percentageValue > 100) { + NotificationsManager.fromBackend("Discount must be between 0% and 100%"); + return; + } + + const providerValue = getProviderBackendValue(selectedProvider); + + if (!providerValue) { + NotificationsManager.fromBackend("Invalid provider selected"); + return; + } + + if (discountConfig[providerValue]) { + NotificationsManager.fromBackend( + `Discount for ${Providers[selectedProvider as keyof typeof Providers]} already exists. Edit it in the table above.` + ); + return; + } + + // Convert percentage to decimal for storage + const discountValue = percentageValue / 100; + const updatedConfig = { + ...discountConfig, + [providerValue]: discountValue, + }; + + setDiscountConfig(updatedConfig); + await saveDiscountConfig(updatedConfig); + setSelectedProvider(undefined); + setNewDiscount(""); + setIsModalVisible(false); + }; + + const handleModalCancel = () => { + setIsModalVisible(false); + form.resetFields(); + setSelectedProvider(undefined); + setNewDiscount(""); + }; + + const handleFormSubmit = (values: any) => { + handleAddProvider(); + }; + + const handleRemoveProvider = async (provider: string, providerDisplayName: string) => { + modal.confirm({ + title: 'Remove Provider Discount', + icon: , + content: `Are you sure you want to remove the discount for ${providerDisplayName}?`, + okText: 'Remove', + okType: 'danger', + cancelText: 'Cancel', + onOk: async () => { + const updatedConfig = { ...discountConfig }; + delete updatedConfig[provider]; + setDiscountConfig(updatedConfig); + await saveDiscountConfig(updatedConfig); + }, + }); + }; + + const handleDiscountChange = async (provider: string, value: string) => { + const discountValue = parseFloat(value); + if (!isNaN(discountValue) && discountValue >= 0 && discountValue <= 1) { + const updatedConfig = { + ...discountConfig, + [provider]: discountValue, + }; + setDiscountConfig(updatedConfig); + await saveDiscountConfig(updatedConfig); + } + }; + + if (!accessToken) { + return null; + } + + return ( +
+ {contextHolder} + + {/* Header Section - Outside the card */} +
+
+
+ Cost Tracking Settings + +
+ + Configure cost discounts for different LLM providers. Changes are saved automatically. + +
+ +
+ + {/* Main Content Card with Tabs */} +
+ + + Provider Discounts + Test It + + + + {isFetching ? ( +
+ Loading configuration... +
+ ) : Object.keys(discountConfig).length > 0 ? ( +
+ +
+ ) : ( +
+ + + + + No provider discounts configured + + + Click "Add Provider Discount" to get started + +
+ )} +
+ +
+ +
+
+
+
+
+ + +

Add Provider Discount

+
+ } + open={isModalVisible} + width={1000} + onCancel={handleModalCancel} + footer={null} + className="top-8" + styles={{ + body: { padding: "24px" }, + header: { padding: "24px 24px 0 24px", border: "none" }, + }} + > +
+ + Select a provider and set its discount percentage. Enter a value between 0% and 100% (e.g., 5 for a 5% discount). + +
+ + +
+ + + ); +}; + +export default CostTrackingSettings; diff --git a/ui/litellm-dashboard/src/components/CostTrackingSettings/how_it_works.tsx b/ui/litellm-dashboard/src/components/CostTrackingSettings/how_it_works.tsx new file mode 100644 index 00000000000..d213e679d2c --- /dev/null +++ b/ui/litellm-dashboard/src/components/CostTrackingSettings/how_it_works.tsx @@ -0,0 +1,148 @@ +import React, { useState, useMemo } from "react"; +import { Title, Text, TextInput } from "@tremor/react"; +import CodeBlock from "@/app/(dashboard)/api-reference/components/CodeBlock"; + +const HowItWorks: React.FC = () => { + const [responseCost, setResponseCost] = useState(""); + const [discountAmount, setDiscountAmount] = useState(""); + + const calculatedDiscount = useMemo(() => { + const cost = parseFloat(responseCost); + const discount = parseFloat(discountAmount); + + if (isNaN(cost) || isNaN(discount) || cost === 0 || discount === 0) { + return null; + } + + const originalCost = cost + discount; + const discountPercentage = (discount / originalCost) * 100; + + return { + originalCost: originalCost.toFixed(10), + finalCost: cost.toFixed(10), + discountAmount: discount.toFixed(10), + discountPercentage: discountPercentage.toFixed(2), + }; + }, [responseCost, discountAmount]); + + return ( +
+
+ Cost Calculation + + Discounts are applied to provider costs: final_cost = base_cost × (1 - discount%/100) + +
+
+ Example + + A 5% discount on a $10.00 request results in: $10.00 × (1 - 0.05) = $9.50 + +
+
+ Valid Range + + Discount percentages must be between 0% and 100% + +
+ +
+ Validating Discounts + + Make a test request and check the response headers to verify discounts are applied: + + + + Look for these headers in the response: + +
+
+ + x-litellm-response-cost + + Final cost after discount +
+
+ + x-litellm-response-cost-original + + Original cost before discount +
+
+ + x-litellm-response-cost-discount-amount + + Amount discounted +
+
+
+ +
+ Discount Calculator + + Enter values from your response headers to verify the discount: + +
+
+ + +
+
+ + +
+
+ + {calculatedDiscount && ( +
+ Calculated Results +
+
+ Original Cost: + ${calculatedDiscount.originalCost} +
+
+ Final Cost: + ${calculatedDiscount.finalCost} +
+
+ Discount Amount: + ${calculatedDiscount.discountAmount} +
+
+ Discount Applied: + {calculatedDiscount.discountPercentage}% +
+
+
+ )} +
+
+ ); +}; + +export default HowItWorks; + diff --git a/ui/litellm-dashboard/src/components/CostTrackingSettings/index.ts b/ui/litellm-dashboard/src/components/CostTrackingSettings/index.ts new file mode 100644 index 00000000000..11adc414664 --- /dev/null +++ b/ui/litellm-dashboard/src/components/CostTrackingSettings/index.ts @@ -0,0 +1,8 @@ +export { default as CostTrackingSettings } from "./cost_tracking_settings"; +export { default as ProviderDiscountTable } from "./provider_discount_table"; +export { default as AddProviderForm } from "./add_provider_form"; +export { default as HowItWorks } from "./how_it_works"; +export type { CostTrackingSettingsProps, DiscountConfig, CostDiscountResponse } from "./types"; +export type { ProviderDisplayInfo } from "./provider_display_helpers"; +export * from "./provider_display_helpers"; + diff --git a/ui/litellm-dashboard/src/components/CostTrackingSettings/provider_discount_table.tsx b/ui/litellm-dashboard/src/components/CostTrackingSettings/provider_discount_table.tsx new file mode 100644 index 00000000000..235fe40ee21 --- /dev/null +++ b/ui/litellm-dashboard/src/components/CostTrackingSettings/provider_discount_table.tsx @@ -0,0 +1,152 @@ +import React, { useState } from "react"; +import { TextInput, Icon, Text } from "@tremor/react"; +import { TrashIcon, PencilAltIcon, CheckIcon, XIcon } from "@heroicons/react/outline"; +import { SimpleTable } from "../common_components/simple_table"; +import { DiscountConfig } from "./types"; +import { getProviderDisplayInfo, handleImageError } from "./provider_display_helpers"; + +interface ProviderDiscountTableProps { + discountConfig: DiscountConfig; + onDiscountChange: (provider: string, value: string) => void; + onRemoveProvider: (provider: string, providerDisplayName: string) => void; +} + +interface ProviderDiscountRow { + provider: string; + discount: number; +} + +const ProviderDiscountTable: React.FC = ({ + discountConfig, + onDiscountChange, + onRemoveProvider, +}) => { + const [editingProvider, setEditingProvider] = useState(null); + const [editValue, setEditValue] = useState(""); + + const handleStartEdit = (provider: string, currentDiscount: number) => { + setEditingProvider(provider); + setEditValue((currentDiscount * 100).toString()); + }; + + const handleSaveEdit = (provider: string) => { + const percentValue = parseFloat(editValue); + if (!isNaN(percentValue) && percentValue >= 0 && percentValue <= 100) { + onDiscountChange(provider, (percentValue / 100).toString()); + } + setEditingProvider(null); + setEditValue(""); + }; + + const handleCancelEdit = () => { + setEditingProvider(null); + setEditValue(""); + }; + + const handleKeyDown = (e: React.KeyboardEvent, provider: string) => { + if (e.key === 'Enter') { + handleSaveEdit(provider); + } else if (e.key === 'Escape') { + handleCancelEdit(); + } + }; + + // Convert discount config to array and sort + const data: ProviderDiscountRow[] = Object.entries(discountConfig) + .map(([provider, discount]) => ({ provider, discount })) + .sort((a, b) => { + const displayA = getProviderDisplayInfo(a.provider).displayName; + const displayB = getProviderDisplayInfo(b.provider).displayName; + return displayA.localeCompare(displayB); + }); + + return ( + { + const { displayName, logo } = getProviderDisplayInfo(row.provider); + return ( +
+ {logo && ( + {`${displayName} handleImageError(e, displayName)} + /> + )} + {displayName} +
+ ); + }, + }, + { + header: "Discount Percentage", + cell: (row) => ( +
+ {editingProvider === row.provider ? ( + <> + handleKeyDown(e, row.provider)} + placeholder="5" + className="w-20" + autoFocus + /> + % + handleSaveEdit(row.provider)} + className="cursor-pointer text-green-600 hover:text-green-700" + /> + + + ) : ( + <> + {(row.discount * 100).toFixed(1)}% + handleStartEdit(row.provider, row.discount)} + className="cursor-pointer text-blue-600 hover:text-blue-700" + /> + + )} +
+ ), + width: "250px", + }, + { + header: "Actions", + cell: (row) => { + const { displayName } = getProviderDisplayInfo(row.provider); + return ( + onRemoveProvider(row.provider, displayName)} + className="cursor-pointer hover:text-red-600" + /> + ); + }, + width: "80px", + }, + ]} + getRowKey={(row) => row.provider} + emptyMessage="No provider discounts configured" + /> + ); +}; + +export default ProviderDiscountTable; + diff --git a/ui/litellm-dashboard/src/components/CostTrackingSettings/provider_display_helpers.ts b/ui/litellm-dashboard/src/components/CostTrackingSettings/provider_display_helpers.ts new file mode 100644 index 00000000000..09c0e725146 --- /dev/null +++ b/ui/litellm-dashboard/src/components/CostTrackingSettings/provider_display_helpers.ts @@ -0,0 +1,46 @@ +import { Providers, provider_map, providerLogoMap } from "../provider_info_helpers"; + +export interface ProviderDisplayInfo { + displayName: string; + logo: string; + enumKey: string | null; +} + +/** + * Convert backend provider value (e.g., "openai") to display info + */ +export const getProviderDisplayInfo = (providerValue: string): ProviderDisplayInfo => { + const enumKey = Object.keys(provider_map).find( + (key) => provider_map[key as keyof typeof provider_map] === providerValue + ); + + if (enumKey) { + const displayName = Providers[enumKey as keyof typeof Providers]; + const logo = providerLogoMap[displayName]; + return { displayName, logo, enumKey }; + } + + return { displayName: providerValue, logo: "", enumKey: null }; +}; + +/** + * Convert provider enum key (e.g., "OpenAI") to backend value (e.g., "openai") + */ +export const getProviderBackendValue = (providerEnum: string): string | null => { + return provider_map[providerEnum as keyof typeof provider_map] || null; +}; + +/** + * Handle image error by replacing with fallback div + */ +export const handleImageError = (e: React.SyntheticEvent, fallbackText: string) => { + 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 = fallbackText.charAt(0); + parent.replaceChild(fallbackDiv, target); + } +}; + diff --git a/ui/litellm-dashboard/src/components/CostTrackingSettings/types.ts b/ui/litellm-dashboard/src/components/CostTrackingSettings/types.ts new file mode 100644 index 00000000000..55d49ecffd9 --- /dev/null +++ b/ui/litellm-dashboard/src/components/CostTrackingSettings/types.ts @@ -0,0 +1,14 @@ +export interface CostTrackingSettingsProps { + userID: string | null; + userRole: string | null; + accessToken: string | null; +} + +export interface DiscountConfig { + [provider: string]: number; +} + +export interface CostDiscountResponse { + values: DiscountConfig; +} + diff --git a/ui/litellm-dashboard/src/components/HelpLink.tsx b/ui/litellm-dashboard/src/components/HelpLink.tsx new file mode 100644 index 00000000000..d4544c9d425 --- /dev/null +++ b/ui/litellm-dashboard/src/components/HelpLink.tsx @@ -0,0 +1,212 @@ +import React, { useState, useRef, useEffect } from "react"; +import { ExternalLink, ChevronDown } from "lucide-react"; + +interface HelpLinkProps { + href: string; + children?: React.ReactNode; + variant?: "inline" | "subtle" | "button"; + className?: string; +} + +interface DocMenuItem { + label: string; + href: string; +} + +interface DocsMenuProps { + items: DocMenuItem[]; + children?: React.ReactNode; + className?: string; +} + +/** + * A reusable component for linking to documentation, styled similar to Linear's help links. + * + * @example + * // Inline "Learn more" style + * + * Learn more about custom pricing + * + * + * @example + * // Subtle link (just icon + text, minimal styling) + * + * View docs + * + * + * @example + * // Button style (more prominent) + * + * Custom Pricing Documentation + * + */ +export const HelpLink: React.FC = ({ + href, + children = "Learn more", + variant = "inline", + className = "", +}) => { + const baseClasses = "inline-flex items-center gap-1.5 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 rounded"; + + const variantClasses = { + inline: "text-blue-600 hover:text-blue-800 text-sm font-medium hover:underline", + subtle: "text-gray-500 hover:text-gray-700 text-xs", + button: "text-blue-600 hover:text-blue-700 border border-gray-200 hover:border-gray-300 px-3 py-1.5 rounded-md bg-white hover:bg-gray-50 text-sm font-medium shadow-sm", + }; + + return ( + + {children} + + ); +}; + +/** + * A minimal help icon with tooltip for inline contextual help. + * Similar to Linear's "?" icons that appear next to labels. + */ +interface HelpIconProps { + content: React.ReactNode; + learnMoreHref?: string; + learnMoreText?: string; +} + +export const HelpIcon: React.FC = ({ + content, + learnMoreHref, + learnMoreText = "Learn more", +}) => { + const [showTooltip, setShowTooltip] = React.useState(false); + + return ( +
+ + {showTooltip && ( +
+
{content}
+ {learnMoreHref && ( + + {learnMoreText} + + )} +
+
+ )} +
+ ); +}; + +/** + * A dropdown menu for multiple documentation links. + * Linear-style: Single "Docs" button that expands to show multiple relevant links. + * + * @example + * + * Docs + * + */ +export const DocsMenu: React.FC = ({ + items, + children = "Docs", + className = "", +}) => { + const [isOpen, setIsOpen] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + } + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [isOpen]); + + return ( +
+ + + {isOpen && ( +
+ {items.map((item, index) => ( + setIsOpen(false)} + > + {item.label} + + ))} +
+ )} +
+ ); +}; + diff --git a/ui/litellm-dashboard/src/components/common_components/simple_table.tsx b/ui/litellm-dashboard/src/components/common_components/simple_table.tsx new file mode 100644 index 00000000000..3ef3a070120 --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/simple_table.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import { Table, TableHead, TableRow, TableHeaderCell, TableBody, TableCell, Text } from "@tremor/react"; + +export interface SimpleTableColumn { + header: string; + accessor?: keyof T; + cell?: (row: T) => React.ReactNode; + width?: string; +} + +interface SimpleTableProps { + data: T[]; + columns: SimpleTableColumn[]; + isLoading?: boolean; + loadingMessage?: string; + emptyMessage?: string; + getRowKey?: (row: T, index: number) => string; +} + +/** + * Simple table component for forms and settings pages + * For complex tables with sorting/filtering, use DataTable from view_logs + */ +export function SimpleTable({ + data, + columns, + isLoading = false, + loadingMessage = "Loading...", + emptyMessage = "No data", + getRowKey, +}: SimpleTableProps) { + return ( + + + + {columns.map((column, index) => ( + + {column.header} + + ))} + + + + {isLoading ? ( + + + {loadingMessage} + + + ) : data.length > 0 ? ( + data.map((row, rowIndex) => ( + + {columns.map((column, colIndex) => ( + + {column.cell ? column.cell(row) : String(row[column.accessor as keyof T] ?? "")} + + ))} + + )) + ) : ( + + + {emptyMessage} + + + )} + +
+ ); +} + diff --git a/ui/litellm-dashboard/src/components/cost_tracking_settings.tsx b/ui/litellm-dashboard/src/components/cost_tracking_settings.tsx new file mode 100644 index 00000000000..bab61da6775 --- /dev/null +++ b/ui/litellm-dashboard/src/components/cost_tracking_settings.tsx @@ -0,0 +1,312 @@ +import React, { useState, useEffect } from "react"; +import { + Card, + Title, + Text, + TextInput, + Button, + Table, + TableHead, + TableRow, + TableHeaderCell, + TableBody, + TableCell, + Grid, + Col, + Subtitle, +} from "@tremor/react"; +import { getProxyBaseUrl } from "@/components/networking"; +import NotificationsManager from "./molecules/notifications_manager"; + +interface CostTrackingSettingsProps { + userID: string | null; + userRole: string | null; + accessToken: string | null; +} + +interface DiscountConfig { + [provider: string]: number; +} + +const CostTrackingSettings: React.FC = ({ + userID, + userRole, + accessToken +}) => { + const [discountConfig, setDiscountConfig] = useState({}); + const [newProvider, setNewProvider] = useState(""); + const [newDiscount, setNewDiscount] = useState(""); + const [loading, setLoading] = useState(false); + const [isFetching, setIsFetching] = useState(true); + + useEffect(() => { + if (accessToken) { + fetchDiscountConfig(); + } + }, [accessToken]); + + const fetchDiscountConfig = async () => { + setIsFetching(true); + try { + const proxyBaseUrl = getProxyBaseUrl(); + const url = proxyBaseUrl + ? `${proxyBaseUrl}/config/cost_discount_config` + : "/config/cost_discount_config"; + + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (response.ok) { + const data = await response.json(); + setDiscountConfig(data.values || {}); + } else { + console.error("Failed to fetch discount config"); + } + } catch (error) { + console.error("Error fetching discount config:", error); + NotificationsManager.fromBackend("Failed to fetch discount configuration"); + } finally { + setIsFetching(false); + } + }; + + const handleSave = async () => { + setLoading(true); + try { + const proxyBaseUrl = getProxyBaseUrl(); + const url = proxyBaseUrl + ? `${proxyBaseUrl}/config/cost_discount_config` + : "/config/cost_discount_config"; + + const response = await fetch(url, { + method: "PATCH", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(discountConfig), + }); + + if (response.ok) { + NotificationsManager.success("Cost discount configuration updated successfully"); + await fetchDiscountConfig(); + } else { + const errorData = await response.json(); + const errorMessage = errorData.detail?.error || errorData.detail || "Failed to update settings"; + NotificationsManager.fromBackend(errorMessage); + } + } catch (error) { + console.error("Error updating discount config:", error); + NotificationsManager.fromBackend("Failed to update discount configuration"); + } finally { + setLoading(false); + } + }; + + const handleAddProvider = () => { + if (!newProvider || !newDiscount) { + NotificationsManager.fromBackend("Please enter both provider and discount value"); + return; + } + + const discountValue = parseFloat(newDiscount); + if (isNaN(discountValue) || discountValue < 0 || discountValue > 1) { + NotificationsManager.fromBackend("Discount must be between 0 and 1 (0% to 100%)"); + return; + } + + setDiscountConfig(prev => ({ + ...prev, + [newProvider.trim()]: discountValue, + })); + setNewProvider(""); + setNewDiscount(""); + }; + + const handleRemoveProvider = (provider: string) => { + setDiscountConfig(prev => { + const updated = { ...prev }; + delete updated[provider]; + return updated; + }); + }; + + const handleDiscountChange = (provider: string, value: string) => { + const discountValue = parseFloat(value); + if (!isNaN(discountValue) && discountValue >= 0 && discountValue <= 1) { + setDiscountConfig(prev => ({ + ...prev, + [provider]: discountValue, + })); + } + }; + + if (!accessToken) { + return null; + } + + const hasChanges = Object.keys(discountConfig).length > 0; + + return ( +
+
+ Cost Tracking Settings + + Configure cost discounts for different LLM providers. Discounts are applied as multipliers. + +
+ + + + +
+
+ Provider Discounts + + Set custom discount rates per provider (e.g., 0.05 = 5% discount) + +
+ +
+ + {isFetching ? ( +
+ Loading configuration... +
+ ) : ( + <> + {Object.keys(discountConfig).length > 0 ? ( +
+ + + + Provider + Discount Value + Percentage + Actions + + + + {Object.entries(discountConfig) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([provider, discount]) => ( + + {provider} + + handleDiscountChange(provider, value)} + placeholder="0.05" + className="w-32" + /> + + + + {(discount * 100).toFixed(1)}% + + + + + + + ))} + +
+
+ ) : ( +
+ + No provider discounts configured. Add your first provider below. + +
+ )} + +
+
+ Add Provider Discount + + Common providers: vertex_ai, gemini, openai, anthropic, openrouter, bedrock, azure + +
+ + + + + + + + + + + +
+ + )} +
+ + + + + How It Works +
+
+ Cost Calculation + + Discounts are applied to provider costs: final_cost = base_cost × (1 - discount) + +
+
+ Example + + A 5% discount (0.05) on a $10.00 request results in: $10.00 × (1 - 0.05) = $9.50 + +
+
+ Valid Range + + Discount values must be between 0 (0%) and 1 (100%) + +
+
+
+ +
+
+ ); +}; + +export default CostTrackingSettings; + diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index f40ae673665..b974cdfaa9f 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -191,6 +191,13 @@ const Sidebar: React.FC = ({ accessToken, setPage, userRole, defau icon: , roles: all_admin_roles, }, + { + key: "27", + page: "cost-tracking-settings", + label: "Cost Tracking", + icon: , + roles: all_admin_roles, + }, { key: "14", page: "ui-theme",