mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
feat(ui): give prompt compression its own top-level page
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
c3da12161b
commit
66cc6e1ca2
13 changed files with 195 additions and 65 deletions
|
|
@ -212,11 +212,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/cost-optimization/_components/PromptCompressionTab.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/cost-tracking/_components/add_margin_form.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockCreateGuardrailCall, mockGetGuardrailsList } = vi.hoisted(() => ({
|
||||
mockCreateGuardrailCall: vi.fn(),
|
||||
mockGetGuardrailsList: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
createGuardrailCall: mockCreateGuardrailCall,
|
||||
getGuardrailsList: mockGetGuardrailsList,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/molecules/notifications_manager", () => ({
|
||||
default: { success: vi.fn(), fromBackend: vi.fn() },
|
||||
}));
|
||||
|
||||
import CompressionView from "./CompressionView";
|
||||
|
||||
const headroomGuardrail = {
|
||||
guardrail_id: "g-1",
|
||||
guardrail_name: "headroom-prod",
|
||||
litellm_params: { guardrail: "headroom", api_base: "https://headroom.internal", default_on: true },
|
||||
};
|
||||
|
||||
const piiGuardrail = {
|
||||
guardrail_id: "g-2",
|
||||
guardrail_name: "presidio-pii",
|
||||
litellm_params: { guardrail: "presidio", api_base: "https://presidio.internal", default_on: false },
|
||||
};
|
||||
|
||||
describe("CompressionView", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetGuardrailsList.mockResolvedValue({ guardrails: [headroomGuardrail, piiGuardrail] });
|
||||
mockCreateGuardrailCall.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it("lists only compression guardrails with their always-on state", async () => {
|
||||
render(<CompressionView accessToken="test-token" />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("headroom-prod")).toBeInTheDocument());
|
||||
expect(screen.getByText("https://headroom.internal")).toBeInTheDocument();
|
||||
expect(screen.getByText("Always on")).toBeInTheDocument();
|
||||
expect(screen.queryByText("presidio-pii")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("creates a headroom guardrail from the form and reloads the list", async () => {
|
||||
render(<CompressionView accessToken="test-token" />);
|
||||
await waitFor(() => expect(mockGetGuardrailsList).toHaveBeenCalledTimes(1));
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("headroom-compression"), { target: { value: "headroom-new" } });
|
||||
fireEvent.change(screen.getByPlaceholderText("https://your-headroom-endpoint"), {
|
||||
target: { value: "https://new-headroom.internal" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add guardrail" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockCreateGuardrailCall).toHaveBeenCalledWith("test-token", {
|
||||
guardrail_name: "headroom-new",
|
||||
litellm_params: {
|
||||
guardrail: "headroom",
|
||||
mode: "pre_call",
|
||||
api_base: "https://new-headroom.internal",
|
||||
default_on: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(mockGetGuardrailsList).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("sends default_on false when 'Apply to all requests' is switched off", async () => {
|
||||
render(<CompressionView accessToken="test-token" />);
|
||||
await waitFor(() => expect(mockGetGuardrailsList).toHaveBeenCalledTimes(1));
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("headroom-compression"), { target: { value: "headroom-optin" } });
|
||||
fireEvent.change(screen.getByPlaceholderText("https://your-headroom-endpoint"), {
|
||||
target: { value: "https://optin-headroom.internal" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("switch"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add guardrail" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockCreateGuardrailCall).toHaveBeenCalledWith(
|
||||
"test-token",
|
||||
expect.objectContaining({
|
||||
litellm_params: expect.objectContaining({ default_on: false }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not call the backend without an access token", async () => {
|
||||
render(<CompressionView accessToken={null} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Add guardrail" })).toBeInTheDocument());
|
||||
expect(mockGetGuardrailsList).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,9 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Button, Form, Input, Switch } from "antd";
|
||||
import { Shrink } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { createGuardrailCall, getGuardrailsList } from "@/components/networking";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import {
|
||||
|
|
@ -13,7 +17,7 @@ import {
|
|||
GuardrailListResponse,
|
||||
} from "./helpers";
|
||||
|
||||
interface PromptCompressionTabProps {
|
||||
interface CompressionViewProps {
|
||||
accessToken: string | null;
|
||||
}
|
||||
|
||||
|
|
@ -23,8 +27,10 @@ interface CompressionFormValues {
|
|||
defaultOn: boolean;
|
||||
}
|
||||
|
||||
const PromptCompressionTab: React.FC<PromptCompressionTabProps> = ({ accessToken }) => {
|
||||
const [form] = Form.useForm<CompressionFormValues>();
|
||||
const EMPTY_FORM: CompressionFormValues = { name: "", apiBase: "", defaultOn: true };
|
||||
|
||||
const CompressionView: React.FC<CompressionViewProps> = ({ accessToken }) => {
|
||||
const [formValues, setFormValues] = useState<CompressionFormValues>(EMPTY_FORM);
|
||||
const [guardrails, setGuardrails] = useState<GuardrailListItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isSaving, setIsSaving] = useState<boolean>(false);
|
||||
|
|
@ -46,22 +52,16 @@ const PromptCompressionTab: React.FC<PromptCompressionTabProps> = ({ accessToken
|
|||
loadGuardrails();
|
||||
}, [loadGuardrails]);
|
||||
|
||||
const handleAdd = async (values: CompressionFormValues) => {
|
||||
const handleAdd = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!accessToken) {
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await createGuardrailCall(
|
||||
accessToken,
|
||||
buildCompressionGuardrailPayload({
|
||||
name: values.name,
|
||||
apiBase: values.apiBase,
|
||||
defaultOn: values.defaultOn ?? true,
|
||||
}),
|
||||
);
|
||||
await createGuardrailCall(accessToken, buildCompressionGuardrailPayload(formValues));
|
||||
NotificationsManager.success("Compression guardrail created");
|
||||
form.resetFields();
|
||||
setFormValues(EMPTY_FORM);
|
||||
await loadGuardrails();
|
||||
} catch (error) {
|
||||
console.error("Failed to create compression guardrail:", error);
|
||||
|
|
@ -72,7 +72,17 @@ const PromptCompressionTab: React.FC<PromptCompressionTabProps> = ({ accessToken
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-6">
|
||||
<div className="w-full space-y-6 p-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shrink className="size-6 text-emerald-600" strokeWidth={1.75} />
|
||||
<h1 className="text-xl font-semibold text-foreground">Compression</h1>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Configure prompt compression so you pay for fewer input tokens
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Headroom prompt compression</CardTitle>
|
||||
|
|
@ -80,7 +90,8 @@ const PromptCompressionTab: React.FC<PromptCompressionTabProps> = ({ accessToken
|
|||
<CardContent>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
Headroom is a native LiteLLM guardrail that compresses your prompts before they reach the model, so you pay
|
||||
for fewer input tokens. The tokens it removes are priced and shown on the Usage tab as compression savings.{" "}
|
||||
for fewer input tokens. The tokens it removes are priced and shown as compression savings on the Cost
|
||||
Optimization dashboard.{" "}
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/proxy/headroom"
|
||||
target="_blank"
|
||||
|
|
@ -125,29 +136,39 @@ const PromptCompressionTab: React.FC<PromptCompressionTabProps> = ({ accessToken
|
|||
<CardTitle>Add Headroom compression guardrail</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
requiredMark={false}
|
||||
onFinish={handleAdd}
|
||||
initialValues={{ defaultOn: true }}
|
||||
>
|
||||
<Form.Item name="name" label="Name" rules={[{ required: true, message: "Name is required" }]}>
|
||||
<Input placeholder="headroom-compression" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="apiBase"
|
||||
label="Headroom API base"
|
||||
tooltip="Base URL of your Headroom compression service (LiteLLM calls its /v1/compress endpoint)"
|
||||
extra="The URL where your Headroom compression service is hosted"
|
||||
rules={[{ required: true, message: "API base is required" }]}
|
||||
>
|
||||
<Input placeholder="https://your-headroom-endpoint" />
|
||||
</Form.Item>
|
||||
<Form.Item name="defaultOn" label="Apply to all requests" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<div className="mb-4 rounded-lg border border-yellow-200 bg-yellow-50 p-3">
|
||||
<form onSubmit={handleAdd} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="compression-name">Name</Label>
|
||||
<Input
|
||||
id="compression-name"
|
||||
required
|
||||
placeholder="headroom-compression"
|
||||
value={formValues.name}
|
||||
onChange={(event) => setFormValues({ ...formValues, name: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="compression-api-base">Headroom API base</Label>
|
||||
<Input
|
||||
id="compression-api-base"
|
||||
required
|
||||
placeholder="https://your-headroom-endpoint"
|
||||
value={formValues.apiBase}
|
||||
onChange={(event) => setFormValues({ ...formValues, apiBase: event.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Base URL of your Headroom compression service; LiteLLM calls its /v1/compress endpoint
|
||||
</p>
|
||||
</div>
|
||||
<Label htmlFor="compression-default-on">
|
||||
<Switch
|
||||
id="compression-default-on"
|
||||
checked={formValues.defaultOn}
|
||||
onCheckedChange={(checked) => setFormValues({ ...formValues, defaultOn: checked })}
|
||||
/>
|
||||
Apply to all requests
|
||||
</Label>
|
||||
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-3">
|
||||
<p className="text-sm text-yellow-800">
|
||||
Applying compression to all requests is available to all users. Enabling it selectively per key or team
|
||||
is a LiteLLM Enterprise feature. Get a trial key{" "}
|
||||
|
|
@ -162,15 +183,15 @@ const PromptCompressionTab: React.FC<PromptCompressionTabProps> = ({ accessToken
|
|||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button type="primary" htmlType="submit" loading={isSaving}>
|
||||
Add guardrail
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? "Adding..." : "Add guardrail"}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PromptCompressionTab;
|
||||
export default CompressionView;
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import CompressionView from "./_components/CompressionView";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
|
||||
export default function CompressionPage() {
|
||||
const { accessToken } = useAuthorized();
|
||||
return <CompressionView accessToken={accessToken} />;
|
||||
}
|
||||
|
|
@ -26,8 +26,6 @@ vi.mock("@/app/(dashboard)/router-settings/_components/general_settings", () =>
|
|||
PromptCachingPanel: () => <div data-testid="caching-settings" />,
|
||||
}));
|
||||
|
||||
vi.mock("./PromptCompressionTab", () => ({ __esModule: true, default: () => <div /> }));
|
||||
|
||||
import CostOptimizationView from "./CostOptimizationView";
|
||||
|
||||
const singlePage = {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { fireEvent, render } from "@testing-library/react";
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./UsageTab", () => ({ __esModule: true, default: () => <div data-testid="usage-tab" /> }));
|
||||
vi.mock("./PromptCompressionTab", () => ({ __esModule: true, default: () => <div data-testid="compression-tab" /> }));
|
||||
vi.mock("./PromptCachingTab", () => ({ __esModule: true, default: () => <div data-testid="caching-tab" /> }));
|
||||
|
||||
import CostOptimizationView from "./CostOptimizationView";
|
||||
|
|
@ -10,12 +9,12 @@ import CostOptimizationView from "./CostOptimizationView";
|
|||
const renderView = () => render(<CostOptimizationView accessToken="test-token" userId="u1" userRole="proxy_admin" />);
|
||||
|
||||
describe("CostOptimizationView", () => {
|
||||
it("renders the three cost-optimization tabs and no autorouter tab", () => {
|
||||
it("renders the reporting tabs, with compression config moved to its own page", () => {
|
||||
const { getByText, queryByText } = renderView();
|
||||
|
||||
expect(getByText("Usage")).toBeInTheDocument();
|
||||
expect(getByText("Prompt Compression")).toBeInTheDocument();
|
||||
expect(getByText("Prompt Caching")).toBeInTheDocument();
|
||||
expect(queryByText("Prompt Compression")).not.toBeInTheDocument();
|
||||
expect(queryByText("Autorouter")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -23,11 +22,11 @@ describe("CostOptimizationView", () => {
|
|||
const { getByRole } = renderView();
|
||||
|
||||
expect(getByRole("tab", { name: "Usage" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(getByRole("tab", { name: "Prompt Compression" })).toHaveAttribute("aria-selected", "false");
|
||||
expect(getByRole("tab", { name: "Prompt Caching" })).toHaveAttribute("aria-selected", "false");
|
||||
|
||||
fireEvent.click(getByRole("tab", { name: "Prompt Compression" }));
|
||||
fireEvent.click(getByRole("tab", { name: "Prompt Caching" }));
|
||||
|
||||
expect(getByRole("tab", { name: "Usage" })).toHaveAttribute("aria-selected", "false");
|
||||
expect(getByRole("tab", { name: "Prompt Compression" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(getByRole("tab", { name: "Prompt Caching" })).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { PiggyBank } from "lucide-react";
|
|||
import { Alert, Tabs } from "antd";
|
||||
|
||||
import UsageTab from "./UsageTab";
|
||||
import PromptCompressionTab from "./PromptCompressionTab";
|
||||
import PromptCachingTab from "./PromptCachingTab";
|
||||
import { useDailyActivityRange } from "./useDailyActivityRange";
|
||||
|
||||
|
|
@ -24,11 +23,6 @@ const CostOptimizationView: React.FC<CostOptimizationViewProps> = ({ accessToken
|
|||
label: "Usage",
|
||||
children: <UsageTab accessToken={accessToken} activity={activity} />,
|
||||
},
|
||||
{
|
||||
key: "compression",
|
||||
label: "Prompt Compression",
|
||||
children: <PromptCompressionTab accessToken={accessToken} />,
|
||||
},
|
||||
{
|
||||
key: "caching",
|
||||
label: "Prompt Caching",
|
||||
|
|
@ -44,8 +38,8 @@ const CostOptimizationView: React.FC<CostOptimizationViewProps> = ({ accessToken
|
|||
<h1 className="text-xl font-semibold text-foreground">Cost Optimization</h1>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Track and configure the mechanisms that save you money: prompt compression and prompt caching. Auto routers
|
||||
live under Models + Endpoints, on the Auto-Routers tab
|
||||
Track what the money-saving mechanisms are actually saving you. Prompt compression is configured under AI
|
||||
Gateway, on the Compression page; auto routers live under Models + Endpoints, on the Auto-Routers tab
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ describe("Sidebar (leftnav)", () => {
|
|||
"Agentic",
|
||||
"MCP Servers",
|
||||
"Guardrails",
|
||||
"Compression",
|
||||
"Policies",
|
||||
"Tools",
|
||||
"Usage",
|
||||
|
|
@ -286,6 +287,10 @@ describe("getBreadcrumb", () => {
|
|||
expect(getBreadcrumb("logs")).toEqual({ section: "Observability", title: "Logs" });
|
||||
});
|
||||
|
||||
it("resolves compression as a top-level AI Gateway page", () => {
|
||||
expect(getBreadcrumb("compression")).toEqual({ section: "AI Gateway", title: "Compression" });
|
||||
});
|
||||
|
||||
it("resolves a nested child page to its parent section", () => {
|
||||
expect(getBreadcrumb("search-tools")).toEqual({ section: "AI Gateway", title: "Search Tools" });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ import {
|
|||
Settings as SettingsIcon,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
Shrink,
|
||||
Tags,
|
||||
Terminal,
|
||||
User,
|
||||
|
|
@ -152,6 +153,13 @@ const menuGroups: MenuGroup[] = [
|
|||
{ key: "mcp-servers", page: "mcp-servers", label: "MCP Servers", icon: <Server {...ICON} /> },
|
||||
{ key: "skills", page: "skills", label: "Skills", icon: <Blocks {...ICON} />, roles: all_admin_roles },
|
||||
{ key: "guardrails", page: "guardrails", label: "Guardrails", icon: <Shield {...ICON} /> },
|
||||
{
|
||||
key: "compression",
|
||||
page: "compression",
|
||||
label: "Compression",
|
||||
icon: <Shrink {...ICON} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "policies",
|
||||
page: "policies",
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ export const pageDescriptions: Record<string, string> = {
|
|||
"tool-policies": "Configure tool use policies and permissions",
|
||||
"vector-stores": "Manage vector databases for embeddings",
|
||||
new_usage: "View usage analytics and metrics",
|
||||
"cost-optimization": "Track and configure cost-saving features: prompt compression, caching, and auto routing",
|
||||
"cost-optimization": "Track savings from cost-saving features: prompt compression, caching, and auto routing",
|
||||
compression: "Configure prompt compression so requests are billed for fewer input tokens",
|
||||
logs: "Access request and response logs",
|
||||
"guardrails-monitor": "Monitor guardrail performance and view logs",
|
||||
users: "Manage internal user accounts and permissions",
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ export const MIGRATED_PAGES: Record<string, string> = {
|
|||
new_usage: "usage",
|
||||
usage: "old-usage",
|
||||
"cost-optimization": "cost-optimization",
|
||||
compression: "compression",
|
||||
agents: "agents",
|
||||
"router-settings": "router-settings",
|
||||
users: "users",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue