mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-11 03:38:38 +00:00
fix: multiple QA fixes for policy flow builder with guardrail versioning on litellm
This commit is contained in:
parent
7f3b047a1c
commit
c68ee52a4d
40 changed files with 718 additions and 56 deletions
|
|
@ -5812,6 +5812,102 @@ export const updatePolicyCall = async (accessToken: string, policyId: string, po
|
|||
}
|
||||
};
|
||||
|
||||
export const listPolicyVersions = async (
|
||||
accessToken: string,
|
||||
policyName: string
|
||||
): Promise<{ policy_name: string; versions: any[]; total_count: number }> => {
|
||||
try {
|
||||
const encodedName = encodeURIComponent(policyName);
|
||||
const url = proxyBaseUrl
|
||||
? `${proxyBaseUrl}/policies/name/${encodedName}/versions`
|
||||
: `/policies/name/${encodedName}/versions`;
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
const errorMessage = deriveErrorMessage(errorData);
|
||||
handleError(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error("Failed to list policy versions:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const createPolicyVersion = async (
|
||||
accessToken: string,
|
||||
policyName: string,
|
||||
sourcePolicyId?: string | null
|
||||
): Promise<any> => {
|
||||
try {
|
||||
const encodedName = encodeURIComponent(policyName);
|
||||
const url = proxyBaseUrl
|
||||
? `${proxyBaseUrl}/policies/name/${encodedName}/versions`
|
||||
: `/policies/name/${encodedName}/versions`;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ source_policy_id: sourcePolicyId ?? undefined }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
const errorMessage = deriveErrorMessage(errorData);
|
||||
handleError(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error("Failed to create policy version:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const updatePolicyVersionStatus = async (
|
||||
accessToken: string,
|
||||
policyId: string,
|
||||
versionStatus: "published" | "production"
|
||||
): Promise<any> => {
|
||||
try {
|
||||
const url = proxyBaseUrl
|
||||
? `${proxyBaseUrl}/policies/${policyId}/status`
|
||||
: `/policies/${policyId}/status`;
|
||||
const response = await fetch(url, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ version_status: versionStatus }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
const errorMessage = deriveErrorMessage(errorData);
|
||||
handleError(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error("Failed to update policy version status:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const deletePolicyCall = async (accessToken: string, policyId: string) => {
|
||||
try {
|
||||
const url = proxyBaseUrl ? `${proxyBaseUrl}/policies/${policyId}` : `/policies/${policyId}`;
|
||||
|
|
|
|||
|
|
@ -469,11 +469,7 @@ const PoliciesPanel: React.FC<PoliciesPanelProps> = ({
|
|||
onDeleteClick={handleDeleteClick}
|
||||
onEditClick={(policy) => {
|
||||
setEditingPolicy(policy);
|
||||
if (policy.pipeline) {
|
||||
setShowFlowBuilder(true);
|
||||
} else {
|
||||
setIsAddPolicyModalVisible(true);
|
||||
}
|
||||
setShowFlowBuilder(true);
|
||||
}}
|
||||
onViewClick={(policyId) => setSelectedPolicyId(policyId)}
|
||||
isAdmin={isAdmin}
|
||||
|
|
@ -643,6 +639,17 @@ const PoliciesPanel: React.FC<PoliciesPanelProps> = ({
|
|||
availableGuardrails={guardrailsList}
|
||||
createPolicy={createPolicyCall}
|
||||
updatePolicy={updatePolicyCall}
|
||||
onVersionCreated={(newPolicy) => {
|
||||
setEditingPolicy(newPolicy);
|
||||
fetchPolicies();
|
||||
}}
|
||||
onSelectVersion={(policy) => {
|
||||
setEditingPolicy(policy);
|
||||
}}
|
||||
onVersionStatusUpdated={(updatedPolicy) => {
|
||||
setEditingPolicy(updatedPolicy);
|
||||
fetchPolicies();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import React, { useState } from "react";
|
||||
import { Select, Typography, message } from "antd";
|
||||
import { Select, Typography, message, Spin } from "antd";
|
||||
import { Button, TextInput } from "@tremor/react";
|
||||
import { ArrowLeftIcon, PlusIcon } from "@heroicons/react/outline";
|
||||
import { DotsVerticalIcon } from "@heroicons/react/solid";
|
||||
import { GuardrailPipeline, PipelineStep, PipelineTestResult, PolicyCreateRequest, PolicyUpdateRequest, Policy } from "./types";
|
||||
import { Guardrail } from "../guardrails/types";
|
||||
import { testPipelineCall } from "../networking";
|
||||
import { testPipelineCall, listPolicyVersions, createPolicyVersion, updatePolicyVersionStatus } from "../networking";
|
||||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
import { getComplianceDatasetPrompts } from "../../data/compliancePrompts";
|
||||
import type { CompliancePrompt } from "../../data/compliancePrompts";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
|
|
@ -55,6 +57,34 @@ function updateStepAtIndex(
|
|||
return steps.map((s, i) => (i === index ? { ...s, ...updated } : s));
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a pipeline from a policy. When the policy has a pipeline, use it.
|
||||
* When it only has guardrails_add (legacy/simple form), convert those guardrails
|
||||
* into pipeline steps in order.
|
||||
*/
|
||||
function derivePipelineFromPolicy(policy: Policy | null | undefined): GuardrailPipeline {
|
||||
if (!policy) {
|
||||
return { mode: "pre_call", steps: [createDefaultStep()] };
|
||||
}
|
||||
if (policy.pipeline?.steps?.length) {
|
||||
return policy.pipeline;
|
||||
}
|
||||
const guardrails = policy.guardrails_add || [];
|
||||
if (guardrails.length > 0) {
|
||||
return {
|
||||
mode: policy.pipeline?.mode ?? "pre_call",
|
||||
steps: guardrails.map((g) => ({
|
||||
guardrail: g,
|
||||
on_pass: "next" as const,
|
||||
on_fail: "block" as const,
|
||||
pass_data: false,
|
||||
modify_response_message: null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
return { mode: "pre_call", steps: [createDefaultStep()] };
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Icons (matching the reference image)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -559,6 +589,20 @@ const TERMINAL_STYLES: Record<string, { bg: string; color: string }> = {
|
|||
modify_response: { bg: "#eff6ff", color: "#2563eb" },
|
||||
};
|
||||
|
||||
interface ComplianceRunEntry {
|
||||
prompt: CompliancePrompt;
|
||||
result: PipelineTestResult | null;
|
||||
error?: string;
|
||||
matched: boolean;
|
||||
}
|
||||
|
||||
function complianceMatchExpected(expected: "pass" | "fail", terminalAction: string): boolean {
|
||||
if (expected === "pass") {
|
||||
return terminalAction === "allow" || terminalAction === "modify_response";
|
||||
}
|
||||
return terminalAction === "block";
|
||||
}
|
||||
|
||||
const PipelineTestPanel: React.FC<PipelineTestPanelProps> = ({
|
||||
pipeline,
|
||||
accessToken,
|
||||
|
|
@ -568,6 +612,8 @@ const PipelineTestPanel: React.FC<PipelineTestPanelProps> = ({
|
|||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [result, setResult] = useState<PipelineTestResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [complianceRunning, setComplianceRunning] = useState(false);
|
||||
const [complianceResults, setComplianceResults] = useState<ComplianceRunEntry[]>([]);
|
||||
|
||||
const handleRunTest = async () => {
|
||||
if (!accessToken) return;
|
||||
|
|
@ -596,6 +642,43 @@ const PipelineTestPanel: React.FC<PipelineTestPanelProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
const handleRunComplianceDataset = async () => {
|
||||
if (!accessToken) return;
|
||||
|
||||
const emptySteps = pipeline.steps.filter((s) => !s.guardrail);
|
||||
if (emptySteps.length > 0) {
|
||||
setError("All steps must have a guardrail selected");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setComplianceRunning(true);
|
||||
const prompts = getComplianceDatasetPrompts();
|
||||
const entries: ComplianceRunEntry[] = [];
|
||||
|
||||
for (const prompt of prompts) {
|
||||
try {
|
||||
const data = await testPipelineCall(accessToken, pipeline, [
|
||||
{ role: "user", content: prompt.prompt },
|
||||
]);
|
||||
const matched = complianceMatchExpected(prompt.expectedResult, data.terminal_action);
|
||||
entries.push({ prompt, result: data, matched });
|
||||
} catch (e) {
|
||||
const errMsg = e instanceof Error ? e.message : String(e);
|
||||
entries.push({
|
||||
prompt,
|
||||
result: null,
|
||||
error: errMsg,
|
||||
matched: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setComplianceResults(entries);
|
||||
setComplianceRunning(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -657,10 +740,20 @@ const PipelineTestPanel: React.FC<PipelineTestPanelProps> = ({
|
|||
<Button
|
||||
onClick={handleRunTest}
|
||||
loading={isRunning}
|
||||
disabled={complianceRunning}
|
||||
style={{ marginTop: 8, width: "100%" }}
|
||||
>
|
||||
Run Test
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleRunComplianceDataset}
|
||||
loading={complianceRunning}
|
||||
disabled={isRunning}
|
||||
style={{ marginTop: 8, width: "100%" }}
|
||||
variant="secondary"
|
||||
>
|
||||
Test pipeline (compliance dataset)
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Results section */}
|
||||
|
|
@ -773,11 +866,318 @@ const PipelineTestPanel: React.FC<PipelineTestPanelProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{!result && !error && (
|
||||
<div style={{ textAlign: "center", color: "#9ca3af", fontSize: 13, marginTop: 24 }}>
|
||||
Enter a test message and click "Run Test" to execute the pipeline
|
||||
{complianceResults.length > 0 && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "#111827",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
Compliance dataset
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "#6b7280",
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
{complianceResults.filter((e) => e.matched).length} / {complianceResults.length} matched
|
||||
expected
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: 320,
|
||||
overflowY: "auto",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
{complianceResults.map((entry, i) => {
|
||||
const actual =
|
||||
entry.result?.terminal_action ?? (entry.error ? "error" : "—");
|
||||
const matchStyle = entry.matched
|
||||
? { bg: "#f0fdf4", color: "#16a34a" }
|
||||
: { bg: "#fef2f2", color: "#dc2626" };
|
||||
return (
|
||||
<div
|
||||
key={entry.prompt.id ?? i}
|
||||
style={{
|
||||
padding: "8px 10px",
|
||||
borderBottom:
|
||||
i < complianceResults.length - 1
|
||||
? "1px solid #e5e7eb"
|
||||
: "none",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
color: "#374151",
|
||||
marginBottom: 4,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
title={entry.prompt.prompt}
|
||||
>
|
||||
{entry.prompt.prompt}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "#6b7280" }}>
|
||||
expected: {entry.prompt.expectedResult}
|
||||
</span>
|
||||
<span style={{ color: "#9ca3af" }}>→</span>
|
||||
<span style={{ color: "#6b7280" }}>
|
||||
actual: {actual}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
backgroundColor: matchStyle.bg,
|
||||
color: matchStyle.color,
|
||||
padding: "1px 6px",
|
||||
borderRadius: 4,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{entry.matched ? "✓" : "✗"}
|
||||
</span>
|
||||
</div>
|
||||
{entry.error && (
|
||||
<div style={{ color: "#dc2626", marginTop: 4 }}>
|
||||
{entry.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!result && !error && complianceResults.length === 0 && (
|
||||
<div style={{ textAlign: "center", color: "#9ca3af", fontSize: 13, marginTop: 24 }}>
|
||||
Enter a test message and click "Run Test" or "Test pipeline (compliance dataset)" to
|
||||
execute the pipeline
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Policy Versions Sidebar (left sidebar when editing a policy)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const VERSION_STATUS_STYLES: Record<
|
||||
string,
|
||||
{ bg: string; color: string }
|
||||
> = {
|
||||
draft: { bg: "#f3f4f6", color: "#6b7280" },
|
||||
published: { bg: "#eff6ff", color: "#2563eb" },
|
||||
production: { bg: "#f0fdf4", color: "#16a34a" },
|
||||
};
|
||||
|
||||
interface PolicyVersionsSidebarProps {
|
||||
policyName: string;
|
||||
editingPolicyId: string | null;
|
||||
editingVersionStatus?: "draft" | "published" | "production";
|
||||
accessToken: string | null;
|
||||
versions: Policy[];
|
||||
isLoading: boolean;
|
||||
isCreatingVersion?: boolean;
|
||||
isUpdatingStatus?: boolean;
|
||||
onNewVersion: () => void;
|
||||
onSelectVersion: (policy: Policy) => void;
|
||||
onPublish?: () => void;
|
||||
onPromoteToProduction?: () => void;
|
||||
}
|
||||
|
||||
const PolicyVersionsSidebar: React.FC<PolicyVersionsSidebarProps> = ({
|
||||
policyName,
|
||||
editingPolicyId,
|
||||
editingVersionStatus,
|
||||
accessToken,
|
||||
versions,
|
||||
isLoading,
|
||||
isCreatingVersion = false,
|
||||
isUpdatingStatus = false,
|
||||
onNewVersion,
|
||||
onSelectVersion,
|
||||
onPublish,
|
||||
onPromoteToProduction,
|
||||
}) => {
|
||||
const canPublish = editingVersionStatus === "draft" && onPublish;
|
||||
const canPromote = editingVersionStatus === "published" && onPromoteToProduction;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: 260,
|
||||
flexShrink: 0,
|
||||
backgroundColor: "#fff",
|
||||
borderRight: "1px solid #e5e7eb",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: 16, overflowY: "auto", flex: 1 }}>
|
||||
{/* Versions section */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
textTransform: "uppercase",
|
||||
color: "#6b7280",
|
||||
letterSpacing: "0.06em",
|
||||
display: "block",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
Versions
|
||||
</span>
|
||||
<Button
|
||||
onClick={onNewVersion}
|
||||
disabled={!accessToken || isCreatingVersion}
|
||||
loading={isCreatingVersion}
|
||||
style={{ width: "100%", marginBottom: 12 }}
|
||||
>
|
||||
+ New Version
|
||||
</Button>
|
||||
{isLoading ? (
|
||||
<div style={{ display: "flex", justifyContent: "center", padding: 16 }}>
|
||||
<Spin size="small" />
|
||||
</div>
|
||||
) : versions.length === 0 ? (
|
||||
<span style={{ fontSize: 13, color: "#9ca3af" }}>
|
||||
No versions found
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{versions.map((v) => {
|
||||
const statusStyle =
|
||||
VERSION_STATUS_STYLES[v.version_status ?? "draft"] ??
|
||||
VERSION_STATUS_STYLES.draft;
|
||||
const isActive = v.policy_id === editingPolicyId;
|
||||
return (
|
||||
<button
|
||||
key={v.policy_id}
|
||||
type="button"
|
||||
onClick={() => onSelectVersion(v)}
|
||||
style={{
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
padding: "10px 12px",
|
||||
borderRadius: 8,
|
||||
border: isActive ? "1px solid #6366f1" : "1px solid #e5e7eb",
|
||||
backgroundColor: isActive ? "#eef2ff" : "#fff",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: "#111827" }}>
|
||||
v{v.version_number ?? 1}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
backgroundColor: statusStyle.bg,
|
||||
color: statusStyle.color,
|
||||
padding: "2px 6px",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
{v.version_status ?? "draft"}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Publish / Promote to production for selected version */}
|
||||
{(canPublish || canPromote) && (
|
||||
<div style={{ marginTop: 12, paddingTop: 12, borderTop: "1px solid #e5e7eb" }}>
|
||||
{canPublish && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onPublish}
|
||||
disabled={!accessToken || isUpdatingStatus}
|
||||
loading={isUpdatingStatus}
|
||||
style={{ width: "100%", marginBottom: canPromote ? 8 : 0 }}
|
||||
>
|
||||
Publish
|
||||
</Button>
|
||||
)}
|
||||
{canPromote && (
|
||||
<Button
|
||||
onClick={onPromoteToProduction}
|
||||
disabled={!accessToken || isUpdatingStatus}
|
||||
loading={isUpdatingStatus}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
Promote to production
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Silent Mirroring section */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2" style={{ marginBottom: 8 }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
textTransform: "uppercase",
|
||||
color: "#6b7280",
|
||||
letterSpacing: "0.06em",
|
||||
}}
|
||||
>
|
||||
Silent Mirroring
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
backgroundColor: "#eef2ff",
|
||||
color: "#6366f1",
|
||||
padding: "2px 6px",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
COMING SOON
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "#6b7280",
|
||||
lineHeight: 1.5,
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
Test policy versions on production traffic without blocking requests.
|
||||
Shadow testing helps validate changes before full rollout.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -795,6 +1195,9 @@ interface FlowBuilderPageProps {
|
|||
availableGuardrails: Guardrail[];
|
||||
createPolicy: (accessToken: string, policyData: any) => Promise<any>;
|
||||
updatePolicy: (accessToken: string, policyId: string, policyData: any) => Promise<any>;
|
||||
onVersionCreated?: (newPolicy: Policy) => void;
|
||||
onSelectVersion?: (policy: Policy) => void;
|
||||
onVersionStatusUpdated?: (updatedPolicy: Policy) => void;
|
||||
}
|
||||
|
||||
export const FlowBuilderPage: React.FC<FlowBuilderPageProps> = ({
|
||||
|
|
@ -805,16 +1208,110 @@ export const FlowBuilderPage: React.FC<FlowBuilderPageProps> = ({
|
|||
availableGuardrails,
|
||||
createPolicy,
|
||||
updatePolicy,
|
||||
onVersionCreated,
|
||||
onSelectVersion,
|
||||
onVersionStatusUpdated,
|
||||
}) => {
|
||||
const isEditing = !!editingPolicy?.policy_id;
|
||||
const showVersionsSidebar = !!editingPolicy?.policy_name;
|
||||
|
||||
const [policyName, setPolicyName] = useState(editingPolicy?.policy_name || "");
|
||||
const [description, setDescription] = useState(editingPolicy?.description || "");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showTestPanel, setShowTestPanel] = useState(false);
|
||||
const [pipeline, setPipeline] = useState<GuardrailPipeline>(
|
||||
editingPolicy?.pipeline || { mode: "pre_call", steps: [createDefaultStep()] }
|
||||
() => derivePipelineFromPolicy(editingPolicy)
|
||||
);
|
||||
const [versions, setVersions] = useState<Policy[]>([]);
|
||||
const [isVersionsLoading, setIsVersionsLoading] = useState(false);
|
||||
const [isCreatingVersion, setIsCreatingVersion] = useState(false);
|
||||
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
|
||||
|
||||
// Sync local state when editingPolicy changes (e.g. user switched version)
|
||||
React.useEffect(() => {
|
||||
setPolicyName(editingPolicy?.policy_name || "");
|
||||
setDescription(editingPolicy?.description || "");
|
||||
setPipeline(derivePipelineFromPolicy(editingPolicy));
|
||||
}, [editingPolicy?.policy_id, editingPolicy?.policy_name, editingPolicy?.description, editingPolicy?.pipeline, editingPolicy?.guardrails_add]);
|
||||
|
||||
// Fetch versions when editing an existing policy by name
|
||||
React.useEffect(() => {
|
||||
if (!showVersionsSidebar || !editingPolicy?.policy_name || !accessToken) {
|
||||
setVersions([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setIsVersionsLoading(true);
|
||||
listPolicyVersions(accessToken, editingPolicy.policy_name)
|
||||
.then((res) => {
|
||||
if (!cancelled) setVersions(res.versions || []);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setVersions([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsVersionsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [showVersionsSidebar, editingPolicy?.policy_name, accessToken]);
|
||||
|
||||
const handleNewVersion = async () => {
|
||||
if (!accessToken || !editingPolicy?.policy_name) return;
|
||||
setIsCreatingVersion(true);
|
||||
try {
|
||||
const newPolicy = await createPolicyVersion(accessToken, editingPolicy.policy_name);
|
||||
NotificationsManager.success("New draft version created");
|
||||
onVersionCreated?.(newPolicy);
|
||||
} catch (error) {
|
||||
NotificationsManager.fromBackend(
|
||||
"Failed to create version: " + (error instanceof Error ? error.message : String(error))
|
||||
);
|
||||
} finally {
|
||||
setIsCreatingVersion(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectVersion = (policy: Policy) => {
|
||||
onSelectVersion?.(policy);
|
||||
};
|
||||
|
||||
const handlePublishVersion = async () => {
|
||||
if (!accessToken || !editingPolicy?.policy_id) return;
|
||||
setIsUpdatingStatus(true);
|
||||
try {
|
||||
const updated = await updatePolicyVersionStatus(accessToken, editingPolicy.policy_id, "published");
|
||||
NotificationsManager.success("Version published");
|
||||
const list = await listPolicyVersions(accessToken, editingPolicy.policy_name ?? "");
|
||||
setVersions(list.versions ?? []);
|
||||
onVersionStatusUpdated?.(updated);
|
||||
} catch (error) {
|
||||
NotificationsManager.fromBackend(
|
||||
"Failed to publish: " + (error instanceof Error ? error.message : String(error))
|
||||
);
|
||||
} finally {
|
||||
setIsUpdatingStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePromoteToProduction = async () => {
|
||||
if (!accessToken || !editingPolicy?.policy_id) return;
|
||||
setIsUpdatingStatus(true);
|
||||
try {
|
||||
const updated = await updatePolicyVersionStatus(accessToken, editingPolicy.policy_id, "production");
|
||||
NotificationsManager.success("Version promoted to production");
|
||||
const list = await listPolicyVersions(accessToken, editingPolicy.policy_name ?? "");
|
||||
setVersions(list.versions ?? []);
|
||||
onVersionStatusUpdated?.(updated);
|
||||
} catch (error) {
|
||||
NotificationsManager.fromBackend(
|
||||
"Failed to promote to production: " + (error instanceof Error ? error.message : String(error))
|
||||
);
|
||||
} finally {
|
||||
setIsUpdatingStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!policyName.trim()) {
|
||||
|
|
@ -849,13 +1346,13 @@ export const FlowBuilderPage: React.FC<FlowBuilderPageProps> = ({
|
|||
if (isEditing && editingPolicy) {
|
||||
await updatePolicy(accessToken, editingPolicy.policy_id, data as PolicyUpdateRequest);
|
||||
NotificationsManager.success("Policy updated successfully");
|
||||
onSuccess();
|
||||
} else {
|
||||
await createPolicy(accessToken, data as PolicyCreateRequest);
|
||||
NotificationsManager.success("Policy created successfully");
|
||||
onSuccess();
|
||||
onBack();
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
onBack();
|
||||
} catch (error) {
|
||||
console.error("Failed to save policy:", error);
|
||||
NotificationsManager.fromBackend(
|
||||
|
|
@ -963,8 +1460,24 @@ export const FlowBuilderPage: React.FC<FlowBuilderPageProps> = ({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Flow builder canvas + test panel */}
|
||||
{/* Sidebar (when editing) + Flow builder canvas + test panel */}
|
||||
<div style={{ flex: 1, display: "flex", overflow: "hidden" }}>
|
||||
{showVersionsSidebar && (
|
||||
<PolicyVersionsSidebar
|
||||
policyName={policyName}
|
||||
editingPolicyId={editingPolicy?.policy_id ?? null}
|
||||
editingVersionStatus={editingPolicy?.version_status}
|
||||
accessToken={accessToken}
|
||||
versions={versions}
|
||||
isLoading={isVersionsLoading}
|
||||
isCreatingVersion={isCreatingVersion}
|
||||
isUpdatingStatus={isUpdatingStatus}
|
||||
onNewVersion={handleNewVersion}
|
||||
onSelectVersion={handleSelectVersion}
|
||||
onPublish={handlePublishVersion}
|
||||
onPromoteToProduction={handlePromoteToProduction}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Icon, Button, Badge } from "@tremor/react";
|
||||
import { TrashIcon, PencilIcon, SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon } from "@heroicons/react/outline";
|
||||
import { Tooltip, Tag } from "antd";
|
||||
|
|
@ -12,6 +12,32 @@ import {
|
|||
} from "@tanstack/react-table";
|
||||
import { Policy } from "./types";
|
||||
|
||||
/** One row per policy name; primaryPolicy is used for display and for Edit (FlowBuilder loads all versions) */
|
||||
interface PolicyRow {
|
||||
policy_name: string;
|
||||
primaryPolicy: Policy;
|
||||
versionCount: number;
|
||||
}
|
||||
|
||||
function groupPoliciesByName(policies: Policy[]): PolicyRow[] {
|
||||
const byName = new Map<string, Policy[]>();
|
||||
for (const p of policies) {
|
||||
const name = p.policy_name || "(unnamed)";
|
||||
if (!byName.has(name)) byName.set(name, []);
|
||||
byName.get(name)!.push(p);
|
||||
}
|
||||
const rows: PolicyRow[] = [];
|
||||
for (const [policyName, versions] of byName) {
|
||||
// Prefer production, then highest version_number
|
||||
const primary =
|
||||
versions.find((v) => v.version_status === "production") ??
|
||||
[...versions].sort((a, b) => (b.version_number ?? 0) - (a.version_number ?? 0))[0] ??
|
||||
versions[0];
|
||||
rows.push({ policy_name: policyName, primaryPolicy: primary, versionCount: versions.length });
|
||||
}
|
||||
return rows.sort((a, b) => a.policy_name.localeCompare(b.policy_name));
|
||||
}
|
||||
|
||||
interface PolicyTableProps {
|
||||
policies: Policy[];
|
||||
isLoading: boolean;
|
||||
|
|
@ -29,49 +55,48 @@ const PolicyTable: React.FC<PolicyTableProps> = ({
|
|||
onViewClick,
|
||||
isAdmin = false,
|
||||
}) => {
|
||||
const [sorting, setSorting] = useState<SortingState>([{ id: "created_at", desc: true }]);
|
||||
const [sorting, setSorting] = useState<SortingState>([{ id: "policy_name", desc: false }]);
|
||||
|
||||
const rows = useMemo(() => groupPoliciesByName(policies), [policies]);
|
||||
|
||||
// Format date helper function
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return "-";
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const columns: ColumnDef<Policy>[] = [
|
||||
{
|
||||
header: "Policy ID",
|
||||
accessorKey: "policy_id",
|
||||
cell: (info: any) => (
|
||||
<Tooltip title={String(info.getValue() || "")}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left overflow-hidden truncate max-w-[200px]"
|
||||
onClick={() => info.getValue() && onViewClick(info.getValue())}
|
||||
>
|
||||
{info.getValue() ? `${String(info.getValue()).slice(0, 7)}...` : ""}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
const columns: ColumnDef<PolicyRow>[] = [
|
||||
{
|
||||
header: "Name",
|
||||
accessorKey: "policy_name",
|
||||
cell: ({ row }) => {
|
||||
const policy = row.original;
|
||||
const { primaryPolicy, versionCount } = row.original;
|
||||
return (
|
||||
<Tooltip title={policy.policy_name}>
|
||||
<span className="text-xs font-medium">{policy.policy_name || "-"}</span>
|
||||
</Tooltip>
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip title={`${primaryPolicy.policy_name || "-"}${versionCount > 1 ? ` (${versionCount} versions)` : ""}`}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
className="font-medium text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left"
|
||||
onClick={() => primaryPolicy.policy_id && onViewClick(primaryPolicy.policy_id)}
|
||||
>
|
||||
{primaryPolicy.policy_name || "-"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{versionCount > 1 && (
|
||||
<Badge color="gray" size="xs">
|
||||
{versionCount} version{versionCount !== 1 ? "s" : ""}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Description",
|
||||
accessorKey: "description",
|
||||
accessorFn: (row) => row.primaryPolicy.description ?? "",
|
||||
cell: ({ row }) => {
|
||||
const policy = row.original;
|
||||
const policy = row.original.primaryPolicy;
|
||||
return (
|
||||
<Tooltip title={policy.description}>
|
||||
<span className="text-xs truncate max-w-[200px] block">
|
||||
|
|
@ -83,9 +108,9 @@ const PolicyTable: React.FC<PolicyTableProps> = ({
|
|||
},
|
||||
{
|
||||
header: "Inherits From",
|
||||
accessorKey: "inherit",
|
||||
accessorFn: (row) => row.primaryPolicy.inherit ?? "",
|
||||
cell: ({ row }) => {
|
||||
const policy = row.original;
|
||||
const policy = row.original.primaryPolicy;
|
||||
return policy.inherit ? (
|
||||
<Badge color="blue" size="xs">
|
||||
{policy.inherit}
|
||||
|
|
@ -97,9 +122,9 @@ const PolicyTable: React.FC<PolicyTableProps> = ({
|
|||
},
|
||||
{
|
||||
header: "Guardrails (Add)",
|
||||
accessorKey: "guardrails_add",
|
||||
accessorFn: (row) => (row.primaryPolicy.guardrails_add ?? []).join(", "),
|
||||
cell: ({ row }) => {
|
||||
const policy = row.original;
|
||||
const policy = row.original.primaryPolicy;
|
||||
const guardrails = policy.guardrails_add || [];
|
||||
if (guardrails.length === 0) {
|
||||
return <span className="text-xs text-gray-400">-</span>;
|
||||
|
|
@ -122,9 +147,9 @@ const PolicyTable: React.FC<PolicyTableProps> = ({
|
|||
},
|
||||
{
|
||||
header: "Guardrails (Remove)",
|
||||
accessorKey: "guardrails_remove",
|
||||
accessorFn: (row) => (row.primaryPolicy.guardrails_remove ?? []).join(", "),
|
||||
cell: ({ row }) => {
|
||||
const policy = row.original;
|
||||
const policy = row.original.primaryPolicy;
|
||||
const guardrails = policy.guardrails_remove || [];
|
||||
if (guardrails.length === 0) {
|
||||
return <span className="text-xs text-gray-400">-</span>;
|
||||
|
|
@ -147,9 +172,12 @@ const PolicyTable: React.FC<PolicyTableProps> = ({
|
|||
},
|
||||
{
|
||||
header: "Model Condition",
|
||||
accessorKey: "condition",
|
||||
accessorFn: (row) => {
|
||||
const m = row.primaryPolicy.condition?.model;
|
||||
return typeof m === "string" ? m : JSON.stringify(m ?? "");
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const policy = row.original;
|
||||
const policy = row.original.primaryPolicy;
|
||||
const modelCondition = policy.condition?.model;
|
||||
if (!modelCondition) {
|
||||
return <span className="text-xs text-gray-400">-</span>;
|
||||
|
|
@ -169,9 +197,10 @@ const PolicyTable: React.FC<PolicyTableProps> = ({
|
|||
},
|
||||
{
|
||||
header: "Created At",
|
||||
accessorKey: "created_at",
|
||||
id: "created_at",
|
||||
accessorFn: (row) => row.primaryPolicy.created_at ?? "",
|
||||
cell: ({ row }) => {
|
||||
const policy = row.original;
|
||||
const policy = row.original.primaryPolicy;
|
||||
return (
|
||||
<Tooltip title={policy.created_at}>
|
||||
<span className="text-xs">{formatDate(policy.created_at)}</span>
|
||||
|
|
@ -183,7 +212,8 @@ const PolicyTable: React.FC<PolicyTableProps> = ({
|
|||
id: "actions",
|
||||
header: "Actions",
|
||||
cell: ({ row }) => {
|
||||
const policy = row.original;
|
||||
const { primaryPolicy } = row.original;
|
||||
const policy = primaryPolicy;
|
||||
return (
|
||||
<div className="flex space-x-2">
|
||||
{isAdmin && (
|
||||
|
|
@ -216,7 +246,7 @@ const PolicyTable: React.FC<PolicyTableProps> = ({
|
|||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: policies,
|
||||
data: rows,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
|
|
@ -273,9 +303,9 @@ const PolicyTable: React.FC<PolicyTableProps> = ({
|
|||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : policies.length > 0 ? (
|
||||
) : rows.length > 0 ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} className="h-8">
|
||||
<TableRow key={row.original.policy_name} className="h-8">
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ export interface Policy {
|
|||
guardrails_remove: string[];
|
||||
condition: PolicyCondition | null;
|
||||
pipeline?: GuardrailPipeline | null;
|
||||
version_number?: number;
|
||||
version_status?: "draft" | "published" | "production";
|
||||
parent_version_id?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
created_by?: string;
|
||||
|
|
@ -78,6 +81,12 @@ export interface PolicyListResponse {
|
|||
total_count: number;
|
||||
}
|
||||
|
||||
export interface PolicyVersionListResponse {
|
||||
policy_name: string;
|
||||
versions: Policy[];
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
export interface PolicyAttachmentListResponse {
|
||||
attachments: PolicyAttachment[];
|
||||
total_count: number;
|
||||
|
|
|
|||
|
|
@ -538,6 +538,13 @@ const frameworkMeta: Record<string, { icon: string; description: string }> = {
|
|||
},
|
||||
};
|
||||
|
||||
/** Flat list of all compliance prompts for pipeline testing (EU AI Act, GDPR, topic blocking, airline, etc.). */
|
||||
export function getComplianceDatasetPrompts(): CompliancePrompt[] {
|
||||
return getFrameworks().flatMap((fw) =>
|
||||
fw.categories.flatMap((cat) => cat.prompts)
|
||||
);
|
||||
}
|
||||
|
||||
export function getFrameworks(): ComplianceFramework[] {
|
||||
const frameworkMap = new Map<
|
||||
string,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue