mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
Merge remote-tracking branch 'origin' into litellm_access_groups_inte
This commit is contained in:
commit
890927b69c
14 changed files with 1514 additions and 33 deletions
|
|
@ -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__
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 → {ACTION_LABELS[step.on_pass] || step.on_pass}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<FailIcon /> Fail → {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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue