Merge remote-tracking branch 'origin' into litellm_access_groups_inte

This commit is contained in:
yuneng-jiang 2026-02-14 09:06:49 -08:00
commit 890927b69c
14 changed files with 1514 additions and 33 deletions

View file

@ -56,7 +56,9 @@ class PipelineExecutor:
PipelineExecutionResult with terminal action and step results
"""
step_results: List[PipelineStepResult] = []
working_data = copy.deepcopy(data)
working_data = data.copy()
if "metadata" in working_data:
working_data["metadata"] = working_data["metadata"].copy()
for i, step in enumerate(steps):
start_time = time.perf_counter()
@ -148,6 +150,12 @@ class PipelineExecutor:
return ("error", None, f"Guardrail '{step.guardrail}' not found")
try:
# Inject guardrail name into metadata so should_run_guardrail() allows it
if "metadata" not in data:
data["metadata"] = {}
original_guardrails = data["metadata"].get("guardrails")
data["metadata"]["guardrails"] = [step.guardrail]
# Use unified_guardrail path if callback implements apply_guardrail
target = callback
use_unified = "apply_guardrail" in type(callback).__dict__

View file

@ -10,8 +10,11 @@ from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.policy_engine.attachment_registry import get_attachment_registry
from litellm.proxy.policy_engine.pipeline_executor import PipelineExecutor
from litellm.proxy.policy_engine.policy_registry import get_policy_registry
from litellm.types.proxy.policy_engine import (
GuardrailPipeline,
PipelineTestRequest,
PolicyAttachmentCreateRequest,
PolicyAttachmentDBResponse,
PolicyAttachmentListResponse,
@ -349,6 +352,69 @@ async def get_resolved_guardrails(policy_id: str):
raise HTTPException(status_code=500, detail=str(e))
# ─────────────────────────────────────────────────────────────────────────────
# Pipeline Test Endpoint
# ─────────────────────────────────────────────────────────────────────────────
@router.post(
"/policies/test-pipeline",
tags=["Policies"],
dependencies=[Depends(user_api_key_auth)],
)
async def test_pipeline(
request: PipelineTestRequest,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
Test a guardrail pipeline with sample messages.
Executes the pipeline steps against the provided test messages and returns
step-by-step results showing which guardrails passed/failed, actions taken,
and timing information.
Example Request:
```bash
curl -X POST "http://localhost:4000/policies/test-pipeline" \\
-H "Authorization: Bearer <your_api_key>" \\
-H "Content-Type: application/json" \\
-d '{
"pipeline": {
"mode": "pre_call",
"steps": [
{"guardrail": "pii-guard", "on_pass": "next", "on_fail": "block"}
]
},
"test_messages": [{"role": "user", "content": "My SSN is 123-45-6789"}]
}'
```
"""
try:
validated_pipeline = GuardrailPipeline(**request.pipeline)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Invalid pipeline: {e}")
data = {
"messages": request.test_messages,
"model": "test",
"metadata": {},
}
try:
result = await PipelineExecutor.execute_steps(
steps=validated_pipeline.steps,
mode=validated_pipeline.mode,
data=data,
user_api_key_dict=user_api_key_dict,
call_type="completion",
policy_name="test-pipeline",
)
return result.model_dump()
except Exception as e:
verbose_proxy_logger.exception(f"Error testing pipeline: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ─────────────────────────────────────────────────────────────────────────────
# Policy Attachment CRUD Endpoints
# ─────────────────────────────────────────────────────────────────────────────

View file

@ -10,6 +10,8 @@ by policy_attachments (see AttachmentRegistry).
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any, Dict, List, Optional
from prisma import Json as PrismaJson
from litellm._logging import verbose_proxy_logger
from litellm.types.proxy.policy_engine import (
GuardrailPipeline,
@ -248,7 +250,10 @@ class PolicyRegistry:
data["created_by"] = created_by
data["updated_by"] = created_by
if policy_request.condition is not None:
data["condition"] = policy_request.condition.model_dump()
data["condition"] = PrismaJson(policy_request.condition.model_dump())
if policy_request.pipeline is not None:
validated_pipeline = GuardrailPipeline(**policy_request.pipeline)
data["pipeline"] = PrismaJson(validated_pipeline.model_dump())
created_policy = await prisma_client.db.litellm_policytable.create(
data=data
@ -267,6 +272,7 @@ class PolicyRegistry:
"condition": policy_request.condition.model_dump()
if policy_request.condition
else None,
"pipeline": policy_request.pipeline,
},
)
self.add_policy(policy_request.policy_name, policy)
@ -279,6 +285,7 @@ class PolicyRegistry:
guardrails_add=created_policy.guardrails_add or [],
guardrails_remove=created_policy.guardrails_remove or [],
condition=created_policy.condition,
pipeline=created_policy.pipeline,
created_at=created_policy.created_at,
updated_at=created_policy.updated_at,
created_by=created_policy.created_by,
@ -325,7 +332,10 @@ class PolicyRegistry:
if policy_request.guardrails_remove is not None:
update_data["guardrails_remove"] = policy_request.guardrails_remove
if policy_request.condition is not None:
update_data["condition"] = policy_request.condition.model_dump()
update_data["condition"] = PrismaJson(policy_request.condition.model_dump())
if policy_request.pipeline is not None:
validated_pipeline = GuardrailPipeline(**policy_request.pipeline)
update_data["pipeline"] = PrismaJson(validated_pipeline.model_dump())
updated_policy = await prisma_client.db.litellm_policytable.update(
where={"policy_id": policy_id},
@ -343,6 +353,7 @@ class PolicyRegistry:
"remove": updated_policy.guardrails_remove,
},
"condition": updated_policy.condition,
"pipeline": updated_policy.pipeline,
},
)
self.add_policy(updated_policy.policy_name, policy)
@ -355,6 +366,7 @@ class PolicyRegistry:
guardrails_add=updated_policy.guardrails_add or [],
guardrails_remove=updated_policy.guardrails_remove or [],
condition=updated_policy.condition,
pipeline=updated_policy.pipeline,
created_at=updated_policy.created_at,
updated_at=updated_policy.updated_at,
created_by=updated_policy.created_by,
@ -432,6 +444,7 @@ class PolicyRegistry:
guardrails_add=policy.guardrails_add or [],
guardrails_remove=policy.guardrails_remove or [],
condition=policy.condition,
pipeline=policy.pipeline,
created_at=policy.created_at,
updated_at=policy.updated_at,
created_by=policy.created_by,
@ -468,6 +481,7 @@ class PolicyRegistry:
guardrails_add=p.guardrails_add or [],
guardrails_remove=p.guardrails_remove or [],
condition=p.condition,
pipeline=p.pipeline,
created_at=p.created_at,
updated_at=p.updated_at,
created_by=p.created_by,
@ -503,6 +517,7 @@ class PolicyRegistry:
"remove": policy_response.guardrails_remove,
},
"condition": policy_response.condition,
"pipeline": policy_response.pipeline,
},
)
self.add_policy(policy_response.policy_name, policy)
@ -551,6 +566,7 @@ class PolicyRegistry:
"remove": policy_response.guardrails_remove,
},
"condition": policy_response.condition,
"pipeline": policy_response.pipeline,
},
)
temp_policies[policy_response.policy_name] = policy

View file

@ -917,6 +917,7 @@ model LiteLLM_PolicyTable {
guardrails_add String[] @default([])
guardrails_remove String[] @default([])
condition Json? @default("{}") // Policy conditions (e.g., model matching)
pipeline Json? // Optional guardrail pipeline (mode + steps[])
created_at DateTime @default(now())
created_by String?
updated_at DateTime @default(now()) @updatedAt

View file

@ -1222,10 +1222,7 @@ class ProxyLogging:
},
}
}
if HTTPException is not None:
raise HTTPException(status_code=400, detail=error_detail)
else:
raise Exception(str(error_detail))
raise HTTPException(status_code=400, detail=error_detail)
if result.terminal_action == "modify_response":
raise ModifyResponseException(
@ -1236,9 +1233,6 @@ class ProxyLogging:
detection_info=None,
)
verbose_proxy_logger.warning(
f"Pipeline '{policy_name}': unrecognized terminal_action '{result.terminal_action}', defaulting to allow"
)
return data
# The actual implementation of the function

View file

@ -26,6 +26,7 @@ from litellm.types.proxy.policy_engine.policy_types import (
)
from litellm.types.proxy.policy_engine.resolver_types import (
AttachmentImpactResponse,
PipelineTestRequest,
PolicyAttachmentCreateRequest,
PolicyAttachmentDBResponse,
PolicyAttachmentListResponse,
@ -90,6 +91,8 @@ __all__ = [
"PolicyAttachmentCreateRequest",
"PolicyAttachmentDBResponse",
"PolicyAttachmentListResponse",
# Pipeline test types
"PipelineTestRequest",
# Resolve types
"PolicyResolveRequest",
"PolicyResolveResponse",

View file

@ -154,6 +154,10 @@ class PolicyCreateRequest(BaseModel):
default=None,
description="Condition for when this policy applies.",
)
pipeline: Optional[Dict[str, Any]] = Field(
default=None,
description="Optional guardrail pipeline for ordered execution. Contains 'mode' and 'steps'.",
)
class PolicyUpdateRequest(BaseModel):
@ -183,6 +187,10 @@ class PolicyUpdateRequest(BaseModel):
default=None,
description="Condition for when this policy applies.",
)
pipeline: Optional[Dict[str, Any]] = Field(
default=None,
description="Optional guardrail pipeline for ordered execution. Contains 'mode' and 'steps'.",
)
class PolicyDBResponse(BaseModel):
@ -201,6 +209,9 @@ class PolicyDBResponse(BaseModel):
condition: Optional[Dict[str, Any]] = Field(
default=None, description="Policy condition."
)
pipeline: Optional[Dict[str, Any]] = Field(
default=None, description="Optional guardrail pipeline."
)
created_at: Optional[datetime] = Field(
default=None, description="When the policy was created."
)
@ -291,6 +302,17 @@ class PolicyAttachmentListResponse(BaseModel):
# ─────────────────────────────────────────────────────────────────────────────
class PipelineTestRequest(BaseModel):
"""Request body for testing a guardrail pipeline with sample messages."""
pipeline: Dict[str, Any] = Field(
description="Pipeline definition with 'mode' and 'steps'.",
)
test_messages: List[Dict[str, str]] = Field(
description="Test messages to run through the pipeline, e.g. [{'role': 'user', 'content': '...'}].",
)
class PolicyResolveRequest(BaseModel):
"""Request body for resolving effective policies/guardrails for a context."""

View file

@ -0,0 +1,102 @@
"""
Tests for pipeline field on policy CRUD types (resolver_types.py).
"""
import pytest
from litellm.types.proxy.policy_engine.resolver_types import (
PolicyCreateRequest,
PolicyDBResponse,
PolicyUpdateRequest,
)
def test_policy_create_request_with_pipeline():
pipeline_data = {
"mode": "pre_call",
"steps": [
{"guardrail": "g1", "on_fail": "next", "on_pass": "allow"},
{"guardrail": "g2", "on_fail": "block", "on_pass": "allow"},
],
}
req = PolicyCreateRequest(
policy_name="test-policy",
guardrails_add=["g1", "g2"],
pipeline=pipeline_data,
)
assert req.pipeline is not None
assert req.pipeline["mode"] == "pre_call"
assert len(req.pipeline["steps"]) == 2
def test_policy_create_request_without_pipeline():
req = PolicyCreateRequest(
policy_name="test-policy",
guardrails_add=["g1"],
)
assert req.pipeline is None
def test_policy_update_request_with_pipeline():
pipeline_data = {
"mode": "pre_call",
"steps": [
{"guardrail": "g1", "on_fail": "block", "on_pass": "allow"},
],
}
req = PolicyUpdateRequest(pipeline=pipeline_data)
assert req.pipeline is not None
assert req.pipeline["steps"][0]["guardrail"] == "g1"
def test_policy_db_response_with_pipeline():
pipeline_data = {
"mode": "pre_call",
"steps": [
{"guardrail": "g1", "on_fail": "next", "on_pass": "allow"},
{"guardrail": "g2", "on_fail": "block", "on_pass": "allow"},
],
}
resp = PolicyDBResponse(
policy_id="test-id",
policy_name="test-policy",
guardrails_add=["g1", "g2"],
pipeline=pipeline_data,
)
assert resp.pipeline is not None
assert resp.pipeline["mode"] == "pre_call"
dumped = resp.model_dump()
assert dumped["pipeline"]["steps"][0]["guardrail"] == "g1"
def test_policy_db_response_without_pipeline():
resp = PolicyDBResponse(
policy_id="test-id",
policy_name="test-policy",
)
assert resp.pipeline is None
dumped = resp.model_dump()
assert dumped["pipeline"] is None
def test_policy_create_request_roundtrip():
pipeline_data = {
"mode": "post_call",
"steps": [
{
"guardrail": "g1",
"on_fail": "modify_response",
"on_pass": "next",
"pass_data": True,
"modify_response_message": "custom msg",
},
],
}
req = PolicyCreateRequest(
policy_name="roundtrip-test",
guardrails_add=["g1"],
pipeline=pipeline_data,
)
dumped = req.model_dump()
restored = PolicyCreateRequest(**dumped)
assert restored.pipeline == pipeline_data

View file

@ -5673,6 +5673,37 @@ export const deletePolicyAttachmentCall = async (accessToken: string, attachment
}
};
export const testPipelineCall = async (
accessToken: string,
pipeline: any,
testMessages: Array<{role: string, content: string}>
) => {
try {
const url = proxyBaseUrl ? `${proxyBaseUrl}/policies/test-pipeline` : `/policies/test-pipeline`;
const response = await fetch(url, {
method: "POST",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ pipeline, test_messages: testMessages }),
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
const data = await response.json();
return data;
} catch (error) {
console.error("Failed to test pipeline:", error);
throw error;
}
};
export const getResolvedGuardrails = async (accessToken: string, policyId: string) => {
try {
const url = proxyBaseUrl

View file

@ -14,6 +14,7 @@ interface AddPolicyFormProps {
visible: boolean;
onClose: () => void;
onSuccess: () => void;
onOpenFlowBuilder: () => void;
accessToken: string | null;
editingPolicy?: Policy | null;
existingPolicies: Policy[];
@ -22,10 +23,117 @@ interface AddPolicyFormProps {
updatePolicy: (accessToken: string, policyId: string, policyData: any) => Promise<any>;
}
// ─────────────────────────────────────────────────────────────────────────────
// Mode Picker (Step 1) - shown first when creating a new policy
// ─────────────────────────────────────────────────────────────────────────────
interface ModePicker {
selected: "simple" | "flow_builder";
onSelect: (mode: "simple" | "flow_builder") => void;
}
const ModePicker: React.FC<ModePicker> = ({ selected, onSelect }) => (
<div className="flex gap-4" style={{ padding: "8px 0" }}>
{/* Simple Mode Card */}
<div
onClick={() => onSelect("simple")}
style={{
flex: 1,
padding: "24px 20px",
border: `2px solid ${selected === "simple" ? "#4f46e5" : "#e5e7eb"}`,
borderRadius: 12,
cursor: "pointer",
backgroundColor: selected === "simple" ? "#eef2ff" : "#fff",
transition: "all 0.15s ease",
}}
>
<div
style={{
width: 40,
height: 40,
borderRadius: 10,
backgroundColor: selected === "simple" ? "#e0e7ff" : "#f3f4f6",
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: 16,
}}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke={selected === "simple" ? "#4f46e5" : "#6b7280"} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M8 7h8M8 12h8M8 17h5" />
</svg>
</div>
<Text strong style={{ fontSize: 15, display: "block", marginBottom: 4 }}>
Simple Mode
</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
Pick guardrails from a list. All run in parallel.
</Text>
</div>
{/* Flow Builder Card */}
<div
onClick={() => onSelect("flow_builder")}
style={{
flex: 1,
padding: "24px 20px",
border: `2px solid ${selected === "flow_builder" ? "#4f46e5" : "#e5e7eb"}`,
borderRadius: 12,
cursor: "pointer",
backgroundColor: selected === "flow_builder" ? "#eef2ff" : "#fff",
transition: "all 0.15s ease",
position: "relative",
}}
>
<Tag
color="purple"
style={{
position: "absolute",
top: 12,
right: 12,
fontSize: 10,
fontWeight: 600,
margin: 0,
}}
>
NEW
</Tag>
<div
style={{
width: 40,
height: 40,
borderRadius: 10,
backgroundColor: selected === "flow_builder" ? "#e0e7ff" : "#f3f4f6",
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: 16,
}}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke={selected === "flow_builder" ? "#4f46e5" : "#6b7280"} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
</svg>
</div>
<Text strong style={{ fontSize: 15, display: "block", marginBottom: 4 }}>
Flow Builder
</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
Define steps, conditions, and error responses.
</Text>
</div>
</div>
);
// ─────────────────────────────────────────────────────────────────────────────
// Main Component
// ─────────────────────────────────────────────────────────────────────────────
const AddPolicyForm: React.FC<AddPolicyFormProps> = ({
visible,
onClose,
onSuccess,
onOpenFlowBuilder,
accessToken,
editingPolicy,
existingPolicies,
@ -39,16 +147,16 @@ const AddPolicyForm: React.FC<AddPolicyFormProps> = ({
const [isLoadingResolved, setIsLoadingResolved] = useState(false);
const [modelConditionType, setModelConditionType] = useState<"model" | "regex">("model");
const [availableModels, setAvailableModels] = useState<string[]>([]);
const [step, setStep] = useState<"pick_mode" | "simple_form">("pick_mode");
const [selectedMode, setSelectedMode] = useState<"simple" | "flow_builder">("simple");
const { userId, userRole } = useAuthorized();
// Only consider it "editing" if editingPolicy has a policy_id (real existing policy)
// If editingPolicy is set but has no policy_id, it's just pre-filled data for a new policy (e.g., from a template)
const isEditing = !!editingPolicy?.policy_id;
useEffect(() => {
if (visible && editingPolicy) {
const modelCondition = editingPolicy.condition?.model;
// Detect if it's a regex pattern (contains *, ., [, ], etc.)
const isRegex = modelCondition && /[.*+?^${}()|[\]\\]/.test(modelCondition);
setModelConditionType(isRegex ? "regex" : "model");
@ -60,14 +168,25 @@ const AddPolicyForm: React.FC<AddPolicyFormProps> = ({
guardrails_remove: editingPolicy.guardrails_remove || [],
model_condition: modelCondition,
});
// Load resolved guardrails for editing
if (editingPolicy.policy_id && accessToken) {
loadResolvedGuardrails(editingPolicy.policy_id);
}
// If editing a pipeline policy, go directly to flow builder
if (editingPolicy.pipeline) {
onClose();
onOpenFlowBuilder();
return;
}
// If editing a simple policy, skip mode picker
setStep("simple_form");
} else if (visible) {
form.resetFields();
setResolvedGuardrails([]);
setModelConditionType("model");
setSelectedMode("simple");
setStep("pick_mode");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible, editingPolicy, form]);
@ -81,7 +200,6 @@ const AddPolicyForm: React.FC<AddPolicyFormProps> = ({
const loadAvailableModels = async () => {
if (!accessToken) return;
try {
const response = await modelAvailableCall(accessToken, userId, userRole);
if (response?.data) {
@ -95,7 +213,6 @@ const AddPolicyForm: React.FC<AddPolicyFormProps> = ({
const loadResolvedGuardrails = async (policyId: string) => {
if (!accessToken) return;
setIsLoadingResolved(true);
try {
const data = await getResolvedGuardrails(accessToken, policyId);
@ -115,20 +232,15 @@ const AddPolicyForm: React.FC<AddPolicyFormProps> = ({
let resolved = new Set<string>();
// If inheriting, find parent policy and get its guardrails
if (inheritFrom) {
const parentPolicy = existingPolicies.find(p => p.policy_name === inheritFrom);
if (parentPolicy) {
// Recursively resolve parent's guardrails
const parentResolved = resolveParentGuardrails(parentPolicy);
parentResolved.forEach(g => resolved.add(g));
}
}
// Add guardrails
guardrailsAdd.forEach((g: string) => resolved.add(g));
// Remove guardrails
guardrailsRemove.forEach((g: string) => resolved.delete(g));
return Array.from(resolved).sort();
@ -137,32 +249,23 @@ const AddPolicyForm: React.FC<AddPolicyFormProps> = ({
const resolveParentGuardrails = (policy: Policy): string[] => {
let resolved = new Set<string>();
// If parent inherits, resolve recursively
if (policy.inherit) {
const grandparent = existingPolicies.find(p => p.policy_name === policy.inherit);
if (grandparent) {
const grandparentResolved = resolveParentGuardrails(grandparent);
grandparentResolved.forEach(g => resolved.add(g));
resolveParentGuardrails(grandparent).forEach(g => resolved.add(g));
}
}
// Add parent's guardrails
if (policy.guardrails_add) {
policy.guardrails_add.forEach(g => resolved.add(g));
}
// Remove parent's removed guardrails
if (policy.guardrails_remove) {
policy.guardrails_remove.forEach(g => resolved.delete(g));
}
return Array.from(resolved);
};
// Recompute resolved guardrails when form values change
const handleFormChange = () => {
const resolved = computeResolvedGuardrails();
setResolvedGuardrails(resolved);
setResolvedGuardrails(computeResolvedGuardrails());
};
const resetForm = () => {
@ -171,9 +274,20 @@ const AddPolicyForm: React.FC<AddPolicyFormProps> = ({
const handleClose = () => {
resetForm();
setStep("pick_mode");
setSelectedMode("simple");
onClose();
};
const handleModeConfirm = () => {
if (selectedMode === "flow_builder") {
onClose();
onOpenFlowBuilder();
} else {
setStep("simple_form");
}
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
@ -228,6 +342,50 @@ const AddPolicyForm: React.FC<AddPolicyFormProps> = ({
value: p.policy_name,
}));
// ── Mode Picker Step ──────────────────────────────────────────────────────
if (step === "pick_mode") {
return (
<Modal
title="Create New Policy"
open={visible}
onCancel={handleClose}
footer={null}
width={620}
>
<ModePicker selected={selectedMode} onSelect={setSelectedMode} />
{selectedMode === "flow_builder" && (
<Alert
message="You'll be redirected to the full-screen Flow Builder to design your policy logic visually."
type="info"
style={{
marginTop: 16,
backgroundColor: "#eef2ff",
border: "1px solid #c7d2fe",
}}
/>
)}
<div className="flex justify-end gap-2" style={{ marginTop: 24 }}>
<Button variant="secondary" onClick={handleClose}>
Cancel
</Button>
<Button
onClick={handleModeConfirm}
style={{
backgroundColor: "#4f46e5",
color: "#fff",
border: "none",
}}
>
{selectedMode === "flow_builder" ? "Continue to Builder" : "Create Policy"}
</Button>
</div>
</Modal>
);
}
// ── Simple Form Step ──────────────────────────────────────────────────────
return (
<Modal
title={isEditing ? "Edit Policy" : "Create New Policy"}

View file

@ -6,6 +6,7 @@ import { isAdminRole } from "@/utils/roles";
import PolicyTable from "./policy_table";
import PolicyInfoView from "./policy_info";
import AddPolicyForm from "./add_policy_form";
import { FlowBuilderPage } from "./pipeline_flow_builder";
import AttachmentTable from "./attachment_table";
import AddAttachmentForm from "./add_attachment_form";
import PolicyTestPanel from "./policy_test_panel";
@ -56,6 +57,7 @@ const PoliciesPanel: React.FC<PoliciesPanelProps> = ({
const [selectedTemplate, setSelectedTemplate] = useState<any>(null);
const [existingGuardrailNames, setExistingGuardrailNames] = useState<Set<string>>(new Set());
const [isCreatingGuardrails, setIsCreatingGuardrails] = useState(false);
const [showFlowBuilder, setShowFlowBuilder] = useState(false);
const isAdmin = userRole ? isAdminRole(userRole) : false;
@ -349,8 +351,12 @@ const PoliciesPanel: React.FC<PoliciesPanelProps> = ({
onClose={() => setSelectedPolicyId(null)}
onEdit={(policy) => {
setEditingPolicy(policy);
setIsAddPolicyModalVisible(true);
setSelectedPolicyId(null);
if (policy.pipeline) {
setShowFlowBuilder(true);
} else {
setIsAddPolicyModalVisible(true);
}
}}
accessToken={accessToken}
isAdmin={isAdmin}
@ -363,7 +369,11 @@ const PoliciesPanel: React.FC<PoliciesPanelProps> = ({
onDeleteClick={handleDeleteClick}
onEditClick={(policy) => {
setEditingPolicy(policy);
setIsAddPolicyModalVisible(true);
if (policy.pipeline) {
setShowFlowBuilder(true);
} else {
setIsAddPolicyModalVisible(true);
}
}}
onViewClick={(policyId) => setSelectedPolicyId(policyId)}
isAdmin={isAdmin}
@ -374,6 +384,10 @@ const PoliciesPanel: React.FC<PoliciesPanelProps> = ({
visible={isAddPolicyModalVisible}
onClose={handleCloseModal}
onSuccess={handleSuccess}
onOpenFlowBuilder={() => {
setIsAddPolicyModalVisible(false);
setShowFlowBuilder(true);
}}
accessToken={accessToken}
editingPolicy={editingPolicy}
existingPolicies={policiesList}
@ -473,6 +487,24 @@ const PoliciesPanel: React.FC<PoliciesPanelProps> = ({
</TabPanel>
</TabPanels>
</TabGroup>
{showFlowBuilder && (
<FlowBuilderPage
onBack={() => {
setShowFlowBuilder(false);
setEditingPolicy(null);
}}
onSuccess={() => {
fetchPolicies();
setEditingPolicy(null);
}}
accessToken={accessToken}
editingPolicy={editingPolicy}
availableGuardrails={guardrailsList}
createPolicy={createPolicyCall}
updatePolicy={updatePolicyCall}
/>
)}
</div>
);
};

View file

@ -0,0 +1,999 @@
import React, { useState } from "react";
import { Select, Typography, message } 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 NotificationsManager from "../molecules/notifications_manager";
const { Text } = Typography;
const ACTION_OPTIONS = [
{ label: "Next Step", value: "next" },
{ label: "Allow", value: "allow" },
{ label: "Block", value: "block" },
{ label: "Custom Response", value: "modify_response" },
];
const ACTION_LABELS: Record<string, string> = {
allow: "Allow",
block: "Block",
next: "Next Step",
modify_response: "Custom Response",
};
function createDefaultStep(): PipelineStep {
return {
guardrail: "",
on_pass: "next",
on_fail: "block",
pass_data: false,
modify_response_message: null,
};
}
function insertStep(steps: PipelineStep[], atIndex: number): PipelineStep[] {
const newSteps = [...steps];
newSteps.splice(atIndex, 0, createDefaultStep());
return newSteps;
}
function removeStep(steps: PipelineStep[], index: number): PipelineStep[] {
if (steps.length <= 1) return steps;
const newSteps = [...steps];
newSteps.splice(index, 1);
return newSteps;
}
function updateStepAtIndex(
steps: PipelineStep[],
index: number,
updated: Partial<PipelineStep>
): PipelineStep[] {
return steps.map((s, i) => (i === index ? { ...s, ...updated } : s));
}
// ─────────────────────────────────────────────────────────────────────────────
// Icons (matching the reference image)
// ─────────────────────────────────────────────────────────────────────────────
const GuardrailIcon: React.FC = () => (
<div
style={{
width: 28,
height: 28,
borderRadius: "50%",
backgroundColor: "#eef2ff",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#6366f1" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4" />
</svg>
</div>
);
const PlayIcon: React.FC = () => (
<div
style={{
width: 28,
height: 28,
borderRadius: "50%",
backgroundColor: "#f3f4f6",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="#6b7280" stroke="none">
<polygon points="6,3 20,12 6,21" />
</svg>
</div>
);
const PassIcon: React.FC = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#22c55e" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
<circle cx="12" cy="12" r="10" />
<path d="M9 12l2 2 4-4" />
</svg>
);
const FailIcon: React.FC = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#f87171" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
<circle cx="12" cy="12" r="10" />
</svg>
);
// ─────────────────────────────────────────────────────────────────────────────
// Connector
// ─────────────────────────────────────────────────────────────────────────────
interface ConnectorProps {
onInsert: () => void;
}
const Connector: React.FC<ConnectorProps> = ({ onInsert }) => (
<div className="flex flex-col items-center" style={{ height: 56 }}>
<div style={{ width: 1, flex: 1, backgroundColor: "#d1d5db" }} />
<button
onClick={onInsert}
className="flex items-center justify-center"
style={{
width: 24,
height: 24,
borderRadius: "50%",
border: "1px solid #d1d5db",
backgroundColor: "#fff",
cursor: "pointer",
zIndex: 1,
transition: "all 0.15s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "#6366f1";
e.currentTarget.style.backgroundColor = "#eef2ff";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "#d1d5db";
e.currentTarget.style.backgroundColor = "#fff";
}}
title="Insert step"
>
<PlusIcon style={{ width: 12, height: 12, color: "#9ca3af" }} />
</button>
<div style={{ width: 1, flex: 1, backgroundColor: "#d1d5db" }} />
</div>
);
// ─────────────────────────────────────────────────────────────────────────────
// Step Card (editable)
// ─────────────────────────────────────────────────────────────────────────────
interface StepCardProps {
step: PipelineStep;
stepIndex: number;
totalSteps: number;
onChange: (updated: Partial<PipelineStep>) => void;
onDelete: () => void;
availableGuardrails: Guardrail[];
}
const StepCard: React.FC<StepCardProps> = ({
step,
stepIndex,
totalSteps,
onChange,
onDelete,
availableGuardrails,
}) => {
const guardrailOptions = availableGuardrails.map((g) => ({
label: g.guardrail_name || g.guardrail_id,
value: g.guardrail_name || g.guardrail_id,
}));
return (
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: 10,
backgroundColor: "#fff",
maxWidth: 720,
width: "100%",
overflow: "hidden",
}}
>
{/* Header row */}
<div
className="flex items-center justify-between"
style={{ padding: "14px 20px 0 20px" }}
>
<div className="flex items-center gap-2">
<GuardrailIcon />
<span
style={{
fontSize: 11,
fontWeight: 700,
textTransform: "uppercase",
color: "#6366f1",
letterSpacing: "0.06em",
}}
>
GUARDRAIL
</span>
</div>
<div className="flex items-center gap-2">
<span style={{ fontSize: 13, color: "#9ca3af" }}>
Step {stepIndex + 1}
</span>
<button
onClick={onDelete}
disabled={totalSteps <= 1}
style={{
background: "none",
border: "none",
cursor: totalSteps <= 1 ? "not-allowed" : "pointer",
opacity: totalSteps <= 1 ? 0.3 : 1,
padding: 2,
display: "flex",
alignItems: "center",
}}
title="Delete step"
>
<DotsVerticalIcon style={{ width: 16, height: 16, color: "#9ca3af" }} />
</button>
</div>
</div>
{/* Guardrail selector */}
<div style={{ padding: "12px 20px 16px 20px" }}>
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
Guardrail
</label>
<Select
showSearch
style={{ width: "100%" }}
placeholder="Select a guardrail"
value={step.guardrail || undefined}
onChange={(value) => onChange({ guardrail: value })}
options={guardrailOptions}
filterOption={(input, option) =>
(option?.label ?? "").toString().toLowerCase().includes(input.toLowerCase())
}
/>
</div>
{/* ON PASS section */}
<div style={{ borderTop: "1px solid #f0f0f0", padding: "14px 20px" }}>
<div className="flex items-center gap-2" style={{ marginBottom: 8 }}>
<PassIcon />
<span style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>ON PASS</span>
</div>
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
Action
</label>
<Select
style={{ width: "100%" }}
value={step.on_pass}
onChange={(value) => onChange({ on_pass: value as PipelineStep["on_pass"] })}
options={ACTION_OPTIONS}
/>
{step.on_pass === "modify_response" && (
<div style={{ marginTop: 8 }}>
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
Custom Response Message
</label>
<TextInput
placeholder="Enter custom response..."
value={step.modify_response_message || ""}
onChange={(e) => onChange({ modify_response_message: e.target.value || null })}
/>
</div>
)}
</div>
{/* ON FAIL section */}
<div style={{ borderTop: "1px solid #f0f0f0", padding: "14px 20px" }}>
<div className="flex items-center gap-2" style={{ marginBottom: 8 }}>
<FailIcon />
<span style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>ON FAIL</span>
</div>
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
Action
</label>
<Select
style={{ width: "100%" }}
value={step.on_fail}
onChange={(value) => onChange({ on_fail: value as PipelineStep["on_fail"] })}
options={ACTION_OPTIONS}
/>
{step.on_fail === "modify_response" && (
<div style={{ marginTop: 8 }}>
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
Custom Response Message
</label>
<TextInput
placeholder="Enter custom response..."
value={step.modify_response_message || ""}
onChange={(e) => onChange({ modify_response_message: e.target.value || null })}
/>
</div>
)}
</div>
</div>
);
};
// ─────────────────────────────────────────────────────────────────────────────
// Main Component
// ─────────────────────────────────────────────────────────────────────────────
interface PipelineFlowBuilderProps {
pipeline: GuardrailPipeline;
onChange: (pipeline: GuardrailPipeline) => void;
availableGuardrails: Guardrail[];
}
const PipelineFlowBuilder: React.FC<PipelineFlowBuilderProps> = ({
pipeline,
onChange,
availableGuardrails,
}) => {
const handleInsertStep = (atIndex: number) => {
onChange({ ...pipeline, steps: insertStep(pipeline.steps, atIndex) });
};
const handleRemoveStep = (index: number) => {
onChange({ ...pipeline, steps: removeStep(pipeline.steps, index) });
};
const handleUpdateStep = (index: number, updated: Partial<PipelineStep>) => {
onChange({
...pipeline,
steps: updateStepAtIndex(pipeline.steps, index, updated),
});
};
return (
<div className="flex flex-col items-center" style={{ padding: "16px 0" }}>
{/* Trigger Card */}
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "16px 20px",
backgroundColor: "#fff",
maxWidth: 720,
width: "100%",
}}
>
<div className="flex items-center gap-3">
<PlayIcon />
<div>
<span
style={{
fontSize: 11,
fontWeight: 700,
textTransform: "uppercase",
color: "#6b7280",
letterSpacing: "0.06em",
display: "block",
marginBottom: 2,
}}
>
TRIGGER
</span>
<span style={{ fontSize: 14, fontWeight: 600, color: "#111827", display: "block" }}>
Incoming LLM Request
</span>
<span style={{ fontSize: 13, color: "#9ca3af" }}>
This flow runs when a request matches this policy
</span>
</div>
</div>
</div>
{/* Steps */}
{pipeline.steps.map((step, index) => (
<React.Fragment key={index}>
<Connector onInsert={() => handleInsertStep(index)} />
<StepCard
step={step}
stepIndex={index}
totalSteps={pipeline.steps.length}
onChange={(updated) => handleUpdateStep(index, updated)}
onDelete={() => handleRemoveStep(index)}
availableGuardrails={availableGuardrails}
/>
</React.Fragment>
))}
{/* Bottom connector */}
<Connector onInsert={() => handleInsertStep(pipeline.steps.length)} />
{/* End card */}
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "14px 20px",
backgroundColor: "#fff",
maxWidth: 720,
width: "100%",
}}
>
<div className="flex items-center gap-3">
<div
style={{
width: 28,
height: 28,
borderRadius: "50%",
backgroundColor: "#f3f4f6",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#6b7280" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" />
<line x1="8" y1="12" x2="16" y2="12" />
</svg>
</div>
<div>
<span
style={{
fontSize: 11,
fontWeight: 700,
textTransform: "uppercase",
color: "#6b7280",
letterSpacing: "0.06em",
display: "block",
marginBottom: 2,
}}
>
END
</span>
<span style={{ fontSize: 14, fontWeight: 600, color: "#111827", display: "block" }}>
Continue to LLM
</span>
<span style={{ fontSize: 13, color: "#9ca3af" }}>
Request proceeds to the model
</span>
</div>
</div>
</div>
</div>
);
};
// ─────────────────────────────────────────────────────────────────────────────
// Read-only display for policy info view
// ─────────────────────────────────────────────────────────────────────────────
interface PipelineInfoDisplayProps {
pipeline: GuardrailPipeline;
}
export const PipelineInfoDisplay: React.FC<PipelineInfoDisplayProps> = ({ pipeline }) => (
<div className="flex flex-col items-center" style={{ padding: "16px 0" }}>
{/* Trigger */}
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "14px 20px",
backgroundColor: "#fff",
maxWidth: 720,
width: "100%",
}}
>
<div className="flex items-center gap-3">
<PlayIcon />
<div>
<span style={{ fontSize: 11, fontWeight: 700, textTransform: "uppercase", color: "#6b7280", letterSpacing: "0.06em", display: "block", marginBottom: 2 }}>
TRIGGER
</span>
<span style={{ fontSize: 14, fontWeight: 600, color: "#111827" }}>
Incoming LLM Request
</span>
</div>
</div>
</div>
{/* Steps */}
{pipeline.steps.map((step, index) => (
<React.Fragment key={index}>
{/* Connector */}
<div style={{ width: 1, height: 32, backgroundColor: "#d1d5db" }} />
{/* Step card */}
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "14px 20px",
backgroundColor: "#fff",
maxWidth: 720,
width: "100%",
}}
>
{/* Header */}
<div className="flex items-center justify-between" style={{ marginBottom: 8 }}>
<div className="flex items-center gap-2">
<GuardrailIcon />
<span style={{ fontSize: 11, fontWeight: 700, textTransform: "uppercase", color: "#6366f1", letterSpacing: "0.06em" }}>
GUARDRAIL
</span>
</div>
<span style={{ fontSize: 13, color: "#9ca3af" }}>Step {index + 1}</span>
</div>
{/* Name */}
<div style={{ fontSize: 15, fontWeight: 600, color: "#111827", marginBottom: 8 }}>
{step.guardrail}
</div>
{/* Divider */}
<div style={{ borderTop: "1px solid #f3f4f6", marginBottom: 10 }} />
{/* Pass / Fail */}
<div className="flex items-center gap-6" style={{ fontSize: 13, color: "#374151" }}>
<span className="flex items-center gap-1.5">
<PassIcon /> Pass &#8594; {ACTION_LABELS[step.on_pass] || step.on_pass}
</span>
<span className="flex items-center gap-1.5">
<FailIcon /> Fail &#8594; {ACTION_LABELS[step.on_fail] || step.on_fail}
</span>
</div>
</div>
</React.Fragment>
))}
</div>
);
// ─────────────────────────────────────────────────────────────────────────────
// Pipeline Test Panel (right drawer)
// ─────────────────────────────────────────────────────────────────────────────
interface PipelineTestPanelProps {
pipeline: GuardrailPipeline;
accessToken: string | null;
onClose: () => void;
}
const OUTCOME_STYLES: Record<string, { bg: string; color: string; label: string }> = {
pass: { bg: "#f0fdf4", color: "#16a34a", label: "PASS" },
fail: { bg: "#fef2f2", color: "#dc2626", label: "FAIL" },
error: { bg: "#fffbeb", color: "#d97706", label: "ERROR" },
};
const TERMINAL_STYLES: Record<string, { bg: string; color: string }> = {
allow: { bg: "#f0fdf4", color: "#16a34a" },
block: { bg: "#fef2f2", color: "#dc2626" },
modify_response: { bg: "#eff6ff", color: "#2563eb" },
};
const PipelineTestPanel: React.FC<PipelineTestPanelProps> = ({
pipeline,
accessToken,
onClose,
}) => {
const [testMessage, setTestMessage] = useState("Hello, can you help me?");
const [isRunning, setIsRunning] = useState(false);
const [result, setResult] = useState<PipelineTestResult | null>(null);
const [error, setError] = useState<string | null>(null);
const handleRunTest = 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;
}
setIsRunning(true);
setResult(null);
setError(null);
try {
const data = await testPipelineCall(
accessToken,
pipeline,
[{ role: "user", content: testMessage }]
);
setResult(data);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setIsRunning(false);
}
};
return (
<div
style={{
width: 400,
borderLeft: "1px solid #e5e7eb",
backgroundColor: "#fff",
display: "flex",
flexDirection: "column",
flexShrink: 0,
overflow: "hidden",
}}
>
{/* Panel header */}
<div
style={{
padding: "12px 16px",
borderBottom: "1px solid #e5e7eb",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<span style={{ fontSize: 14, fontWeight: 600, color: "#111827" }}>Test Pipeline</span>
<button
onClick={onClose}
style={{
background: "none",
border: "none",
cursor: "pointer",
fontSize: 18,
color: "#9ca3af",
padding: "0 4px",
}}
>
x
</button>
</div>
{/* Input section */}
<div style={{ padding: 16, borderBottom: "1px solid #e5e7eb" }}>
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
Test Message
</label>
<textarea
value={testMessage}
onChange={(e) => setTestMessage(e.target.value)}
placeholder="Enter a test message..."
rows={3}
style={{
width: "100%",
border: "1px solid #d1d5db",
borderRadius: 6,
padding: "8px 10px",
fontSize: 13,
resize: "vertical",
fontFamily: "inherit",
}}
/>
<Button
onClick={handleRunTest}
loading={isRunning}
style={{ marginTop: 8, width: "100%" }}
>
Run Test
</Button>
</div>
{/* Results section */}
<div style={{ flex: 1, overflowY: "auto", padding: 16 }}>
{error && (
<div
style={{
padding: "10px 12px",
backgroundColor: "#fef2f2",
border: "1px solid #fecaca",
borderRadius: 6,
fontSize: 13,
color: "#dc2626",
marginBottom: 12,
}}
>
{error}
</div>
)}
{result && (
<div>
{/* Step results */}
{result.step_results.map((step, i) => {
const style = OUTCOME_STYLES[step.outcome] || OUTCOME_STYLES.error;
return (
<div
key={i}
style={{
border: "1px solid #e5e7eb",
borderRadius: 8,
padding: "10px 12px",
marginBottom: 8,
}}
>
<div className="flex items-center justify-between" style={{ marginBottom: 4 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: "#111827" }}>
Step {i + 1}: {step.guardrail_name}
</span>
<span
style={{
fontSize: 11,
fontWeight: 700,
backgroundColor: style.bg,
color: style.color,
padding: "2px 8px",
borderRadius: 4,
}}
>
{style.label}
</span>
</div>
<div style={{ fontSize: 12, color: "#6b7280" }}>
Action: {ACTION_LABELS[step.action_taken] || step.action_taken}
{step.duration_seconds != null && (
<span style={{ marginLeft: 8 }}>
({(step.duration_seconds * 1000).toFixed(0)}ms)
</span>
)}
</div>
{step.error_detail && (
<div style={{ fontSize: 12, color: "#dc2626", marginTop: 4 }}>
{step.error_detail}
</div>
)}
</div>
);
})}
{/* Terminal result */}
<div
style={{
borderTop: "1px solid #e5e7eb",
paddingTop: 12,
marginTop: 4,
}}
>
<div className="flex items-center justify-between">
<span style={{ fontSize: 13, fontWeight: 600, color: "#111827" }}>Result</span>
{(() => {
const ts = TERMINAL_STYLES[result.terminal_action] || TERMINAL_STYLES.block;
return (
<span
style={{
fontSize: 12,
fontWeight: 700,
backgroundColor: ts.bg,
color: ts.color,
padding: "3px 10px",
borderRadius: 4,
textTransform: "uppercase",
}}
>
{result.terminal_action === "modify_response" ? "Custom Response" : result.terminal_action}
</span>
);
})()}
</div>
{result.error_message && (
<div style={{ fontSize: 12, color: "#dc2626", marginTop: 6 }}>
{result.error_message}
</div>
)}
{result.modify_response_message && (
<div style={{ fontSize: 12, color: "#2563eb", marginTop: 6 }}>
Response: {result.modify_response_message}
</div>
)}
</div>
</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
</div>
)}
</div>
</div>
);
};
// ─────────────────────────────────────────────────────────────────────────────
// Full-screen Flow Builder Page
// ─────────────────────────────────────────────────────────────────────────────
interface FlowBuilderPageProps {
onBack: () => void;
onSuccess: () => void;
accessToken: string | null;
editingPolicy?: Policy | null;
availableGuardrails: Guardrail[];
createPolicy: (accessToken: string, policyData: any) => Promise<any>;
updatePolicy: (accessToken: string, policyId: string, policyData: any) => Promise<any>;
}
export const FlowBuilderPage: React.FC<FlowBuilderPageProps> = ({
onBack,
onSuccess,
accessToken,
editingPolicy,
availableGuardrails,
createPolicy,
updatePolicy,
}) => {
const isEditing = !!editingPolicy?.policy_id;
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()] }
);
const handleSave = async () => {
if (!policyName.trim()) {
message.error("Please enter a policy name");
return;
}
if (!accessToken) {
message.error("No access token available");
return;
}
const emptySteps = pipeline.steps.filter((s) => !s.guardrail);
if (emptySteps.length > 0) {
message.error("Please select a guardrail for all steps");
return;
}
setIsSubmitting(true);
try {
const guardrailsFromPipeline = pipeline.steps
.map((s) => s.guardrail)
.filter(Boolean);
const data: PolicyCreateRequest | PolicyUpdateRequest = {
policy_name: policyName,
description: description || undefined,
guardrails_add: guardrailsFromPipeline,
guardrails_remove: [],
pipeline: pipeline,
};
if (isEditing && editingPolicy) {
await updatePolicy(accessToken, editingPolicy.policy_id, data as PolicyUpdateRequest);
NotificationsManager.success("Policy updated successfully");
} else {
await createPolicy(accessToken, data as PolicyCreateRequest);
NotificationsManager.success("Policy created successfully");
}
onSuccess();
onBack();
} catch (error) {
console.error("Failed to save policy:", error);
NotificationsManager.fromBackend(
"Failed to save policy: " + (error instanceof Error ? error.message : String(error))
);
} finally {
setIsSubmitting(false);
}
};
return (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "#f9fafb",
zIndex: 1000,
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
{/* Header bar */}
<div
style={{
borderBottom: "1px solid #e5e7eb",
backgroundColor: "#fff",
padding: "10px 24px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flexShrink: 0,
}}
>
<div className="flex items-center gap-3">
<button
onClick={onBack}
style={{
background: "none",
border: "none",
cursor: "pointer",
padding: 4,
display: "flex",
alignItems: "center",
}}
>
<ArrowLeftIcon style={{ width: 18, height: 18, color: "#6b7280" }} />
</button>
<span style={{ fontSize: 14, color: "#6b7280" }}>Policies</span>
<span style={{ fontSize: 14, color: "#d1d5db" }}>/</span>
<TextInput
placeholder="Policy name..."
value={policyName}
onChange={(e) => setPolicyName(e.target.value)}
disabled={isEditing}
style={{ width: 240 }}
/>
<span
style={{
fontSize: 11,
fontWeight: 600,
backgroundColor: "#eef2ff",
color: "#6366f1",
padding: "3px 8px",
borderRadius: 4,
letterSpacing: "0.02em",
}}
>
Flow
</span>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" onClick={onBack}>
Cancel
</Button>
<Button
variant="secondary"
onClick={() => setShowTestPanel(!showTestPanel)}
>
{showTestPanel ? "Hide Test" : "Test Pipeline"}
</Button>
<Button onClick={handleSave} loading={isSubmitting}>
{isEditing ? "Update Policy" : "Save Policy"}
</Button>
</div>
</div>
{/* Description bar */}
<div
style={{
padding: "8px 24px",
backgroundColor: "#fff",
borderBottom: "1px solid #e5e7eb",
flexShrink: 0,
}}
>
<TextInput
placeholder="Add a description (optional)..."
value={description}
onChange={(e) => setDescription(e.target.value)}
style={{ maxWidth: 500 }}
/>
</div>
{/* Flow builder canvas + test panel */}
<div style={{ flex: 1, display: "flex", overflow: "hidden" }}>
<div
style={{
flex: 1,
overflowY: "auto",
display: "flex",
justifyContent: "center",
padding: "32px 24px",
}}
>
<div style={{ maxWidth: 760, width: "100%" }}>
<PipelineFlowBuilder
pipeline={pipeline}
onChange={setPipeline}
availableGuardrails={availableGuardrails}
/>
</div>
</div>
{showTestPanel && (
<PipelineTestPanel
pipeline={pipeline}
accessToken={accessToken}
onClose={() => setShowTestPanel(false)}
/>
)}
</div>
</div>
);
};
export { createDefaultStep };
export default PipelineFlowBuilder;

View file

@ -3,6 +3,7 @@ import { Card, Badge, Button } from "@tremor/react";
import { ArrowLeftIcon, PencilIcon } from "@heroicons/react/outline";
import { Descriptions, Tag, Spin, Divider, Typography, Alert } from "antd";
import { Policy } from "./types";
import { PipelineInfoDisplay } from "./pipeline_flow_builder";
import { getResolvedGuardrails } from "../networking";
const { Title, Text } = Typography;
@ -127,6 +128,21 @@ const PolicyInfoView: React.FC<PolicyInfoViewProps> = ({
</Descriptions.Item>
</Descriptions>
{policy.pipeline && (
<>
<Divider orientation="left">
<Text strong>Pipeline Flow</Text>
</Divider>
<Alert
message={`Pipeline (${policy.pipeline.mode} mode, ${policy.pipeline.steps.length} step${policy.pipeline.steps.length !== 1 ? "s" : ""})`}
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
<PipelineInfoDisplay pipeline={policy.pipeline} />
</>
)}
<Divider orientation="left">
<Text strong>Guardrails Configuration</Text>
</Divider>

View file

@ -6,6 +6,7 @@ export interface Policy {
guardrails_add: string[];
guardrails_remove: string[];
condition: PolicyCondition | null;
pipeline?: GuardrailPipeline | null;
created_at?: string;
updated_at?: string;
created_by?: string;
@ -16,6 +17,19 @@ export interface PolicyCondition {
model?: string;
}
export interface PipelineStep {
guardrail: string;
on_fail: "block" | "allow" | "next" | "modify_response";
on_pass: "allow" | "block" | "next" | "modify_response";
pass_data?: boolean;
modify_response_message?: string | null;
}
export interface GuardrailPipeline {
mode: "pre_call" | "post_call";
steps: PipelineStep[];
}
export interface PolicyAttachment {
attachment_id: string;
policy_name: string;
@ -37,6 +51,7 @@ export interface PolicyCreateRequest {
guardrails_add?: string[];
guardrails_remove?: string[];
condition?: PolicyCondition;
pipeline?: GuardrailPipeline | null;
}
export interface PolicyUpdateRequest {
@ -46,6 +61,7 @@ export interface PolicyUpdateRequest {
guardrails_add?: string[];
guardrails_remove?: string[];
condition?: PolicyCondition;
pipeline?: GuardrailPipeline | null;
}
export interface PolicyAttachmentCreateRequest {
@ -66,3 +82,20 @@ export interface PolicyAttachmentListResponse {
attachments: PolicyAttachment[];
total_count: number;
}
export interface PipelineStepResult {
guardrail_name: string;
outcome: "pass" | "fail" | "error";
action_taken: string;
modified_data: Record<string, any> | null;
error_detail: string | null;
duration_seconds: number | null;
}
export interface PipelineTestResult {
terminal_action: string;
step_results: PipelineStepResult[];
modified_data: Record<string, any> | null;
error_message: string | null;
modify_response_message: string | null;
}