mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
feat(ui): make the Headroom compression card editable
The Prompt Compression tab could only create a Headroom guardrail and then show it as a static "Always on" badge; switching that endpoint to opt-in, or changing anything else about it, meant hunting for the guardrail by name on the Guardrails page. The tab now owns the one knob that belongs to a cost page, an Always on / Opt-in select that PATCHes only default_on so the rest of litellm_params survives the merge, and hands off everything else (api_key, model, unreachable_fallback, delete) to the guardrail detail view it now links to. Picking Opt-in explains what a caller has to send for compression to run, since the mode is otherwise invisible from the request side. Deep linking needed the guardrail detail view to be addressable, so its selection moved out of GuardrailsPanel state into ?guardrail= and ?guardrail_tab= query params, following the useKeyDetailRouting pattern the api-keys page already uses. That also gives the Guardrails page working back navigation and shareable links to a single guardrail. Create, edit, and the mode select are gated on admin, matching the backend: internal users may call /guardrails/list but not POST or PATCH /guardrails, so the old always-visible form handed non-admins a guaranteed 403. Guardrails defined in the proxy config file stay read-only here for the same reason the detail page refuses to edit them. The card was rebuilt on shadcn/ui primitives (Card, Select, Badge, Button, Input, Label, Switch, Skeleton) and no longer imports antd, which is lint-banned for new dashboard UI; its eslint suppression is pruned.
This commit is contained in:
parent
daf22ec871
commit
49c65cead8
11 changed files with 696 additions and 122 deletions
|
|
@ -220,11 +220,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
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ const CostOptimizationView: React.FC<CostOptimizationViewProps> = ({ accessToken
|
|||
{
|
||||
key: "compression",
|
||||
label: "Prompt Compression",
|
||||
children: <PromptCompressionTab accessToken={accessToken} />,
|
||||
children: <PromptCompressionTab accessToken={accessToken} userRole={userRole} />,
|
||||
},
|
||||
{
|
||||
key: "autorouter",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,185 @@
|
|||
/* @vitest-environment jsdom */
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockGetGuardrailsList = vi.fn();
|
||||
const mockCreateGuardrailCall = vi.fn();
|
||||
const mockUpdateGuardrailCall = vi.fn();
|
||||
const mockPush = vi.fn();
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
serverRootPath: "/",
|
||||
getGuardrailsList: (...args: unknown[]) => mockGetGuardrailsList(...args),
|
||||
createGuardrailCall: (...args: unknown[]) => mockCreateGuardrailCall(...args),
|
||||
updateGuardrailCall: (...args: unknown[]) => mockUpdateGuardrailCall(...args),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: mockPush }) }));
|
||||
|
||||
vi.mock("@/components/molecules/notifications_manager", () => ({
|
||||
__esModule: true,
|
||||
default: { success: vi.fn(), fromBackend: vi.fn() },
|
||||
}));
|
||||
|
||||
import PromptCompressionTab from "./PromptCompressionTab";
|
||||
|
||||
const compressionGuardrail = (overrides: Record<string, unknown> = {}) => ({
|
||||
guardrail_id: "fee65a60",
|
||||
guardrail_name: "headroom-compression",
|
||||
litellm_params: { guardrail: "headroom", api_base: "https://headroom.example.com", default_on: true },
|
||||
guardrail_definition_location: "db",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("PromptCompressionTab", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetGuardrailsList.mockResolvedValue({ guardrails: [compressionGuardrail()] });
|
||||
});
|
||||
|
||||
it("switches an always-on endpoint to opt-in without clobbering its other params", async () => {
|
||||
mockUpdateGuardrailCall.mockResolvedValue({});
|
||||
mockGetGuardrailsList.mockResolvedValueOnce({ guardrails: [compressionGuardrail()] }).mockResolvedValueOnce({
|
||||
guardrails: [
|
||||
compressionGuardrail({
|
||||
litellm_params: { guardrail: "headroom", api_base: "https://headroom.example.com", default_on: false },
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<PromptCompressionTab accessToken="sk-test" userRole="Admin" />);
|
||||
|
||||
await user.click(await screen.findByLabelText("Compression mode for headroom-compression"));
|
||||
await user.click(await screen.findByRole("option", { name: "Opt-in" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockUpdateGuardrailCall).toHaveBeenCalledWith("sk-test", "fee65a60", {
|
||||
litellm_params: { default_on: false },
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByText(/only runs when a request asks for it/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sends default_on true when switching back to always on", async () => {
|
||||
mockUpdateGuardrailCall.mockResolvedValue({});
|
||||
mockGetGuardrailsList.mockResolvedValue({
|
||||
guardrails: [
|
||||
compressionGuardrail({
|
||||
litellm_params: { guardrail: "headroom", api_base: "https://headroom.example.com", default_on: false },
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<PromptCompressionTab accessToken="sk-test" userRole="Admin" />);
|
||||
|
||||
await user.click(await screen.findByLabelText("Compression mode for headroom-compression"));
|
||||
await user.click(await screen.findByRole("option", { name: "Always on" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockUpdateGuardrailCall).toHaveBeenCalledWith("sk-test", "fee65a60", {
|
||||
litellm_params: { default_on: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("deep-links Edit settings to the guardrail's settings tab", async () => {
|
||||
render(<PromptCompressionTab accessToken="sk-test" userRole="Admin" />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: /Edit settings/ }));
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith("/ui/guardrails?guardrail=fee65a60&guardrail_tab=settings");
|
||||
});
|
||||
|
||||
it("shows a read-only badge instead of edit controls for non-admins", async () => {
|
||||
render(<PromptCompressionTab accessToken="sk-test" userRole="Internal User" />);
|
||||
|
||||
expect(await screen.findByText("Always on")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Compression mode for headroom-compression")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Edit settings/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps a config-file guardrail read-only even for an admin", async () => {
|
||||
mockGetGuardrailsList.mockResolvedValue({
|
||||
guardrails: [compressionGuardrail({ guardrail_definition_location: "config" })],
|
||||
});
|
||||
|
||||
render(<PromptCompressionTab accessToken="sk-test" userRole="Admin" />);
|
||||
|
||||
expect(await screen.findByText(/Defined in the proxy config file/)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Compression mode for headroom-compression")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("creates a guardrail from the empty state and refreshes the list", async () => {
|
||||
mockGetGuardrailsList.mockResolvedValueOnce({ guardrails: [] }).mockResolvedValueOnce({
|
||||
guardrails: [compressionGuardrail()],
|
||||
});
|
||||
mockCreateGuardrailCall.mockResolvedValue({});
|
||||
|
||||
render(<PromptCompressionTab accessToken="sk-test" userRole="Admin" />);
|
||||
|
||||
fireEvent.change(await screen.findByLabelText("Name"), { target: { value: "headroom-compression" } });
|
||||
fireEvent.change(screen.getByLabelText("Headroom API base"), {
|
||||
target: { value: "https://headroom.example.com" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add guardrail" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockCreateGuardrailCall).toHaveBeenCalledWith("sk-test", {
|
||||
guardrail_name: "headroom-compression",
|
||||
litellm_params: {
|
||||
guardrail: "headroom",
|
||||
mode: "pre_call",
|
||||
api_base: "https://headroom.example.com",
|
||||
default_on: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByText("headroom-compression")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("blocks submission and flags the empty fields instead of creating a blank guardrail", async () => {
|
||||
mockGetGuardrailsList.mockResolvedValue({ guardrails: [] });
|
||||
|
||||
render(<PromptCompressionTab accessToken="sk-test" userRole="Admin" />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Add guardrail" }));
|
||||
|
||||
expect(await screen.findByText("Name is required")).toBeInTheDocument();
|
||||
expect(screen.getByText("API base is required")).toBeInTheDocument();
|
||||
expect(mockCreateGuardrailCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hides the add form behind an explicit action once an endpoint exists", async () => {
|
||||
render(<PromptCompressionTab accessToken="sk-test" userRole="Admin" />);
|
||||
|
||||
expect(await screen.findByText("headroom-compression")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Headroom API base")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Add another endpoint/ }));
|
||||
|
||||
expect(await screen.findByLabelText("Headroom API base")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ignores guardrails from other providers", async () => {
|
||||
mockGetGuardrailsList.mockResolvedValue({
|
||||
guardrails: [
|
||||
compressionGuardrail(),
|
||||
{
|
||||
guardrail_id: "other",
|
||||
guardrail_name: "presidio-pii",
|
||||
litellm_params: { guardrail: "presidio", default_on: true },
|
||||
guardrail_definition_location: "db",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<PromptCompressionTab accessToken="sk-test" userRole="Admin" />);
|
||||
|
||||
const list = await screen.findByRole("list");
|
||||
expect(within(list).getAllByRole("listitem")).toHaveLength(1);
|
||||
expect(screen.queryByText("presidio-pii")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,39 +1,139 @@
|
|||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Button, Form, Input, Switch } from "antd";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Plus, Settings2 } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { createGuardrailCall, getGuardrailsList } from "@/components/networking";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { createGuardrailCall, getGuardrailsList, updateGuardrailCall } from "@/components/networking";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import { guardrailDetailHref } from "@/app/(dashboard)/guardrails/detailNavigation";
|
||||
import { isAdminRole } from "@/utils/roles";
|
||||
import {
|
||||
buildCompressionGuardrailPayload,
|
||||
compressionGuardrailsOf,
|
||||
GuardrailListItem,
|
||||
GuardrailListResponse,
|
||||
isConfigDefinedGuardrail,
|
||||
} from "./helpers";
|
||||
|
||||
interface PromptCompressionTabProps {
|
||||
accessToken: string | null;
|
||||
userRole?: string;
|
||||
}
|
||||
|
||||
interface CompressionFormValues {
|
||||
name: string;
|
||||
apiBase: string;
|
||||
defaultOn: boolean;
|
||||
type CompressionMode = "always" | "opt_in";
|
||||
|
||||
const MODE_LABELS: Readonly<Record<CompressionMode, string>> = {
|
||||
always: "Always on",
|
||||
opt_in: "Opt-in",
|
||||
};
|
||||
|
||||
const modeOf = (guardrail: GuardrailListItem): CompressionMode =>
|
||||
guardrail.litellm_params?.default_on ? "always" : "opt_in";
|
||||
|
||||
interface CompressionEndpointRowProps {
|
||||
guardrail: GuardrailListItem;
|
||||
canEdit: boolean;
|
||||
isPending: boolean;
|
||||
onModeChange: (guardrail: GuardrailListItem, mode: CompressionMode) => void;
|
||||
onEditSettings: (guardrail: GuardrailListItem) => void;
|
||||
}
|
||||
|
||||
const PromptCompressionTab: React.FC<PromptCompressionTabProps> = ({ accessToken }) => {
|
||||
const [form] = Form.useForm<CompressionFormValues>();
|
||||
const CompressionEndpointRow: React.FC<CompressionEndpointRowProps> = ({
|
||||
guardrail,
|
||||
canEdit,
|
||||
isPending,
|
||||
onModeChange,
|
||||
onEditSettings,
|
||||
}) => {
|
||||
const mode = modeOf(guardrail);
|
||||
const name = guardrail.guardrail_name ?? guardrail.guardrail_id;
|
||||
const isFromConfig = isConfigDefinedGuardrail(guardrail);
|
||||
const isEditable = canEdit && !isFromConfig;
|
||||
|
||||
const handleSelect = (value: unknown) => {
|
||||
if (value !== "always" && value !== "opt_in") {
|
||||
return;
|
||||
}
|
||||
onModeChange(guardrail, value);
|
||||
};
|
||||
|
||||
return (
|
||||
<li className="flex flex-wrap items-center justify-between gap-4 p-4">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-foreground">{name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{guardrail.litellm_params?.api_base ?? ""}</p>
|
||||
{isFromConfig && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Defined in the proxy config file, so it is read-only here
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{isEditable ? (
|
||||
<Select value={mode} onValueChange={handleSelect} disabled={isPending}>
|
||||
<SelectTrigger size="sm" className="w-[124px]" aria-label={`Compression mode for ${name}`}>
|
||||
<SelectValue>{MODE_LABELS[mode]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(MODE_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Badge variant={mode === "always" ? "secondary" : "outline"}>{MODE_LABELS[mode]}</Badge>
|
||||
)}
|
||||
|
||||
{canEdit && (
|
||||
<Button variant="outline" size="sm" onClick={() => onEditSettings(guardrail)}>
|
||||
<Settings2 />
|
||||
Edit settings
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{mode === "opt_in" && (
|
||||
<p className="w-full rounded-md bg-muted px-3 py-2 text-xs text-muted-foreground">
|
||||
Compression only runs when a request asks for it:{" "}
|
||||
<code className="font-mono text-foreground">"guardrails": ["{name}"]</code> in the request
|
||||
body. Turning it on for a specific key or team instead is a LiteLLM Enterprise feature
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const PromptCompressionTab: React.FC<PromptCompressionTabProps> = ({ accessToken, userRole }) => {
|
||||
const router = useRouter();
|
||||
const isAdmin = userRole ? isAdminRole(userRole) : false;
|
||||
|
||||
const [guardrails, setGuardrails] = useState<GuardrailListItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isSaving, setIsSaving] = useState<boolean>(false);
|
||||
const [pendingModeId, setPendingModeId] = useState<string | null>(null);
|
||||
const [isAddFormOpen, setIsAddFormOpen] = useState<boolean>(false);
|
||||
const [name, setName] = useState<string>("");
|
||||
const [apiBase, setApiBase] = useState<string>("");
|
||||
const [defaultOn, setDefaultOn] = useState<boolean>(true);
|
||||
const [showFieldErrors, setShowFieldErrors] = useState<boolean>(false);
|
||||
|
||||
const loadGuardrails = useCallback(() => {
|
||||
if (!accessToken) {
|
||||
return;
|
||||
return Promise.resolve();
|
||||
}
|
||||
getGuardrailsList(accessToken)
|
||||
return getGuardrailsList(accessToken)
|
||||
.then((response) => setGuardrails(compressionGuardrailsOf(response as GuardrailListResponse)))
|
||||
.catch((error) => {
|
||||
console.error("Failed to load compression guardrails:", error);
|
||||
|
|
@ -46,22 +146,50 @@ const PromptCompressionTab: React.FC<PromptCompressionTabProps> = ({ accessToken
|
|||
loadGuardrails();
|
||||
}, [loadGuardrails]);
|
||||
|
||||
const handleAdd = async (values: CompressionFormValues) => {
|
||||
const handleModeChange = async (guardrail: GuardrailListItem, mode: CompressionMode) => {
|
||||
if (!accessToken) {
|
||||
return;
|
||||
}
|
||||
const nextDefaultOn = mode === "always";
|
||||
setPendingModeId(guardrail.guardrail_id);
|
||||
try {
|
||||
await updateGuardrailCall(accessToken, guardrail.guardrail_id, {
|
||||
litellm_params: { default_on: nextDefaultOn },
|
||||
});
|
||||
NotificationsManager.success(
|
||||
nextDefaultOn ? "Compression now runs on every request" : "Compression is now opt-in per request",
|
||||
);
|
||||
await loadGuardrails();
|
||||
} catch (error) {
|
||||
console.error("Failed to update compression guardrail:", error);
|
||||
NotificationsManager.fromBackend("Failed to update compression guardrail");
|
||||
} finally {
|
||||
setPendingModeId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditSettings = (guardrail: GuardrailListItem) => {
|
||||
router.push(guardrailDetailHref(guardrail.guardrail_id, "settings"));
|
||||
};
|
||||
|
||||
const handleAdd = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!accessToken) {
|
||||
return;
|
||||
}
|
||||
if (!name.trim() || !apiBase.trim()) {
|
||||
setShowFieldErrors(true);
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await createGuardrailCall(
|
||||
accessToken,
|
||||
buildCompressionGuardrailPayload({
|
||||
name: values.name,
|
||||
apiBase: values.apiBase,
|
||||
defaultOn: values.defaultOn ?? true,
|
||||
}),
|
||||
);
|
||||
await createGuardrailCall(accessToken, buildCompressionGuardrailPayload({ name, apiBase, defaultOn }));
|
||||
NotificationsManager.success("Compression guardrail created");
|
||||
form.resetFields();
|
||||
setName("");
|
||||
setApiBase("");
|
||||
setDefaultOn(true);
|
||||
setShowFieldErrors(false);
|
||||
setIsAddFormOpen(false);
|
||||
await loadGuardrails();
|
||||
} catch (error) {
|
||||
console.error("Failed to create compression guardrail:", error);
|
||||
|
|
@ -71,105 +199,124 @@ const PromptCompressionTab: React.FC<PromptCompressionTabProps> = ({ accessToken
|
|||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Headroom prompt compression</CardTitle>
|
||||
</CardHeader>
|
||||
<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.{" "}
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/proxy/headroom"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 underline"
|
||||
>
|
||||
Headroom setup docs
|
||||
</a>
|
||||
</p>
|
||||
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
||||
{!isLoading && guardrails.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No prompt compression guardrails configured yet. Add one below to start saving on input tokens
|
||||
</p>
|
||||
)}
|
||||
{!isLoading && guardrails.length > 0 && (
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{guardrails.map((guardrail) => (
|
||||
<li key={guardrail.guardrail_id} className="flex items-center justify-between py-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">{guardrail.guardrail_name}</p>
|
||||
<p className="text-xs text-muted-foreground">{guardrail.litellm_params?.api_base ?? ""}</p>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
guardrail.litellm_params?.default_on
|
||||
? "bg-emerald-100 text-emerald-800"
|
||||
: "bg-gray-100 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{guardrail.litellm_params?.default_on ? "Always on" : "Opt-in"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
const hasGuardrails = guardrails.length > 0;
|
||||
const isFormVisible = isAdmin && !isLoading && (!hasGuardrails || isAddFormOpen);
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Add Headroom compression guardrail</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
requiredMark={false}
|
||||
onFinish={handleAdd}
|
||||
initialValues={{ defaultOn: true }}
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Headroom prompt compression</CardTitle>
|
||||
<CardDescription>
|
||||
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.{" "}
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/proxy/headroom"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium text-primary underline underline-offset-4"
|
||||
>
|
||||
<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">
|
||||
<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{" "}
|
||||
Headroom setup docs
|
||||
</a>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{isLoading && <Skeleton className="h-14 w-full" />}
|
||||
|
||||
{!isLoading && hasGuardrails && (
|
||||
<ul className="divide-y divide-border rounded-lg border border-border">
|
||||
{guardrails.map((guardrail) => (
|
||||
<CompressionEndpointRow
|
||||
key={guardrail.guardrail_id}
|
||||
guardrail={guardrail}
|
||||
canEdit={isAdmin}
|
||||
isPending={pendingModeId === guardrail.guardrail_id}
|
||||
onModeChange={handleModeChange}
|
||||
onEditSettings={handleEditSettings}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{!isLoading && !hasGuardrails && !isFormVisible && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No prompt compression endpoint is configured. An admin can add one to start saving on input tokens
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isLoading && hasGuardrails && isAdmin && !isAddFormOpen && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setIsAddFormOpen(true)}>
|
||||
<Plus />
|
||||
Add another endpoint
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isFormVisible && (
|
||||
<form onSubmit={handleAdd} className="space-y-4 rounded-lg border border-border p-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="compression-name">Name</Label>
|
||||
<Input
|
||||
id="compression-name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="headroom-compression"
|
||||
aria-invalid={showFieldErrors && !name.trim()}
|
||||
/>
|
||||
{showFieldErrors && !name.trim() && <p className="text-xs text-destructive">Name is required</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="compression-api-base">Headroom API base</Label>
|
||||
<Input
|
||||
id="compression-api-base"
|
||||
value={apiBase}
|
||||
onChange={(event) => setApiBase(event.target.value)}
|
||||
placeholder="https://your-headroom-endpoint"
|
||||
aria-invalid={showFieldErrors && !apiBase.trim()}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Where your Headroom compression service is hosted; LiteLLM calls its /v1/compress endpoint
|
||||
</p>
|
||||
{showFieldErrors && !apiBase.trim() && <p className="text-xs text-destructive">API base is required</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="compression-default-on">
|
||||
<Switch
|
||||
id="compression-default-on"
|
||||
checked={defaultOn}
|
||||
onCheckedChange={(checked) => setDefaultOn(checked)}
|
||||
/>
|
||||
Apply to all requests
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Off means callers opt in per request. Applying compression to all requests is available to all users;
|
||||
enabling it selectively per key or team is a LiteLLM Enterprise feature.{" "}
|
||||
<a
|
||||
href="https://www.litellm.ai/#pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
className="font-medium text-primary underline underline-offset-4"
|
||||
>
|
||||
here
|
||||
Get a trial key
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button type="primary" htmlType="submit" loading={isSaving}>
|
||||
Add guardrail
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
{hasGuardrails && (
|
||||
<Button type="button" variant="ghost" onClick={() => setIsAddFormOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? "Adding..." : "Add guardrail"}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { GuardrailDefinitionLocation } from "@/components/guardrails/types";
|
||||
|
||||
export interface GuardrailLitellmParams {
|
||||
guardrail?: string | null;
|
||||
api_base?: string | null;
|
||||
|
|
@ -8,6 +10,7 @@ export interface GuardrailListItem {
|
|||
guardrail_id: string;
|
||||
guardrail_name: string | null;
|
||||
litellm_params?: GuardrailLitellmParams | null;
|
||||
guardrail_definition_location?: GuardrailDefinitionLocation | null;
|
||||
}
|
||||
|
||||
export interface GuardrailListResponse {
|
||||
|
|
@ -22,6 +25,10 @@ export const isCompressionGuardrail = (guardrail: GuardrailListItem): boolean =>
|
|||
export const compressionGuardrailsOf = (response: GuardrailListResponse): GuardrailListItem[] =>
|
||||
(response.guardrails ?? []).filter(isCompressionGuardrail);
|
||||
|
||||
/** Guardrails declared in the proxy config file are owned by that file; the API cannot rewrite them. */
|
||||
export const isConfigDefinedGuardrail = (guardrail: GuardrailListItem): boolean =>
|
||||
guardrail.guardrail_definition_location === GuardrailDefinitionLocation.CONFIG;
|
||||
|
||||
export interface CompressionGuardrailInput {
|
||||
name: string;
|
||||
apiBase: string;
|
||||
|
|
|
|||
|
|
@ -30,9 +30,18 @@ vi.mock("./guardrail_table", () => ({
|
|||
),
|
||||
}));
|
||||
|
||||
const mockGuardrailInfoProps = vi.fn();
|
||||
|
||||
vi.mock("./guardrail_info", () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>Mock Guardrail Info View</div>,
|
||||
default: (props: any) => {
|
||||
mockGuardrailInfoProps(props);
|
||||
return <div>Mock Guardrail Info View</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useSearchParams: () => new URLSearchParams(window.location.search),
|
||||
}));
|
||||
|
||||
vi.mock("./GuardrailTestPlayground", () => ({
|
||||
|
|
@ -83,6 +92,7 @@ describe("GuardrailsPanel", () => {
|
|||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.history.pushState(null, "", "/guardrails");
|
||||
mockGetGuardrailsList.mockResolvedValue({
|
||||
guardrails: [
|
||||
{
|
||||
|
|
@ -129,6 +139,27 @@ describe("GuardrailsPanel", () => {
|
|||
expect(mockGetGuardrailsList).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should open the detail view on the settings tab from a deep link", async () => {
|
||||
window.history.pushState(null, "", "/guardrails?guardrail=test-guardrail-1&guardrail_tab=settings");
|
||||
|
||||
render(<GuardrailsPanel {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText("Guardrails"));
|
||||
|
||||
expect(await screen.findByText("Mock Guardrail Info View")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Mock Guardrail Table")).not.toBeInTheDocument();
|
||||
expect(mockGuardrailInfoProps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ guardrailId: "test-guardrail-1", initialTab: "settings" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("should show the table when no guardrail is deep-linked", async () => {
|
||||
render(<GuardrailsPanel {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText("Guardrails"));
|
||||
|
||||
expect(await screen.findByText("Mock Guardrail Table")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Mock Guardrail Info View")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not delete anything when the modal is cancelled", async () => {
|
||||
render(<GuardrailsPanel {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText("Guardrails"));
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { getGuardrailLogoAndName } from "./guardrail_info_helpers";
|
|||
import { CustomCodeModal } from "./custom_code";
|
||||
import GuardrailGarden from "./guardrail_garden";
|
||||
import { TeamGuardrailsTab } from "./TeamGuardrailsTab";
|
||||
import { useGuardrailDetailRouting } from "../detailNavigation";
|
||||
|
||||
interface GuardrailsPanelProps {
|
||||
accessToken: string | null;
|
||||
|
|
@ -40,7 +41,12 @@ const GuardrailsPanel: React.FC<GuardrailsPanelProps> = ({ accessToken, userRole
|
|||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [guardrailToDelete, setGuardrailToDelete] = useState<Guardrail | null>(null);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedGuardrailId, setSelectedGuardrailId] = useState<string | null>(null);
|
||||
const {
|
||||
guardrailId: selectedGuardrailId,
|
||||
tab: guardrailDetailTab,
|
||||
openGuardrail,
|
||||
close,
|
||||
} = useGuardrailDetailRouting();
|
||||
const isAdmin = userRole ? isAdminRole(userRole) : false;
|
||||
|
||||
const fetchGuardrails = async () => {
|
||||
|
|
@ -65,14 +71,14 @@ const GuardrailsPanel: React.FC<GuardrailsPanelProps> = ({ accessToken, userRole
|
|||
|
||||
const handleAddGuardrail = () => {
|
||||
if (selectedGuardrailId) {
|
||||
setSelectedGuardrailId(null);
|
||||
close();
|
||||
}
|
||||
setIsAddModalVisible(true);
|
||||
};
|
||||
|
||||
const handleAddCustomCodeGuardrail = () => {
|
||||
if (selectedGuardrailId) {
|
||||
setSelectedGuardrailId(null);
|
||||
close();
|
||||
}
|
||||
setIsCustomCodeModalVisible(true);
|
||||
};
|
||||
|
|
@ -166,16 +172,17 @@ const GuardrailsPanel: React.FC<GuardrailsPanelProps> = ({ accessToken, userRole
|
|||
{selectedGuardrailId ? (
|
||||
<GuardrailInfoView
|
||||
guardrailId={selectedGuardrailId}
|
||||
onClose={() => setSelectedGuardrailId(null)}
|
||||
onClose={close}
|
||||
accessToken={accessToken}
|
||||
isAdmin={isAdmin}
|
||||
initialTab={guardrailDetailTab}
|
||||
/>
|
||||
) : (
|
||||
<GuardrailTable
|
||||
guardrailsList={guardrailsList}
|
||||
isLoading={isLoading}
|
||||
onDeleteClick={handleDeleteClick}
|
||||
onGuardrailClick={(id) => setSelectedGuardrailId(id)}
|
||||
onGuardrailClick={openGuardrail}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -166,6 +166,74 @@ describe("Guardrail Info", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("should open on the settings tab when linked to it, without a second click", async () => {
|
||||
vi.mocked(networking.getGuardrailInfo).mockResolvedValue({
|
||||
guardrail_id: "123",
|
||||
guardrail_name: "Test Guardrail",
|
||||
litellm_params: {
|
||||
guardrail: "headroom",
|
||||
mode: "pre_call",
|
||||
default_on: true,
|
||||
},
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
guardrail_definition_location: "database",
|
||||
});
|
||||
|
||||
vi.mocked(networking.getGuardrailUISettings).mockResolvedValue({
|
||||
supported_entities: [],
|
||||
supported_actions: [],
|
||||
pii_entity_categories: [],
|
||||
supported_modes: ["pre_call"],
|
||||
});
|
||||
|
||||
vi.mocked(networking.getGuardrailProviderSpecificParams).mockResolvedValue({});
|
||||
|
||||
const { findByText, getByRole } = render(
|
||||
<GuardrailInfoView guardrailId="123" onClose={() => {}} accessToken="123" isAdmin={true} initialTab="settings" />,
|
||||
);
|
||||
|
||||
expect(await findByText("Guardrail Settings")).toBeInTheDocument();
|
||||
expect(getByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should stay on the overview tab for a non-admin linked to the settings tab", async () => {
|
||||
vi.mocked(networking.getGuardrailInfo).mockResolvedValue({
|
||||
guardrail_id: "123",
|
||||
guardrail_name: "Test Guardrail",
|
||||
litellm_params: {
|
||||
guardrail: "headroom",
|
||||
mode: "pre_call",
|
||||
default_on: true,
|
||||
},
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
guardrail_definition_location: "database",
|
||||
});
|
||||
|
||||
vi.mocked(networking.getGuardrailUISettings).mockResolvedValue({
|
||||
supported_entities: [],
|
||||
supported_actions: [],
|
||||
pii_entity_categories: [],
|
||||
supported_modes: ["pre_call"],
|
||||
});
|
||||
|
||||
vi.mocked(networking.getGuardrailProviderSpecificParams).mockResolvedValue({});
|
||||
|
||||
const { findAllByText, queryByText } = render(
|
||||
<GuardrailInfoView
|
||||
guardrailId="123"
|
||||
onClose={() => {}}
|
||||
accessToken="123"
|
||||
isAdmin={false}
|
||||
initialTab="settings"
|
||||
/>,
|
||||
);
|
||||
|
||||
await findAllByText("Test Guardrail");
|
||||
expect(queryByText("Guardrail Settings")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the guardrail info", async () => {
|
||||
// Mock the network responses
|
||||
vi.mocked(networking.getGuardrailInfo).mockResolvedValue({
|
||||
|
|
|
|||
|
|
@ -27,12 +27,14 @@ import GuardrailOptionalParams from "./guardrail_optional_params";
|
|||
import GuardrailProviderFields from "./guardrail_provider_fields";
|
||||
import PiiConfiguration from "./pii_configuration";
|
||||
import ToolPermissionRulesEditor, { ToolPermissionConfig } from "./tool_permission/ToolPermissionRulesEditor";
|
||||
import type { GuardrailDetailTab } from "../detailNavigation";
|
||||
|
||||
export interface GuardrailInfoProps {
|
||||
guardrailId: string;
|
||||
onClose: () => void;
|
||||
accessToken: string | null;
|
||||
isAdmin: boolean;
|
||||
initialTab?: GuardrailDetailTab;
|
||||
}
|
||||
|
||||
interface ProviderParam {
|
||||
|
|
@ -51,7 +53,13 @@ interface ProviderParamsResponse {
|
|||
[provider: string]: { [key: string]: ProviderParam };
|
||||
}
|
||||
|
||||
const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose, accessToken, isAdmin }) => {
|
||||
const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({
|
||||
guardrailId,
|
||||
onClose,
|
||||
accessToken,
|
||||
isAdmin,
|
||||
initialTab = "overview",
|
||||
}) => {
|
||||
const [guardrailData, setGuardrailData] = useState<any>(null);
|
||||
const [guardrailProviderSpecificParams, setGuardrailProviderSpecificParams] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -512,7 +520,7 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<TabGroup>
|
||||
<TabGroup defaultIndex={isAdmin && initialTab === "settings" ? 1 : 0}>
|
||||
<TabList className="mb-4">
|
||||
<Tab key="overview">Overview</Tab>
|
||||
{isAdmin ? <Tab key="settings">Settings</Tab> : <></>}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
/* @vitest-environment jsdom */
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { guardrailDetailHref, useGuardrailDetailRouting } from "./detailNavigation";
|
||||
|
||||
vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams(window.location.search) }));
|
||||
vi.mock("@/components/networking", () => ({ serverRootPath: "/" }));
|
||||
|
||||
describe("guardrailDetailHref", () => {
|
||||
it("links straight to the settings tab when asked for it", () => {
|
||||
expect(guardrailDetailHref("fee65a60", "settings")).toBe(
|
||||
"/ui/guardrails?guardrail=fee65a60&guardrail_tab=settings",
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the tab param for the default overview tab", () => {
|
||||
expect(guardrailDetailHref("fee65a60")).toBe("/ui/guardrails?guardrail=fee65a60");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useGuardrailDetailRouting", () => {
|
||||
beforeEach(() => {
|
||||
window.history.pushState(null, "", "/guardrails");
|
||||
});
|
||||
|
||||
it("openGuardrail sets ?guardrail= via history.pushState (no full navigation)", () => {
|
||||
const spy = vi.spyOn(window.history, "pushState");
|
||||
const { result } = renderHook(() => useGuardrailDetailRouting());
|
||||
act(() => result.current.openGuardrail("fee65a60"));
|
||||
expect(spy).toHaveBeenCalledWith(null, "", expect.stringContaining("guardrail=fee65a60"));
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("openGuardrail drops a stale tab param so the detail view opens on overview", () => {
|
||||
window.history.pushState(null, "", "/guardrails?guardrail=old&guardrail_tab=settings");
|
||||
const spy = vi.spyOn(window.history, "pushState");
|
||||
const { result } = renderHook(() => useGuardrailDetailRouting());
|
||||
act(() => result.current.openGuardrail("fee65a60"));
|
||||
const url = spy.mock.calls.at(-1)?.[2] as string;
|
||||
expect(url).toContain("guardrail=fee65a60");
|
||||
expect(url).not.toContain("guardrail_tab");
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("close removes both guardrail params and keeps unrelated ones", () => {
|
||||
window.history.pushState(null, "", "/guardrails?tab=garden&guardrail=fee65a60&guardrail_tab=settings");
|
||||
const spy = vi.spyOn(window.history, "pushState");
|
||||
const { result } = renderHook(() => useGuardrailDetailRouting());
|
||||
act(() => result.current.close());
|
||||
const url = spy.mock.calls.at(-1)?.[2] as string;
|
||||
expect(url).toContain("tab=garden");
|
||||
expect(url).not.toContain("guardrail=");
|
||||
expect(url).not.toContain("guardrail_tab");
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("reads the guardrail id and settings tab from the query string", () => {
|
||||
window.history.pushState(null, "", "/guardrails?guardrail=fee65a60&guardrail_tab=settings");
|
||||
const { result } = renderHook(() => useGuardrailDetailRouting());
|
||||
expect(result.current.guardrailId).toBe("fee65a60");
|
||||
expect(result.current.tab).toBe("settings");
|
||||
});
|
||||
|
||||
it("falls back to the overview tab for an unknown tab value", () => {
|
||||
window.history.pushState(null, "", "/guardrails?guardrail=fee65a60&guardrail_tab=bogus");
|
||||
const { result } = renderHook(() => useGuardrailDetailRouting());
|
||||
expect(result.current.tab).toBe("overview");
|
||||
});
|
||||
|
||||
it("guardrailId is null when no guardrail param is present", () => {
|
||||
const { result } = renderHook(() => useGuardrailDetailRouting());
|
||||
expect(result.current.guardrailId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { useSearchParams } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { migratedHref } from "@/utils/migratedPages";
|
||||
import { navigateWithParams } from "../navigateWithParams";
|
||||
|
||||
export const GUARDRAIL_PARAM = "guardrail";
|
||||
export const GUARDRAIL_TAB_PARAM = "guardrail_tab";
|
||||
|
||||
export type GuardrailDetailTab = "overview" | "settings";
|
||||
|
||||
export interface GuardrailDetailRouting {
|
||||
guardrailId: string | null;
|
||||
tab: GuardrailDetailTab;
|
||||
openGuardrail: (id: string) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
/** Same-origin href that opens a guardrail's detail view, e.g. "/ui/guardrails?guardrail=abc&guardrail_tab=settings". */
|
||||
export function guardrailDetailHref(guardrailId: string, tab: GuardrailDetailTab = "overview"): string {
|
||||
const params = new URLSearchParams(
|
||||
tab === "settings"
|
||||
? { [GUARDRAIL_PARAM]: guardrailId, [GUARDRAIL_TAB_PARAM]: tab }
|
||||
: { [GUARDRAIL_PARAM]: guardrailId },
|
||||
);
|
||||
return `${migratedHref("guardrails")}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function useGuardrailDetailRouting(): GuardrailDetailRouting {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const openGuardrail = useCallback((id: string) => {
|
||||
navigateWithParams((params) => {
|
||||
params.set(GUARDRAIL_PARAM, id);
|
||||
params.delete(GUARDRAIL_TAB_PARAM);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => {
|
||||
navigateWithParams((params) => {
|
||||
params.delete(GUARDRAIL_PARAM);
|
||||
params.delete(GUARDRAIL_TAB_PARAM);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
guardrailId: searchParams?.get(GUARDRAIL_PARAM) ?? null,
|
||||
tab: searchParams?.get(GUARDRAIL_TAB_PARAM) === "settings" ? "settings" : "overview",
|
||||
openGuardrail,
|
||||
close,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue