mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
[Feat] UI - Allow setting Provider Discounts on UI (#15550)
* fix cost_discount_config * add CostBreakdown * fix: set_cost_breakdown * test_cost_discount_vertex_ai * docs fix * docs fix discounts * docs fix * docs custom pricing * docs fix * fixes for getting cost breakdown in response headers * test - response headers wth discount * - feat: allow setting cost tracking settings * add cost tracking endpoints * feat add cost_tracking_settings_router * add validation when setting cost settings * add CostTrackingSettings * add page * refactor * use simple table * add ProviderDiscountTable * fix ui * save * fix: discount settings * fix edits * add settings * add HelpLink * add help link * docs HelpLink * docs fix * fix how it works * fix layout * test TestCostTrackingSettings * fix Dict
This commit is contained in:
parent
a6c57cb5bd
commit
5b7e2fd778
15 changed files with 1831 additions and 2 deletions
165
litellm/proxy/management_endpoints/cost_tracking_settings.py
Normal file
165
litellm/proxy/management_endpoints/cost_tracking_settings.py
Normal file
|
|
@ -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)}"}
|
||||
)
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
|
@ -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" ? (
|
||||
<UIThemeSettings userID={userID} userRole={userRole} accessToken={accessToken} />
|
||||
) : page == "cost-tracking-settings" ? (
|
||||
<CostTrackingSettings userID={userID} userRole={userRole} accessToken={accessToken} />
|
||||
) : page == "model-hub-table" ? (
|
||||
<ModelHubTable
|
||||
accessToken={accessToken}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,110 @@
|
|||
import React from "react";
|
||||
import { Text, TextInput, Button } from "@tremor/react";
|
||||
import { Select as AntdSelect, Form, Tooltip } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { Providers, provider_map, providerLogoMap } from "../provider_info_helpers";
|
||||
import { DiscountConfig } from "./types";
|
||||
import { handleImageError } from "./provider_display_helpers";
|
||||
|
||||
interface AddProviderFormProps {
|
||||
discountConfig: DiscountConfig;
|
||||
selectedProvider: string | undefined;
|
||||
newDiscount: string;
|
||||
onProviderChange: (provider: string | undefined) => void;
|
||||
onDiscountChange: (discount: string) => void;
|
||||
onAddProvider: () => void;
|
||||
}
|
||||
|
||||
const AddProviderForm: React.FC<AddProviderFormProps> = ({
|
||||
discountConfig,
|
||||
selectedProvider,
|
||||
newDiscount,
|
||||
onProviderChange,
|
||||
onDiscountChange,
|
||||
onAddProvider,
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
Provider
|
||||
<Tooltip title="Select the LLM provider you want to configure a discount for">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
rules={[{ required: true, message: "Please select a provider" }]}
|
||||
>
|
||||
<AntdSelect
|
||||
showSearch
|
||||
placeholder="Select provider"
|
||||
value={selectedProvider}
|
||||
onChange={onProviderChange}
|
||||
style={{ width: "100%" }}
|
||||
size="large"
|
||||
optionFilterProp="children"
|
||||
filterOption={(input, option) =>
|
||||
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 (
|
||||
<AntdSelect.Option key={providerEnum} value={providerEnum} label={providerDisplayName}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<img
|
||||
src={providerLogoMap[providerDisplayName]}
|
||||
alt={`${providerEnum} logo`}
|
||||
className="w-5 h-5"
|
||||
onError={(e) => handleImageError(e, providerDisplayName)}
|
||||
/>
|
||||
<span>{providerDisplayName}</span>
|
||||
</div>
|
||||
</AntdSelect.Option>
|
||||
);
|
||||
})}
|
||||
</AntdSelect>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
Discount Percentage
|
||||
<Tooltip title="Enter a percentage value (e.g., 5 for 5% discount)">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
rules={[{ required: true, message: "Please enter a discount percentage" }]}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<TextInput
|
||||
placeholder="5"
|
||||
value={newDiscount}
|
||||
onValueChange={onDiscountChange}
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500 flex-1"
|
||||
/>
|
||||
<span className="text-gray-600">%</span>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-gray-100">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onAddProvider}
|
||||
disabled={!selectedProvider || !newDiscount}
|
||||
>
|
||||
Add Provider Discount
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddProviderForm;
|
||||
|
||||
|
|
@ -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<CostTrackingSettingsProps> = ({
|
||||
userID,
|
||||
userRole,
|
||||
accessToken
|
||||
}) => {
|
||||
const [discountConfig, setDiscountConfig] = useState<DiscountConfig>({});
|
||||
const [selectedProvider, setSelectedProvider] = useState<string | undefined>(undefined);
|
||||
const [newDiscount, setNewDiscount] = useState<string>("");
|
||||
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: <ExclamationCircleOutlined />,
|
||||
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 (
|
||||
<div className="w-full p-8">
|
||||
{contextHolder}
|
||||
|
||||
{/* Header Section - Outside the card */}
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between mb-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Title>Cost Tracking Settings</Title>
|
||||
<DocsMenu items={DOCS_LINKS} />
|
||||
</div>
|
||||
<Text className="text-gray-500 mt-1">
|
||||
Configure cost discounts for different LLM providers. Changes are saved automatically.
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setIsModalVisible(true)}
|
||||
className="mt-4 md:mt-0"
|
||||
>
|
||||
+ Add Provider Discount
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Main Content Card with Tabs */}
|
||||
<div className="bg-white rounded-lg shadow w-full max-w-full">
|
||||
<TabGroup>
|
||||
<TabList className="px-6 pt-4">
|
||||
<Tab>Provider Discounts</Tab>
|
||||
<Tab>Test It</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
{isFetching ? (
|
||||
<div className="py-12 text-center">
|
||||
<Text className="text-gray-500">Loading configuration...</Text>
|
||||
</div>
|
||||
) : Object.keys(discountConfig).length > 0 ? (
|
||||
<div className="p-6">
|
||||
<ProviderDiscountTable
|
||||
discountConfig={discountConfig}
|
||||
onDiscountChange={handleDiscountChange}
|
||||
onRemoveProvider={handleRemoveProvider}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-16 px-6 text-center">
|
||||
<svg
|
||||
className="mx-auto h-12 w-12 text-gray-400 mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<Text className="text-gray-700 font-medium mb-2">
|
||||
No provider discounts configured
|
||||
</Text>
|
||||
<Text className="text-gray-500 text-sm">
|
||||
Click "Add Provider Discount" to get started
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<div className="px-6 pb-4">
|
||||
<HowItWorks />
|
||||
</div>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={
|
||||
<div className="flex items-center space-x-3 pb-4 border-b border-gray-100">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Add Provider Discount</h2>
|
||||
</div>
|
||||
}
|
||||
open={isModalVisible}
|
||||
width={1000}
|
||||
onCancel={handleModalCancel}
|
||||
footer={null}
|
||||
className="top-8"
|
||||
styles={{
|
||||
body: { padding: "24px" },
|
||||
header: { padding: "24px 24px 0 24px", border: "none" },
|
||||
}}
|
||||
>
|
||||
<div className="mt-6">
|
||||
<Text className="text-sm text-gray-600 mb-6">
|
||||
Select a provider and set its discount percentage. Enter a value between 0% and 100% (e.g., 5 for a 5% discount).
|
||||
</Text>
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={handleFormSubmit}
|
||||
layout="vertical"
|
||||
className="space-y-6"
|
||||
>
|
||||
<AddProviderForm
|
||||
discountConfig={discountConfig}
|
||||
selectedProvider={selectedProvider}
|
||||
newDiscount={newDiscount}
|
||||
onProviderChange={setSelectedProvider}
|
||||
onDiscountChange={setNewDiscount}
|
||||
onAddProvider={handleAddProvider}
|
||||
/>
|
||||
</Form>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CostTrackingSettings;
|
||||
|
|
@ -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 (
|
||||
<div className="space-y-4 pt-2">
|
||||
<div>
|
||||
<Text className="font-medium text-gray-900 text-sm mb-1">Cost Calculation</Text>
|
||||
<Text className="text-xs text-gray-600">
|
||||
Discounts are applied to provider costs: <code className="bg-gray-100 px-1.5 py-0.5 rounded text-xs">final_cost = base_cost × (1 - discount%/100)</code>
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="font-medium text-gray-900 text-sm mb-1">Example</Text>
|
||||
<Text className="text-xs text-gray-600">
|
||||
A 5% discount on a $10.00 request results in: $10.00 × (1 - 0.05) = $9.50
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="font-medium text-gray-900 text-sm mb-1">Valid Range</Text>
|
||||
<Text className="text-xs text-gray-600">
|
||||
Discount percentages must be between 0% and 100%
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
<Text className="font-medium text-gray-900 text-sm mb-2">Validating Discounts</Text>
|
||||
<Text className="text-xs text-gray-600 mb-3">
|
||||
Make a test request and check the response headers to verify discounts are applied:
|
||||
</Text>
|
||||
<CodeBlock
|
||||
language="bash"
|
||||
code={`curl -X POST -i http://your-proxy:4000/chat/completions \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "Authorization: Bearer sk-1234" \\
|
||||
-d '{
|
||||
"model": "gemini/gemini-2.5-pro",
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
}'`}
|
||||
/>
|
||||
<Text className="text-xs text-gray-600 mt-3 mb-2">
|
||||
Look for these headers in the response:
|
||||
</Text>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-start gap-3">
|
||||
<code className="bg-gray-100 px-2 py-1 rounded text-xs font-mono text-gray-800 whitespace-nowrap">
|
||||
x-litellm-response-cost
|
||||
</code>
|
||||
<Text className="text-xs text-gray-600">Final cost after discount</Text>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<code className="bg-gray-100 px-2 py-1 rounded text-xs font-mono text-gray-800 whitespace-nowrap">
|
||||
x-litellm-response-cost-original
|
||||
</code>
|
||||
<Text className="text-xs text-gray-600">Original cost before discount</Text>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<code className="bg-gray-100 px-2 py-1 rounded text-xs font-mono text-gray-800 whitespace-nowrap">
|
||||
x-litellm-response-cost-discount-amount
|
||||
</code>
|
||||
<Text className="text-xs text-gray-600">Amount discounted</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
<Text className="font-medium text-gray-900 text-sm mb-3">Discount Calculator</Text>
|
||||
<Text className="text-xs text-gray-600 mb-3">
|
||||
Enter values from your response headers to verify the discount:
|
||||
</Text>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Response Cost (x-litellm-response-cost)
|
||||
</label>
|
||||
<TextInput
|
||||
placeholder="0.0171938125"
|
||||
value={responseCost}
|
||||
onValueChange={setResponseCost}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Discount Amount (x-litellm-response-cost-discount-amount)
|
||||
</label>
|
||||
<TextInput
|
||||
placeholder="0.0009049375"
|
||||
value={discountAmount}
|
||||
onValueChange={setDiscountAmount}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{calculatedDiscount && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<Text className="text-sm font-medium text-blue-900 mb-2">Calculated Results</Text>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Text className="text-xs text-blue-800">Original Cost:</Text>
|
||||
<code className="text-xs font-mono text-blue-900">${calculatedDiscount.originalCost}</code>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Text className="text-xs text-blue-800">Final Cost:</Text>
|
||||
<code className="text-xs font-mono text-blue-900">${calculatedDiscount.finalCost}</code>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Text className="text-xs text-blue-800">Discount Amount:</Text>
|
||||
<code className="text-xs font-mono text-blue-900">${calculatedDiscount.discountAmount}</code>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-2 border-t border-blue-300">
|
||||
<Text className="text-xs font-semibold text-blue-900">Discount Applied:</Text>
|
||||
<Text className="text-sm font-bold text-blue-900">{calculatedDiscount.discountPercentage}%</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HowItWorks;
|
||||
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
@ -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<ProviderDiscountTableProps> = ({
|
||||
discountConfig,
|
||||
onDiscountChange,
|
||||
onRemoveProvider,
|
||||
}) => {
|
||||
const [editingProvider, setEditingProvider] = useState<string | null>(null);
|
||||
const [editValue, setEditValue] = useState<string>("");
|
||||
|
||||
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 (
|
||||
<SimpleTable
|
||||
data={data}
|
||||
columns={[
|
||||
{
|
||||
header: "Provider",
|
||||
cell: (row) => {
|
||||
const { displayName, logo } = getProviderDisplayInfo(row.provider);
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
{logo && (
|
||||
<img
|
||||
src={logo}
|
||||
alt={`${displayName} logo`}
|
||||
className="w-5 h-5"
|
||||
onError={(e) => handleImageError(e, displayName)}
|
||||
/>
|
||||
)}
|
||||
<span className="font-medium">{displayName}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Discount Percentage",
|
||||
cell: (row) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{editingProvider === row.provider ? (
|
||||
<>
|
||||
<TextInput
|
||||
value={editValue}
|
||||
onValueChange={setEditValue}
|
||||
onKeyDown={(e) => handleKeyDown(e, row.provider)}
|
||||
placeholder="5"
|
||||
className="w-20"
|
||||
autoFocus
|
||||
/>
|
||||
<span className="text-gray-600">%</span>
|
||||
<Icon
|
||||
icon={CheckIcon}
|
||||
size="sm"
|
||||
onClick={() => handleSaveEdit(row.provider)}
|
||||
className="cursor-pointer text-green-600 hover:text-green-700"
|
||||
/>
|
||||
<Icon
|
||||
icon={XIcon}
|
||||
size="sm"
|
||||
onClick={handleCancelEdit}
|
||||
className="cursor-pointer text-gray-600 hover:text-gray-700"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text className="font-medium">{(row.discount * 100).toFixed(1)}%</Text>
|
||||
<Icon
|
||||
icon={PencilAltIcon}
|
||||
size="sm"
|
||||
onClick={() => handleStartEdit(row.provider, row.discount)}
|
||||
className="cursor-pointer text-blue-600 hover:text-blue-700"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
width: "250px",
|
||||
},
|
||||
{
|
||||
header: "Actions",
|
||||
cell: (row) => {
|
||||
const { displayName } = getProviderDisplayInfo(row.provider);
|
||||
return (
|
||||
<Icon
|
||||
icon={TrashIcon}
|
||||
size="sm"
|
||||
onClick={() => 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;
|
||||
|
||||
|
|
@ -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<HTMLImageElement>, 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);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
212
ui/litellm-dashboard/src/components/HelpLink.tsx
Normal file
212
ui/litellm-dashboard/src/components/HelpLink.tsx
Normal file
|
|
@ -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
|
||||
* <HelpLink href="https://docs.litellm.ai/docs/proxy/custom_pricing">
|
||||
* Learn more about custom pricing
|
||||
* </HelpLink>
|
||||
*
|
||||
* @example
|
||||
* // Subtle link (just icon + text, minimal styling)
|
||||
* <HelpLink href="https://docs.litellm.ai/docs/proxy/cost_tracking" variant="subtle">
|
||||
* View docs
|
||||
* </HelpLink>
|
||||
*
|
||||
* @example
|
||||
* // Button style (more prominent)
|
||||
* <HelpLink href="https://docs.litellm.ai/docs/proxy/custom_pricing" variant="button">
|
||||
* Custom Pricing Documentation
|
||||
* </HelpLink>
|
||||
*/
|
||||
export const HelpLink: React.FC<HelpLinkProps> = ({
|
||||
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 (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`${baseClasses} ${variantClasses[variant]} ${className}`}
|
||||
title="Open documentation in a new tab"
|
||||
>
|
||||
<span>{children}</span>
|
||||
<ExternalLink className="h-3.5 w-3.5 flex-shrink-0" aria-hidden="true" />
|
||||
<span className="sr-only">(opens in a new tab)</span>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<HelpIconProps> = ({
|
||||
content,
|
||||
learnMoreHref,
|
||||
learnMoreText = "Learn more",
|
||||
}) => {
|
||||
const [showTooltip, setShowTooltip] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative inline-block ml-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center w-4 h-4 text-gray-400 hover:text-gray-600 transition-colors cursor-help focus:outline-none focus:ring-2 focus:ring-blue-500 rounded-full"
|
||||
onMouseEnter={() => setShowTooltip(true)}
|
||||
onMouseLeave={() => setShowTooltip(false)}
|
||||
onFocus={() => setShowTooltip(true)}
|
||||
onBlur={() => setShowTooltip(false)}
|
||||
aria-label="Help information"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" strokeWidth="1.5" />
|
||||
<path strokeLinecap="round" d="M12 17h0M12 13.5a1.5 1.5 0 0 1 1-1.415A1.5 1.5 0 1 0 12 9" strokeWidth="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
{showTooltip && (
|
||||
<div
|
||||
className="absolute left-1/2 -translate-x-1/2 bottom-full mb-2 z-50 bg-gray-900 text-white p-3 rounded-lg text-xs shadow-lg w-64"
|
||||
style={{ pointerEvents: "none" }}
|
||||
>
|
||||
<div className="mb-2">{content}</div>
|
||||
{learnMoreHref && (
|
||||
<a
|
||||
href={learnMoreHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-blue-300 hover:text-blue-200 font-medium"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
>
|
||||
{learnMoreText}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
)}
|
||||
<div
|
||||
className="absolute left-1/2 -translate-x-1/2 top-full w-0 h-0"
|
||||
style={{
|
||||
borderTop: "6px solid rgb(17 24 39)",
|
||||
borderLeft: "6px solid transparent",
|
||||
borderRight: "6px solid transparent",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A dropdown menu for multiple documentation links.
|
||||
* Linear-style: Single "Docs" button that expands to show multiple relevant links.
|
||||
*
|
||||
* @example
|
||||
* <DocsMenu items={[
|
||||
* { 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" }
|
||||
* ]}>
|
||||
* Docs
|
||||
* </DocsMenu>
|
||||
*/
|
||||
export const DocsMenu: React.FC<DocsMenuProps> = ({
|
||||
items,
|
||||
children = "Docs",
|
||||
className = "",
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(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 (
|
||||
<div className={`relative inline-block ${className}`} ref={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="inline-flex items-center gap-1 text-gray-500 hover:text-gray-700 text-xs transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 rounded px-2 py-1"
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<span>{children}</span>
|
||||
<ChevronDown className={`h-3 w-3 transition-transform ${isOpen ? "rotate-180" : ""}`} aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 mt-1 w-56 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50">
|
||||
{items.map((item, index) => (
|
||||
<a
|
||||
key={index}
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<ExternalLink className="h-3.5 w-3.5 text-gray-400 flex-shrink-0 ml-2" aria-hidden="true" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import React from "react";
|
||||
import { Table, TableHead, TableRow, TableHeaderCell, TableBody, TableCell, Text } from "@tremor/react";
|
||||
|
||||
export interface SimpleTableColumn<T> {
|
||||
header: string;
|
||||
accessor?: keyof T;
|
||||
cell?: (row: T) => React.ReactNode;
|
||||
width?: string;
|
||||
}
|
||||
|
||||
interface SimpleTableProps<T> {
|
||||
data: T[];
|
||||
columns: SimpleTableColumn<T>[];
|
||||
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<T>({
|
||||
data,
|
||||
columns,
|
||||
isLoading = false,
|
||||
loadingMessage = "Loading...",
|
||||
emptyMessage = "No data",
|
||||
getRowKey,
|
||||
}: SimpleTableProps<T>) {
|
||||
return (
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{columns.map((column, index) => (
|
||||
<TableHeaderCell key={index} style={{ width: column.width }}>
|
||||
{column.header}
|
||||
</TableHeaderCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="text-center">
|
||||
<Text className="text-gray-500">{loadingMessage}</Text>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : data.length > 0 ? (
|
||||
data.map((row, rowIndex) => (
|
||||
<TableRow key={getRowKey ? getRowKey(row, rowIndex) : rowIndex}>
|
||||
{columns.map((column, colIndex) => (
|
||||
<TableCell key={colIndex}>
|
||||
{column.cell ? column.cell(row) : String(row[column.accessor as keyof T] ?? "")}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="text-center">
|
||||
<Text className="text-gray-500">{emptyMessage}</Text>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
312
ui/litellm-dashboard/src/components/cost_tracking_settings.tsx
Normal file
312
ui/litellm-dashboard/src/components/cost_tracking_settings.tsx
Normal file
|
|
@ -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<CostTrackingSettingsProps> = ({
|
||||
userID,
|
||||
userRole,
|
||||
accessToken
|
||||
}) => {
|
||||
const [discountConfig, setDiscountConfig] = useState<DiscountConfig>({});
|
||||
const [newProvider, setNewProvider] = useState<string>("");
|
||||
const [newDiscount, setNewDiscount] = useState<string>("");
|
||||
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 (
|
||||
<div style={{ width: "100%" }} className="relative">
|
||||
<div className="mb-6">
|
||||
<Title>Cost Tracking Settings</Title>
|
||||
<Subtitle>
|
||||
Configure cost discounts for different LLM providers. Discounts are applied as multipliers.
|
||||
</Subtitle>
|
||||
</div>
|
||||
|
||||
<Grid numItems={1} className="gap-6">
|
||||
<Col>
|
||||
<Card>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<Title>Provider Discounts</Title>
|
||||
<Text className="mt-1 text-sm text-gray-500">
|
||||
Set custom discount rates per provider (e.g., 0.05 = 5% discount)
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
loading={loading}
|
||||
disabled={loading || isFetching}
|
||||
size="sm"
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isFetching ? (
|
||||
<div className="py-8 text-center">
|
||||
<Text className="text-gray-500">Loading configuration...</Text>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{Object.keys(discountConfig).length > 0 ? (
|
||||
<div className="mt-4">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Provider</TableHeaderCell>
|
||||
<TableHeaderCell>Discount Value</TableHeaderCell>
|
||||
<TableHeaderCell>Percentage</TableHeaderCell>
|
||||
<TableHeaderCell>Actions</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{Object.entries(discountConfig)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([provider, discount]) => (
|
||||
<TableRow key={provider}>
|
||||
<TableCell className="font-medium">{provider}</TableCell>
|
||||
<TableCell>
|
||||
<TextInput
|
||||
value={discount.toString()}
|
||||
onValueChange={(value) => handleDiscountChange(provider, value)}
|
||||
placeholder="0.05"
|
||||
className="w-32"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-gray-700 font-medium">
|
||||
{(discount * 100).toFixed(1)}%
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="secondary"
|
||||
color="red"
|
||||
onClick={() => handleRemoveProvider(provider)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-8 text-center border border-dashed border-gray-300 rounded-lg">
|
||||
<Text className="text-gray-500">
|
||||
No provider discounts configured. Add your first provider below.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t pt-6 mt-6">
|
||||
<div className="mb-3">
|
||||
<Text className="font-medium text-gray-900">Add Provider Discount</Text>
|
||||
<Text className="text-xs text-gray-500 mt-1">
|
||||
Common providers: vertex_ai, gemini, openai, anthropic, openrouter, bedrock, azure
|
||||
</Text>
|
||||
</div>
|
||||
<Grid numItems={3} className="gap-3">
|
||||
<Col numColSpan={1}>
|
||||
<TextInput
|
||||
placeholder="Provider name"
|
||||
value={newProvider}
|
||||
onValueChange={setNewProvider}
|
||||
/>
|
||||
</Col>
|
||||
<Col numColSpan={1}>
|
||||
<TextInput
|
||||
placeholder="Discount (0.05 for 5%)"
|
||||
value={newDiscount}
|
||||
onValueChange={setNewDiscount}
|
||||
/>
|
||||
</Col>
|
||||
<Col numColSpan={1}>
|
||||
<Button
|
||||
onClick={handleAddProvider}
|
||||
className="w-full"
|
||||
disabled={!newProvider || !newDiscount}
|
||||
>
|
||||
Add Provider
|
||||
</Button>
|
||||
</Col>
|
||||
</Grid>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col>
|
||||
<Card>
|
||||
<Title>How It Works</Title>
|
||||
<div className="mt-4 space-y-3">
|
||||
<div>
|
||||
<Text className="font-medium text-gray-900">Cost Calculation</Text>
|
||||
<Text className="text-sm text-gray-600 mt-1">
|
||||
Discounts are applied to provider costs: <code className="bg-gray-100 px-1 py-0.5 rounded">final_cost = base_cost × (1 - discount)</code>
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="font-medium text-gray-900">Example</Text>
|
||||
<Text className="text-sm text-gray-600 mt-1">
|
||||
A 5% discount (0.05) on a $10.00 request results in: $10.00 × (1 - 0.05) = $9.50
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="font-medium text-gray-900">Valid Range</Text>
|
||||
<Text className="text-sm text-gray-600 mt-1">
|
||||
Discount values must be between 0 (0%) and 1 (100%)
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Grid>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CostTrackingSettings;
|
||||
|
||||
|
|
@ -191,6 +191,13 @@ const Sidebar: React.FC<SidebarProps> = ({ accessToken, setPage, userRole, defau
|
|||
icon: <SettingOutlined style={{ fontSize: "18px" }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "27",
|
||||
page: "cost-tracking-settings",
|
||||
label: "Cost Tracking",
|
||||
icon: <BarChartOutlined style={{ fontSize: "18px" }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "14",
|
||||
page: "ui-theme",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue