mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
Merge pull request #14696 from ARajan1084/bedrock-guardrail-view
NEW Amazon Bedrock Guardrail Info View in Logs
This commit is contained in:
commit
c7af9d1b7e
19 changed files with 3492 additions and 265 deletions
|
|
@ -2879,7 +2879,34 @@ jobs:
|
|||
name: Install Playwright Browsers
|
||||
command: |
|
||||
npx playwright install
|
||||
|
||||
- run:
|
||||
name: Run UI unit tests (Vitest)
|
||||
command: |
|
||||
# Use the same Node version we installed earlier with nvm
|
||||
export NVM_DIR="/opt/circleci/.nvm"
|
||||
source "$NVM_DIR/nvm.sh"
|
||||
nvm use v18.17.0
|
||||
|
||||
cd ui/litellm-dashboard
|
||||
# Ensure clean, deterministic install (skip if you prefer npm install)
|
||||
npm ci || npm install
|
||||
|
||||
# Run Vitest via your package.json script:
|
||||
# - --run to avoid watch mode on CI
|
||||
# - --coverage to produce coverage
|
||||
# - --coverage.reporter=lcov so Codecov can ingest lcov.info
|
||||
# - Feel free to drop --reporter if you don’t need extra output
|
||||
npm run test -- --run --coverage --coverage.reporter=lcov
|
||||
|
||||
# If you use a JUnit reporter (e.g., vitest-junit-reporter),
|
||||
# ensure it writes to ./test-results/junit.xml so Circle collects it
|
||||
mkdir -p test-results || true
|
||||
- store_test_results:
|
||||
path: ui/litellm-dashboard/test-results
|
||||
- store_artifacts:
|
||||
path: ui/litellm-dashboard/coverage
|
||||
destination: ui-coverage
|
||||
|
||||
- run:
|
||||
name: Build Docker image
|
||||
command: docker build -t my-app:latest -f ./docker/Dockerfile.database .
|
||||
|
|
|
|||
|
|
@ -357,6 +357,7 @@ class CustomGuardrail(CustomLogger):
|
|||
end_time: Optional[float] = None,
|
||||
duration: Optional[float] = None,
|
||||
masked_entity_count: Optional[Dict[str, int]] = None,
|
||||
guardrail_provider: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Builds `StandardLoggingGuardrailInformation` and adds it to the request metadata so it can be used for logging to DataDog, Langfuse, etc.
|
||||
|
|
@ -367,6 +368,7 @@ class CustomGuardrail(CustomLogger):
|
|||
|
||||
slg = StandardLoggingGuardrailInformation(
|
||||
guardrail_name=self.guardrail_name,
|
||||
guardrail_provider=guardrail_provider,
|
||||
guardrail_mode=(
|
||||
GuardrailMode(**self.event_hook.model_dump()) # type: ignore
|
||||
if isinstance(self.event_hook, Mode)
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
|
|||
)
|
||||
self.guardrailIdentifier = guardrailIdentifier
|
||||
self.guardrailVersion = guardrailVersion
|
||||
self.guardrail_provider = "bedrock"
|
||||
|
||||
# store kwargs as optional_params
|
||||
self.optional_params = kwargs
|
||||
|
|
@ -372,6 +373,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
|
|||
# Add guardrail information to request trace
|
||||
#########################################################
|
||||
self.add_standard_logging_guardrail_information_to_request_data(
|
||||
guardrail_provider=self.guardrail_provider,
|
||||
guardrail_json_response=response.json(),
|
||||
request_data=request_data or {},
|
||||
guardrail_status=self._get_bedrock_guardrail_response_status(
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail):
|
|||
self.logging_only = True
|
||||
kwargs["event_hook"] = GuardrailEventHooks.logging_only
|
||||
super().__init__(**kwargs)
|
||||
self.guardrail_provider = "presidio"
|
||||
self.pii_tokens: dict = (
|
||||
{}
|
||||
) # mapping of PII token to original text - only used with Presidio `replace` operation
|
||||
|
|
@ -369,6 +370,7 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail):
|
|||
else:
|
||||
guardrail_json_response = exception_str
|
||||
self.add_standard_logging_guardrail_information_to_request_data(
|
||||
guardrail_provider=self.guardrail_provider,
|
||||
guardrail_json_response=guardrail_json_response,
|
||||
request_data=request_data,
|
||||
guardrail_status=status,
|
||||
|
|
|
|||
|
|
@ -2015,6 +2015,7 @@ class GuardrailMode(TypedDict, total=False):
|
|||
|
||||
class StandardLoggingGuardrailInformation(TypedDict, total=False):
|
||||
guardrail_name: Optional[str]
|
||||
guardrail_provider: Optional[str]
|
||||
guardrail_mode: Optional[
|
||||
Union[GuardrailEventHooks, List[GuardrailEventHooks], GuardrailMode]
|
||||
]
|
||||
|
|
|
|||
2153
ui/litellm-dashboard/package-lock.json
generated
2153
ui/litellm-dashboard/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -6,7 +6,9 @@
|
|||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"test": "vitest",
|
||||
"test:watch": "vitest -w"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.54.0",
|
||||
|
|
@ -40,6 +42,9 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/lodash": "^4.17.15",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "18.2.48",
|
||||
|
|
@ -50,10 +55,13 @@
|
|||
"autoprefixer": "^10.4.17",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.2.32",
|
||||
"jsdom": "^27.0.0",
|
||||
"postcss": "^8.4.33",
|
||||
"prettier": "3.2.5",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "5.3.3"
|
||||
"typescript": "5.3.3",
|
||||
"vite": "^5.4.20",
|
||||
"vitest": "^1.6.1"
|
||||
},
|
||||
"overrides": {
|
||||
"prismjs": ">=1.30.0",
|
||||
|
|
|
|||
|
|
@ -1,257 +0,0 @@
|
|||
import React, { useState } from "react";
|
||||
|
||||
interface RecognitionMetadata {
|
||||
recognizer_name: string;
|
||||
recognizer_identifier: string;
|
||||
}
|
||||
|
||||
interface GuardrailEntity {
|
||||
end: number;
|
||||
score: number;
|
||||
start: number;
|
||||
entity_type: string;
|
||||
analysis_explanation: string | null;
|
||||
recognition_metadata: RecognitionMetadata;
|
||||
}
|
||||
|
||||
interface MaskedEntityCount {
|
||||
[key: string]: number;
|
||||
}
|
||||
|
||||
interface GuardrailInformation {
|
||||
duration: number;
|
||||
end_time: number;
|
||||
start_time: number;
|
||||
guardrail_mode: string;
|
||||
guardrail_name: string;
|
||||
guardrail_status: string;
|
||||
guardrail_response: GuardrailEntity[];
|
||||
masked_entity_count: MaskedEntityCount;
|
||||
}
|
||||
|
||||
interface GuardrailViewerProps {
|
||||
data: GuardrailInformation;
|
||||
}
|
||||
|
||||
export function GuardrailViewer({ data }: GuardrailViewerProps) {
|
||||
const [sectionExpanded, setSectionExpanded] = useState(true);
|
||||
const [entityListExpanded, setEntityListExpanded] = useState(true);
|
||||
const [expandedEntities, setExpandedEntities] = useState<Record<number, boolean>>({});
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate total masked entities
|
||||
const totalMaskedEntities = data.masked_entity_count ?
|
||||
Object.values(data.masked_entity_count).reduce((sum, count) => sum + count, 0) : 0;
|
||||
|
||||
const formatTime = (timestamp: number): string => {
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const toggleEntity = (index: number) => {
|
||||
setExpandedEntities(prev => ({
|
||||
...prev,
|
||||
[index]: !prev[index]
|
||||
}));
|
||||
};
|
||||
|
||||
const getScoreColor = (score: number): string => {
|
||||
if (score >= 0.8) return "text-green-600";
|
||||
return "text-yellow-600";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow mb-6">
|
||||
<div
|
||||
className="flex justify-between items-center p-4 border-b cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => setSectionExpanded(!sectionExpanded)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<svg
|
||||
className={`w-5 h-5 mr-2 text-gray-600 transition-transform ${sectionExpanded ? 'transform rotate-90' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<h3 className="text-lg font-medium">Guardrail Information</h3>
|
||||
<span className={`ml-3 px-2 py-1 rounded-md text-xs font-medium inline-block ${
|
||||
data.guardrail_status === "success"
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{data.guardrail_status}
|
||||
</span>
|
||||
{totalMaskedEntities > 0 && (
|
||||
<span className="ml-3 px-2 py-1 bg-blue-50 text-blue-700 rounded-md text-xs font-medium">
|
||||
{totalMaskedEntities} masked {totalMaskedEntities === 1 ? 'entity' : 'entities'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">{sectionExpanded ? 'Click to collapse' : 'Click to expand'}</span>
|
||||
</div>
|
||||
|
||||
{sectionExpanded && (
|
||||
<div className="p-4">
|
||||
<div className="bg-white rounded-lg border p-4 mb-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Guardrail Name:</span>
|
||||
<span className="font-mono">{data.guardrail_name}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Mode:</span>
|
||||
<span className="font-mono">{data.guardrail_mode}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Status:</span>
|
||||
<span className={`px-2 py-1 rounded-md text-xs font-medium inline-block ${
|
||||
data.guardrail_status === "success"
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{data.guardrail_status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Start Time:</span>
|
||||
<span>{formatTime(data.start_time)}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">End Time:</span>
|
||||
<span>{formatTime(data.end_time)}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Duration:</span>
|
||||
<span>{data.duration.toFixed(4)}s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Masked Entity Summary */}
|
||||
{data.masked_entity_count && Object.keys(data.masked_entity_count).length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<h4 className="font-medium mb-2">Masked Entity Summary</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(data.masked_entity_count).map(([entityType, count]) => (
|
||||
<span key={entityType} className="px-3 py-1.5 bg-blue-50 text-blue-700 rounded-md text-xs font-medium">
|
||||
{entityType}: {count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Detected Entities Section */}
|
||||
{data.guardrail_response && data.guardrail_response.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<div
|
||||
className="flex items-center mb-2 cursor-pointer"
|
||||
onClick={() => setEntityListExpanded(!entityListExpanded)}
|
||||
>
|
||||
<svg
|
||||
className={`w-5 h-5 mr-2 transition-transform ${entityListExpanded ? 'transform rotate-90' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<h4 className="font-medium">Detected Entities ({data.guardrail_response.length})</h4>
|
||||
</div>
|
||||
|
||||
{entityListExpanded && (
|
||||
<div className="space-y-2">
|
||||
{data.guardrail_response.map((entity, index) => {
|
||||
const isExpanded = expandedEntities[index] || false;
|
||||
|
||||
return (
|
||||
<div key={index} className="border rounded-lg overflow-hidden">
|
||||
<div
|
||||
className="flex items-center justify-between p-3 bg-gray-50 cursor-pointer hover:bg-gray-100"
|
||||
onClick={() => toggleEntity(index)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<svg
|
||||
className={`w-5 h-5 mr-2 transition-transform ${isExpanded ? 'transform rotate-90' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span className="font-medium mr-2">{entity.entity_type}</span>
|
||||
<span className={`font-mono ${getScoreColor(entity.score)}`}>
|
||||
Score: {entity.score.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">
|
||||
Position: {entity.start}-{entity.end}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="p-3 border-t bg-white">
|
||||
<div className="grid grid-cols-2 gap-4 mb-2">
|
||||
<div className="space-y-2">
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Entity Type:</span>
|
||||
<span>{entity.entity_type}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Position:</span>
|
||||
<span>Characters {entity.start}-{entity.end}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Confidence:</span>
|
||||
<span className={getScoreColor(entity.score)}>
|
||||
{entity.score.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{entity.recognition_metadata && (
|
||||
<>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Recognizer:</span>
|
||||
<span>{entity.recognition_metadata.recognizer_name}</span>
|
||||
</div>
|
||||
<div className="flex overflow-hidden">
|
||||
<span className="font-medium w-1/3">Identifier:</span>
|
||||
<span className="truncate text-xs font-mono">
|
||||
{entity.recognition_metadata.recognizer_identifier}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{entity.analysis_explanation && (
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Explanation:</span>
|
||||
<span>{entity.analysis_explanation}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
import React from 'react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import BedrockGuardrailDetails, {
|
||||
BedrockGuardrailResponse,
|
||||
} from '@/components/view_logs/GuardrailViewer/BedrockGuardrailDetails';
|
||||
import { renderWithProviders, screen } from "../../../../tests/test-utils"
|
||||
import {
|
||||
makeAssessment,
|
||||
makeBedrockCoverage,
|
||||
makeBedrockResponse,
|
||||
makeBedrockUsage,
|
||||
} from "@/components/view_logs/GuardrailViewer/__tests__/fixtures"
|
||||
|
||||
describe('BedrockGuardrailDetails', () => {
|
||||
it('returns null when response is falsy', () => {
|
||||
// @ts-expect-error testing nullish handling
|
||||
const { container } = renderWithProviders(<BedrockGuardrailDetails response={undefined} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders top summary: action chip, reason, blocked response', () => {
|
||||
const resp: BedrockGuardrailResponse = makeBedrockResponse({
|
||||
action: 'GUARDRAIL_INTERVENED',
|
||||
actionReason: 'Policy violation',
|
||||
blockedResponse: '[blocked]',
|
||||
});
|
||||
renderWithProviders(<BedrockGuardrailDetails response={resp} />);
|
||||
|
||||
expect(screen.getByText('Action:')).toBeInTheDocument();
|
||||
expect(screen.getByText('Policy violation')).toBeInTheDocument();
|
||||
expect(screen.getByText('[blocked]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders coverage and usage pills', () => {
|
||||
const resp = makeBedrockResponse({
|
||||
guardrailCoverage: makeBedrockCoverage(),
|
||||
usage: makeBedrockUsage({ contentPolicyUnits: 7, wordPolicyUnits: 1 }),
|
||||
});
|
||||
renderWithProviders(<BedrockGuardrailDetails response={resp} />);
|
||||
|
||||
expect(screen.getByText(/text guarded 27\/100/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/images guarded 1\/3/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/contentPolicyUnits: 7/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/wordPolicyUnits: 1/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders outputs when present (prefers `outputs`, falls back to `output`)', () => {
|
||||
// Using outputs
|
||||
let resp = makeBedrockResponse({ outputs: [{ text: 'hello' }] });
|
||||
const { rerender } = renderWithProviders(<BedrockGuardrailDetails response={resp} />);
|
||||
expect(screen.getByText('Outputs')).toBeInTheDocument();
|
||||
expect(screen.getByText('hello')).toBeInTheDocument();
|
||||
|
||||
// Using output
|
||||
resp = makeBedrockResponse({ outputs: undefined, output: [{ text: 'world' }] });
|
||||
rerender(<BedrockGuardrailDetails response={resp} />);
|
||||
expect(screen.getByText('world')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders assessments with all policy sections and metrics', () => {
|
||||
const resp = makeBedrockResponse({
|
||||
assessments: [makeAssessment()],
|
||||
});
|
||||
renderWithProviders(<BedrockGuardrailDetails response={resp} />);
|
||||
|
||||
// Assessment section present
|
||||
expect(screen.getByText('Assessment #1')).toBeInTheDocument();
|
||||
|
||||
// Word policy sections
|
||||
expect(screen.getByText('Word Policy')).toBeInTheDocument();
|
||||
expect(screen.getByText('Custom Words')).toBeInTheDocument();
|
||||
expect(screen.getByText('Managed Word Lists')).toBeInTheDocument();
|
||||
|
||||
// Contextual grounding table headers
|
||||
expect(screen.getByText('Contextual Grounding')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Score').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Threshold').length).toBeGreaterThan(0);
|
||||
|
||||
// Sensitive Info sections
|
||||
expect(screen.getByText('Sensitive Information')).toBeInTheDocument();
|
||||
expect(screen.getByText('PII Entities')).toBeInTheDocument();
|
||||
expect(screen.getByText('Custom Regexes')).toBeInTheDocument();
|
||||
|
||||
// Topic Policy
|
||||
expect(screen.getByText('Topic Policy')).toBeInTheDocument();
|
||||
expect(screen.getByText('weapons')).toBeInTheDocument();
|
||||
|
||||
// Invocation Metrics
|
||||
expect(screen.getByText('Invocation Metrics')).toBeInTheDocument();
|
||||
|
||||
// Raw JSON section exists (closed by default)
|
||||
expect(screen.getByText('Raw Bedrock Guardrail Response')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles non-text outputs gracefully', () => {
|
||||
const resp = makeBedrockResponse({ outputs: [{}, { text: 'texty' }] });
|
||||
renderWithProviders(<BedrockGuardrailDetails response={resp} />);
|
||||
expect(screen.getByText('(non-text output)')).toBeInTheDocument();
|
||||
expect(screen.getByText('texty')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('gracefully handles missing optional sections', () => {
|
||||
const resp = makeBedrockResponse({
|
||||
assessments: [
|
||||
{
|
||||
// only include minimal fields; others omitted
|
||||
invocationMetrics: { guardrailProcessingLatency: 5 },
|
||||
} as any,
|
||||
],
|
||||
usage: undefined,
|
||||
guardrailCoverage: undefined,
|
||||
outputs: [],
|
||||
});
|
||||
renderWithProviders(<BedrockGuardrailDetails response={resp} />);
|
||||
// No crash, minimal render: Assessment + Invocation Metrics present, but no usage/coverage chips at top
|
||||
expect(screen.getByText('Assessment #1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,508 @@
|
|||
import React, { useState } from "react";
|
||||
|
||||
export type BedrockGuardrailAction = "NONE" | "GUARDRAIL_INTERVENED";
|
||||
|
||||
export interface BedrockGuardrailUsage {
|
||||
wordPolicyUnits?: number;
|
||||
topicPolicyUnits?: number;
|
||||
contentPolicyUnits?: number;
|
||||
contentPolicyImageUnits?: number;
|
||||
automatedReasoningPolicies?: number;
|
||||
automatedReasoningPolicyUnits?: number;
|
||||
contextualGroundingPolicyUnits?: number;
|
||||
sensitiveInformationPolicyUnits?: number;
|
||||
sensitiveInformationPolicyFreeUnits?: number;
|
||||
}
|
||||
|
||||
export interface BedrockGuardrailCoverageDim {
|
||||
total?: number;
|
||||
guarded?: number;
|
||||
}
|
||||
|
||||
export interface BedrockGuardrailCoverage {
|
||||
images?: BedrockGuardrailCoverageDim;
|
||||
textCharacters?: BedrockGuardrailCoverageDim;
|
||||
}
|
||||
|
||||
export interface BedrockWordItem {
|
||||
action?: string; // e.g., "BLOCKED" or "ALLOWED"
|
||||
detected?: boolean;
|
||||
match?: string;
|
||||
type?: string; // present on managedWordLists entries
|
||||
}
|
||||
|
||||
export interface BedrockContentFilter {
|
||||
type?: string; // e.g. "HATE", etc.
|
||||
action?: string; // "BLOCKED" | "NONE" (varies by config)
|
||||
detected?: boolean;
|
||||
filterStrength?: string; // e.g., "HIGH"/"MEDIUM"/"LOW"
|
||||
confidence?: string; // e.g., "HIGH"/"MEDIUM"/"LOW"
|
||||
}
|
||||
|
||||
export interface BedrockContextualGroundingFilter {
|
||||
type?: string; // "GROUNDING" | "RELEVANCE"
|
||||
action?: string; // "BLOCKED" | ...
|
||||
detected?: boolean;
|
||||
score?: number; // model score
|
||||
threshold?: number; // configured threshold
|
||||
}
|
||||
|
||||
export interface BedrockPiiEntity {
|
||||
type?: string; // e.g., "ADDRESS", "EMAIL", etc.
|
||||
match?: string; // the matched text (may be masked)
|
||||
detected?: boolean;
|
||||
action?: string; // "BLOCKED" | "ANONYMIZED" | ...
|
||||
}
|
||||
|
||||
export interface BedrockRegexFinding {
|
||||
name?: string;
|
||||
regex?: string;
|
||||
match?: string;
|
||||
detected?: boolean;
|
||||
action?: string;
|
||||
}
|
||||
|
||||
export interface BedrockTopic {
|
||||
name?: string; // topic list entry name
|
||||
type?: string; // "DENY" | "ALLOW" depending on list
|
||||
detected?: boolean;
|
||||
action?: string; // "BLOCKED" | "NONE"
|
||||
}
|
||||
|
||||
export interface BedrockInvocationMetrics {
|
||||
usage?: BedrockGuardrailUsage;
|
||||
guardrailCoverage?: BedrockGuardrailCoverage;
|
||||
guardrailProcessingLatency?: number; // in ms
|
||||
}
|
||||
|
||||
export interface BedrockAssessment {
|
||||
wordPolicy?: { customWords?: BedrockWordItem[]; managedWordLists?: BedrockWordItem[] };
|
||||
contentPolicy?: { filters?: BedrockContentFilter[] };
|
||||
topicPolicy?: { topics?: BedrockTopic[] };
|
||||
sensitiveInformationPolicy?: { piiEntities?: BedrockPiiEntity[]; regexes?: BedrockRegexFinding[] };
|
||||
contextualGroundingPolicy?: { filters?: BedrockContextualGroundingFilter[] };
|
||||
automatedReasoningPolicy?: { findings?: any[] };
|
||||
invocationMetrics?: BedrockInvocationMetrics;
|
||||
}
|
||||
|
||||
export interface BedrockOutputContent {
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface BedrockGuardrailResponse {
|
||||
action?: BedrockGuardrailAction;
|
||||
actionReason?: string | null;
|
||||
outputs?: BedrockOutputContent[];
|
||||
output?: BedrockOutputContent[];
|
||||
usage?: BedrockGuardrailUsage;
|
||||
guardrailCoverage?: BedrockGuardrailCoverage;
|
||||
assessments?: BedrockAssessment[];
|
||||
blockedResponse?: string;
|
||||
}
|
||||
|
||||
/** ====== UI helpers ====== */
|
||||
type ChipTone = "green" | "red" | "blue" | "slate" | "amber";
|
||||
|
||||
const chip = (
|
||||
text: React.ReactNode,
|
||||
tone: ChipTone = "slate"
|
||||
) => {
|
||||
const map: Record<ChipTone, string> = {
|
||||
green: "bg-green-100 text-green-800",
|
||||
red: "bg-red-100 text-red-800",
|
||||
blue: "bg-blue-50 text-blue-700",
|
||||
slate: "bg-slate-100 text-slate-800",
|
||||
amber: "bg-amber-100 text-amber-800",
|
||||
};
|
||||
return <span className={`px-2 py-1 rounded-md text-xs font-medium inline-block ${map[tone]}`}>{text}</span>;
|
||||
};
|
||||
|
||||
const boolPill = (b?: boolean) => (b ? chip("detected", "red") : chip("not detected", "slate"));
|
||||
|
||||
interface SectionProps {
|
||||
title: string;
|
||||
count?: number;
|
||||
defaultOpen?: boolean;
|
||||
right?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Section: React.FC<SectionProps> = ({
|
||||
title,
|
||||
count,
|
||||
defaultOpen = true,
|
||||
right,
|
||||
children,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div
|
||||
className="flex items-center justify-between p-3 bg-gray-50 cursor-pointer hover:bg-gray-100"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<svg className={`w-5 h-5 mr-2 transition-transform ${open ? "transform rotate-90" : ""}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<h5 className="font-medium">
|
||||
{title} {typeof count === "number" && <span className="text-gray-500 font-normal">({count})</span>}
|
||||
</h5>
|
||||
</div>
|
||||
<div>{right}</div>
|
||||
</div>
|
||||
{open && <div className="p-3 border-t bg-white">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface KVProps {
|
||||
label: string;
|
||||
children?: React.ReactNode;
|
||||
mono?: boolean;
|
||||
}
|
||||
|
||||
const KV: React.FC<KVProps> = ({ label, children, mono }) => (
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">{label}</span>
|
||||
<span className={mono ? "font-mono text-sm break-all" : ""}>{children}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Divider: React.FC = () => <div className="my-3 border-t" />;
|
||||
|
||||
/** ====== Main component ====== */
|
||||
export const BedrockGuardrailDetails: React.FC<{ response: BedrockGuardrailResponse }> = ({ response }) => {
|
||||
|
||||
if (!response) return null;
|
||||
|
||||
const outputs: BedrockOutputContent[] = (response.outputs ?? response.output ?? []) as BedrockOutputContent[];
|
||||
|
||||
const actionTone: "green" | "red" = response.action === "GUARDRAIL_INTERVENED" ? "red" : "green";
|
||||
|
||||
const coverageChips = (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{response.guardrailCoverage?.textCharacters && (
|
||||
chip(
|
||||
`text guarded ${response.guardrailCoverage.textCharacters.guarded ?? 0}/${response.guardrailCoverage.textCharacters.total ?? 0}`,
|
||||
"blue"
|
||||
)
|
||||
)}
|
||||
{response.guardrailCoverage?.images && (
|
||||
chip(
|
||||
`images guarded ${response.guardrailCoverage.images.guarded ?? 0}/${response.guardrailCoverage.images.total ?? 0}`,
|
||||
"blue"
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const usagePills = response.usage && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(response.usage).map(([k, v]) =>
|
||||
typeof v === "number" ? (
|
||||
<span key={k} className="px-2 py-1 bg-slate-100 text-slate-800 rounded-md text-xs font-medium">
|
||||
{k}: {v}
|
||||
</span>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Top summary card */}
|
||||
<div className="border rounded-lg p-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<KV label="Action:">
|
||||
{chip(response.action ?? "N/A", actionTone)}
|
||||
</KV>
|
||||
{response.actionReason && <KV label="Action Reason:">{response.actionReason}</KV>}
|
||||
{response.blockedResponse && (
|
||||
<KV label="Blocked Response:">
|
||||
<span className="italic">{response.blockedResponse}</span>
|
||||
</KV>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<KV label="Coverage:">{coverageChips}</KV>
|
||||
<KV label="Usage:">{usagePills}</KV>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Outputs */}
|
||||
{outputs.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<h4 className="font-medium mb-2">Outputs</h4>
|
||||
<div className="space-y-2">
|
||||
{outputs.map((o, i) => (
|
||||
<div key={i} className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="text-sm whitespace-pre-wrap">{o.text ?? <em>(non-text output)</em>}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Assessments */}
|
||||
{response.assessments?.length ? (
|
||||
<div className="space-y-3">
|
||||
{response.assessments.map((assess, idx) => {
|
||||
const policyBadges = (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{assess.wordPolicy && chip("word", "slate")}
|
||||
{assess.contentPolicy && chip("content", "slate")}
|
||||
{assess.topicPolicy && chip("topic", "slate")}
|
||||
{assess.sensitiveInformationPolicy && chip("sensitive-info", "slate")}
|
||||
{assess.contextualGroundingPolicy && chip("contextual-grounding", "slate")}
|
||||
{assess.automatedReasoningPolicy && chip("automated-reasoning", "slate")}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Section
|
||||
key={idx}
|
||||
title={`Assessment #${idx + 1}`}
|
||||
defaultOpen
|
||||
right={
|
||||
<div className="flex items-center gap-3">
|
||||
{assess.invocationMetrics?.guardrailProcessingLatency != null &&
|
||||
chip(`${assess.invocationMetrics.guardrailProcessingLatency} ms`, "amber")}
|
||||
{policyBadges}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* Word policy */}
|
||||
{assess.wordPolicy && (
|
||||
<div className="mb-3">
|
||||
<h6 className="font-medium mb-2">Word Policy</h6>
|
||||
{(assess.wordPolicy.customWords?.length ?? 0) > 0 && (
|
||||
<Section title="Custom Words" defaultOpen>
|
||||
<div className="space-y-2">
|
||||
{assess.wordPolicy.customWords!.map((w, i) => (
|
||||
<div key={i} className="flex justify-between items-center p-2 bg-gray-50 rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
{chip(w.action ?? "N/A", w.detected ? "red" : "slate")}
|
||||
<span className="font-mono text-sm break-all">{w.match}</span>
|
||||
</div>
|
||||
{boolPill(w.detected)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
{(assess.wordPolicy.managedWordLists?.length ?? 0) > 0 && (
|
||||
<Section title="Managed Word Lists" defaultOpen={false}>
|
||||
<div className="space-y-2">
|
||||
{assess.wordPolicy.managedWordLists!.map((w, i) => (
|
||||
<div key={i} className="flex justify-between items-center p-2 bg-gray-50 rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
{chip(w.action ?? "N/A", w.detected ? "red" : "slate")}
|
||||
<span className="font-mono text-sm break-all">{w.match}</span>
|
||||
{w.type && chip(w.type, "slate")}
|
||||
</div>
|
||||
{boolPill(w.detected)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content policy */}
|
||||
{assess.contentPolicy?.filters?.length ? (
|
||||
<div className="mb-3">
|
||||
<h6 className="font-medium mb-2">Content Policy</h6>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-600">
|
||||
<th className="py-1 pr-4">Type</th>
|
||||
<th className="py-1 pr-4">Action</th>
|
||||
<th className="py-1 pr-4">Detected</th>
|
||||
<th className="py-1 pr-4">Strength</th>
|
||||
<th className="py-1 pr-4">Confidence</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{assess.contentPolicy.filters!.map((f, i) => (
|
||||
<tr key={i} className="border-t">
|
||||
<td className="py-1 pr-4">{f.type ?? "—"}</td>
|
||||
<td className="py-1 pr-4">{chip(f.action ?? "—", f.detected ? "red" : "slate")}</td>
|
||||
<td className="py-1 pr-4">{boolPill(f.detected)}</td>
|
||||
<td className="py-1 pr-4">{f.filterStrength ?? "—"}</td>
|
||||
<td className="py-1 pr-4">{f.confidence ?? "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Contextual grounding */}
|
||||
{assess.contextualGroundingPolicy?.filters?.length ? (
|
||||
<div className="mb-3">
|
||||
<h6 className="font-medium mb-2">Contextual Grounding</h6>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-600">
|
||||
<th className="py-1 pr-4">Type</th>
|
||||
<th className="py-1 pr-4">Action</th>
|
||||
<th className="py-1 pr-4">Detected</th>
|
||||
<th className="py-1 pr-4">Score</th>
|
||||
<th className="py-1 pr-4">Threshold</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{assess.contextualGroundingPolicy.filters!.map((f, i) => (
|
||||
<tr key={i} className="border-t">
|
||||
<td className="py-1 pr-4">{f.type ?? "—"}</td>
|
||||
<td className="py-1 pr-4">{chip(f.action ?? "—", f.detected ? "red" : "slate")}</td>
|
||||
<td className="py-1 pr-4">{boolPill(f.detected)}</td>
|
||||
<td className="py-1 pr-4">{f.score ?? "—"}</td>
|
||||
<td className="py-1 pr-4">{f.threshold ?? "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Sensitive Information */}
|
||||
{assess.sensitiveInformationPolicy && (
|
||||
<div className="mb-3">
|
||||
<h6 className="font-medium mb-2">Sensitive Information</h6>
|
||||
{(assess.sensitiveInformationPolicy.piiEntities?.length ?? 0) > 0 && (
|
||||
<Section title="PII Entities" defaultOpen>
|
||||
<div className="space-y-2">
|
||||
{assess.sensitiveInformationPolicy.piiEntities!.map((p, i) => (
|
||||
<div key={i} className="flex justify-between items-center p-2 bg-gray-50 rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
{chip(p.action ?? "N/A", p.detected ? "red" : "slate")}
|
||||
{p.type && chip(p.type, "slate")}
|
||||
<span className="font-mono text-xs break-all">{p.match}</span>
|
||||
</div>
|
||||
{boolPill(p.detected)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
{(assess.sensitiveInformationPolicy.regexes?.length ?? 0) > 0 && (
|
||||
<Section title="Custom Regexes" defaultOpen={false}>
|
||||
<div className="space-y-2">
|
||||
{assess.sensitiveInformationPolicy.regexes!.map((r, i) => (
|
||||
<div key={i} className="flex flex-col sm:flex-row sm:items-center sm:justify-between p-2 bg-gray-50 rounded gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{chip(r.action ?? "N/A", r.detected ? "red" : "slate")}
|
||||
<span className="font-medium">{r.name ?? "regex"}</span>
|
||||
<span className="font-mono text-xs break-all">{r.regex}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{boolPill(r.detected)}
|
||||
{r.match && <span className="font-mono text-xs break-all">{r.match}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Topic policy */}
|
||||
{assess.topicPolicy?.topics?.length ? (
|
||||
<div className="mb-3">
|
||||
<h6 className="font-medium mb-2">Topic Policy</h6>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{assess.topicPolicy.topics!.map((t, i) => (
|
||||
<div key={i} className="px-3 py-1.5 bg-gray-50 rounded-md text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
{chip(t.action ?? "N/A", t.detected ? "red" : "slate")}
|
||||
<span className="font-medium">{t.name ?? "topic"}</span>
|
||||
{t.type && chip(t.type, "slate")}
|
||||
{boolPill(t.detected)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Invocation metrics */}
|
||||
{assess.invocationMetrics && (
|
||||
<Section title="Invocation Metrics" defaultOpen={false}>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<KV label="Latency (ms)">{assess.invocationMetrics.guardrailProcessingLatency ?? "—"}</KV>
|
||||
<KV label="Coverage:">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{assess.invocationMetrics.guardrailCoverage?.textCharacters &&
|
||||
chip(
|
||||
`text ${assess.invocationMetrics.guardrailCoverage.textCharacters.guarded ?? 0}/${
|
||||
assess.invocationMetrics.guardrailCoverage.textCharacters.total ?? 0
|
||||
}`,
|
||||
"blue"
|
||||
)}
|
||||
{assess.invocationMetrics.guardrailCoverage?.images &&
|
||||
chip(
|
||||
`images ${assess.invocationMetrics.guardrailCoverage.images.guarded ?? 0}/${
|
||||
assess.invocationMetrics.guardrailCoverage.images.total ?? 0
|
||||
}`,
|
||||
"blue"
|
||||
)}
|
||||
</div>
|
||||
</KV>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<KV label="Usage:">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{assess.invocationMetrics.usage &&
|
||||
Object.entries(assess.invocationMetrics.usage).map(([k, v]) =>
|
||||
typeof v === "number" ? (
|
||||
<span key={k} className="px-2 py-1 bg-slate-100 text-slate-800 rounded-md text-xs font-medium">
|
||||
{k}: {v}
|
||||
</span>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
</KV>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Automated reasoning (fallback render) */}
|
||||
{assess.automatedReasoningPolicy?.findings?.length ? (
|
||||
<Section title="Automated Reasoning Findings" defaultOpen={false}>
|
||||
<div className="space-y-2">
|
||||
{assess.automatedReasoningPolicy.findings!.map((f, i) => (
|
||||
<pre key={i} className="bg-gray-50 rounded p-2 text-xs overflow-x-auto">
|
||||
{JSON.stringify(f, null, 2)}
|
||||
</pre>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
) : null}
|
||||
</Section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Raw JSON (for debugging / completeness) */}
|
||||
<Section title="Raw Bedrock Guardrail Response" defaultOpen={false}>
|
||||
<pre className="bg-gray-50 rounded p-3 text-xs overflow-x-auto">{JSON.stringify(response, null, 2)}</pre>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BedrockGuardrailDetails;
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders, screen } from "../../../../tests/test-utils"
|
||||
import {
|
||||
makeBedrockResponse, makeEntity,
|
||||
makeGuardrailInformation,
|
||||
} from "@/components/view_logs/GuardrailViewer/__tests__/fixtures"
|
||||
import GuardrailViewer from "@/components/view_logs/GuardrailViewer/GuardrailViewer"
|
||||
|
||||
// We will mock child components selectively for some tests to assert prop passthrough,
|
||||
// but also run an integration-style render without mocks.
|
||||
const PresidioPath = '@/components/view_logs/GuardrailViewer/PresidioDetectedEntities';
|
||||
const BedrockPath = '@/components/view_logs/GuardrailViewer/BedrockGuardrailDetails';
|
||||
|
||||
describe('GuardrailViewer', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('shows header, status pill color, duration rounding, and time labels', () => {
|
||||
const data = makeGuardrailInformation({ duration: 1.23456, guardrail_status: 'success' });
|
||||
renderWithProviders(<GuardrailViewer data={data} />);
|
||||
|
||||
expect(screen.getByText('Guardrail Information')).toBeInTheDocument();
|
||||
// header status pill (success => green)
|
||||
const statusBadges = screen.getAllByText('success');
|
||||
// there are two status locations: header chip and grid "Status"
|
||||
expect(statusBadges.length).toBeGreaterThanOrEqual(1);
|
||||
// Quick class assertion for at least one of them
|
||||
expect(statusBadges[0].className).toMatch(/bg-green-100/);
|
||||
|
||||
// duration displays with 4 decimals
|
||||
expect(screen.getByText(/1\.2346s/)).toBeInTheDocument();
|
||||
|
||||
// time labels exist
|
||||
expect(screen.getByText('Start Time:')).toBeInTheDocument();
|
||||
expect(screen.getByText('End Time:')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calculates and displays masked entity totals with pluralization', () => {
|
||||
const data = makeGuardrailInformation({
|
||||
masked_entity_count: { EMAIL_ADDRESS: 2, PHONE_NUMBER: 1 },
|
||||
});
|
||||
renderWithProviders(<GuardrailViewer data={data} />);
|
||||
|
||||
expect(screen.getByText('3 masked entities')).toBeInTheDocument();
|
||||
// summary chips for each entry
|
||||
expect(screen.getByText('EMAIL_ADDRESS: 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('PHONE_NUMBER: 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides masked badge & summary when count is zero/empty', () => {
|
||||
const data = makeGuardrailInformation({ masked_entity_count: {} });
|
||||
renderWithProviders(<GuardrailViewer data={data} />);
|
||||
|
||||
expect(screen.queryByText(/masked entity/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Masked Entity Summary')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles main section open/closed and chevron rotation class', async () => {
|
||||
const user = userEvent.setup();
|
||||
const data = makeGuardrailInformation();
|
||||
renderWithProviders(<GuardrailViewer data={data} />);
|
||||
|
||||
const header = screen.getByText('Guardrail Information').closest('div')!;
|
||||
// Initially expanded
|
||||
expect(screen.getByText('Click to collapse')).toBeInTheDocument();
|
||||
// Click to collapse
|
||||
await user.click(header);
|
||||
expect(screen.getByText('Click to expand')).toBeInTheDocument();
|
||||
// Details gone
|
||||
expect(screen.queryByText('Masked Entity Summary')).not.toBeInTheDocument();
|
||||
|
||||
// Click to expand again
|
||||
await user.click(header);
|
||||
expect(screen.getByText('Click to collapse')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('defaults to presidio provider when guardrail_provider is undefined', async () => {
|
||||
vi.doMock(PresidioPath, () => ({
|
||||
__esModule: true,
|
||||
default: ({ entities }: any) => <div data-testid="presidio-mock">presidio {entities?.length}</div>,
|
||||
}));
|
||||
const { default: Component } = await import('@/components/view_logs/GuardrailViewer/GuardrailViewer');
|
||||
|
||||
const data = makeGuardrailInformation({
|
||||
guardrail_provider: undefined,
|
||||
guardrail_response: [makeEntity(), makeEntity()],
|
||||
});
|
||||
renderWithProviders(<Component data={data} />);
|
||||
|
||||
expect(screen.getByTestId('presidio-mock')).toHaveTextContent('presidio 2');
|
||||
});
|
||||
|
||||
it('renders PresidioDetectedEntities when provider="presidio" and response has entities', async () => {
|
||||
vi.doMock(PresidioPath, () => ({
|
||||
__esModule: true,
|
||||
default: ({ entities }: any) => <div data-testid="presidio-mock">count:{entities?.length}</div>,
|
||||
}));
|
||||
const { default: Component } = await import('@/components/view_logs/GuardrailViewer/GuardrailViewer');
|
||||
|
||||
const data = makeGuardrailInformation({
|
||||
guardrail_provider: 'presidio',
|
||||
guardrail_response: [makeEntity()],
|
||||
});
|
||||
renderWithProviders(<Component data={data} />);
|
||||
expect(screen.getByTestId('presidio-mock')).toHaveTextContent('count:1');
|
||||
});
|
||||
|
||||
it('renders BedrockGuardrailDetails when provider="bedrock"', async () => {
|
||||
vi.doMock(BedrockPath, () => ({
|
||||
__esModule: true,
|
||||
default: ({ response }: any) => (
|
||||
<div data-testid="bedrock-mock">{response?.action ?? 'no-action'}</div>
|
||||
),
|
||||
}));
|
||||
const { default: Component } = await import('@/components/view_logs/GuardrailViewer/GuardrailViewer');
|
||||
|
||||
const data = makeGuardrailInformation({
|
||||
guardrail_provider: 'bedrock',
|
||||
guardrail_response: makeBedrockResponse({ action: 'GUARDRAIL_INTERVENED' }),
|
||||
});
|
||||
renderWithProviders(<Component data={data} />);
|
||||
expect(screen.getByTestId('bedrock-mock')).toHaveTextContent('GUARDRAIL_INTERVENED');
|
||||
});
|
||||
|
||||
it('unknown provider renders neither Presidio nor Bedrock details', () => {
|
||||
const data = makeGuardrailInformation({
|
||||
guardrail_provider: 'unknown',
|
||||
});
|
||||
renderWithProviders(<GuardrailViewer data={data} />);
|
||||
// Summary still present
|
||||
expect(screen.getByText('Guardrail Information')).toBeInTheDocument();
|
||||
// No provider sections
|
||||
expect(screen.queryByText(/Detected Entities/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Raw Bedrock Guardrail Response/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('integration: renders with real Bedrock details without mocks', () => {
|
||||
const data = makeGuardrailInformation({
|
||||
guardrail_provider: 'bedrock',
|
||||
guardrail_response: makeBedrockResponse({
|
||||
action: 'NONE',
|
||||
outputs: [{ text: 'ok' }],
|
||||
}),
|
||||
});
|
||||
renderWithProviders(<GuardrailViewer data={data} />);
|
||||
|
||||
// Bedrock summary bits
|
||||
expect(screen.getByText('Outputs')).toBeInTheDocument();
|
||||
expect(screen.getByText('ok')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
import React, { useState } from "react";
|
||||
import PresidioDetectedEntities from "./PresidioDetectedEntities";
|
||||
import BedrockGuardrailDetails, {
|
||||
BedrockGuardrailResponse,
|
||||
} from "@/components/view_logs/GuardrailViewer/BedrockGuardrailDetails"
|
||||
|
||||
interface RecognitionMetadata {
|
||||
recognizer_name: string;
|
||||
recognizer_identifier: string;
|
||||
}
|
||||
|
||||
interface GuardrailEntity {
|
||||
end: number;
|
||||
score: number;
|
||||
start: number;
|
||||
entity_type: string;
|
||||
analysis_explanation: string | null;
|
||||
recognition_metadata: RecognitionMetadata;
|
||||
}
|
||||
|
||||
interface MaskedEntityCount {
|
||||
[key: string]: number;
|
||||
}
|
||||
|
||||
interface GuardrailInformation {
|
||||
duration: number;
|
||||
end_time: number;
|
||||
start_time: number;
|
||||
guardrail_mode: string;
|
||||
guardrail_name: string;
|
||||
guardrail_status: string;
|
||||
guardrail_response: GuardrailEntity[] | BedrockGuardrailResponse;
|
||||
masked_entity_count: MaskedEntityCount;
|
||||
guardrail_provider?: string; // "presidio" | other providers
|
||||
}
|
||||
|
||||
interface GuardrailViewerProps {
|
||||
data: GuardrailInformation;
|
||||
}
|
||||
|
||||
const GuardrailViewer = ({ data }: GuardrailViewerProps) => {
|
||||
const [sectionExpanded, setSectionExpanded] = useState(true);
|
||||
|
||||
// Default to presidio for backwards compatibility
|
||||
const guardrailProvider = data.guardrail_provider ?? "presidio";
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate total masked entities
|
||||
const totalMaskedEntities = data.masked_entity_count ?
|
||||
Object.values(data.masked_entity_count).reduce((sum, count) => sum + count, 0) : 0;
|
||||
|
||||
const formatTime = (timestamp: number): string => {
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow mb-6">
|
||||
<div
|
||||
className="flex justify-between items-center p-4 border-b cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => setSectionExpanded(!sectionExpanded)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<svg
|
||||
className={`w-5 h-5 mr-2 text-gray-600 transition-transform ${sectionExpanded ? 'transform rotate-90' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<h3 className="text-lg font-medium">Guardrail Information</h3>
|
||||
<span className={`ml-3 px-2 py-1 rounded-md text-xs font-medium inline-block ${
|
||||
data.guardrail_status === "success"
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{data.guardrail_status}
|
||||
</span>
|
||||
{totalMaskedEntities > 0 && (
|
||||
<span className="ml-3 px-2 py-1 bg-blue-50 text-blue-700 rounded-md text-xs font-medium">
|
||||
{totalMaskedEntities} masked {totalMaskedEntities === 1 ? 'entity' : 'entities'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">{sectionExpanded ? 'Click to collapse' : 'Click to expand'}</span>
|
||||
</div>
|
||||
|
||||
{sectionExpanded && (
|
||||
<div className="p-4">
|
||||
<div className="bg-white rounded-lg border p-4 mb-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Guardrail Name:</span>
|
||||
<span className="font-mono">{data.guardrail_name}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Mode:</span>
|
||||
<span className="font-mono">{data.guardrail_mode}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Status:</span>
|
||||
<span className={`px-2 py-1 rounded-md text-xs font-medium inline-block ${
|
||||
data.guardrail_status === "success"
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{data.guardrail_status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Start Time:</span>
|
||||
<span>{formatTime(data.start_time)}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">End Time:</span>
|
||||
<span>{formatTime(data.end_time)}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Duration:</span>
|
||||
<span>{data.duration.toFixed(4)}s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Masked Entity Summary */}
|
||||
{data.masked_entity_count && Object.keys(data.masked_entity_count).length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<h4 className="font-medium mb-2">Masked Entity Summary</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(data.masked_entity_count).map(([entityType, count]) => (
|
||||
<span key={entityType} className="px-3 py-1.5 bg-blue-50 text-blue-700 rounded-md text-xs font-medium">
|
||||
{entityType}: {count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Provider-specific Detected Entities */}
|
||||
{guardrailProvider === "presidio" && (data.guardrail_response as GuardrailEntity[])?.length > 0 && (
|
||||
<PresidioDetectedEntities entities={data.guardrail_response as GuardrailEntity[]} />
|
||||
)}
|
||||
|
||||
{guardrailProvider === "bedrock" && data.guardrail_response && (
|
||||
<div className="mt-4">
|
||||
<BedrockGuardrailDetails response={data.guardrail_response as BedrockGuardrailResponse} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GuardrailViewer;
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import React from 'react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import PresidioDetectedEntities from '@/components/view_logs/GuardrailViewer/PresidioDetectedEntities';
|
||||
import { renderWithProviders, screen } from "../../../../tests/test-utils"
|
||||
import { makeEntity } from "@/components/view_logs/GuardrailViewer/__tests__/fixtures"
|
||||
|
||||
describe('PresidioDetectedEntities', () => {
|
||||
it('renders null when entities empty', () => {
|
||||
const { container } = renderWithProviders(<PresidioDetectedEntities entities={[]} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders per-entity header info including score color and position', async () => {
|
||||
const user = userEvent.setup();
|
||||
const e = makeEntity({ start: 10, end: 20, score: 0.92, entity_type: 'EMAIL_ADDRESS' });
|
||||
renderWithProviders(<PresidioDetectedEntities entities={[e]} />);
|
||||
|
||||
// Header row values
|
||||
expect(screen.getByText('EMAIL_ADDRESS')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Score: 0\.92/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Position: 10-20')).toBeInTheDocument();
|
||||
|
||||
// Expand details
|
||||
await user.click(screen.getByText('EMAIL_ADDRESS'));
|
||||
expect(screen.getByText('Entity Type:')).toBeInTheDocument();
|
||||
expect(screen.getByText('Characters 10-20')).toBeInTheDocument();
|
||||
expect(screen.getByText('Confidence:')).toBeInTheDocument();
|
||||
// Recognizer details
|
||||
expect(screen.getByText('EmailRecognizer')).toBeInTheDocument();
|
||||
expect(screen.getByText('email_v1')).toBeInTheDocument();
|
||||
// Explanation
|
||||
expect(screen.getByText('Matched via pattern')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles missing metadata & low scores gracefully', async () => {
|
||||
const user = userEvent.setup();
|
||||
const e = makeEntity({
|
||||
score: 0.3,
|
||||
recognition_metadata: undefined as any,
|
||||
analysis_explanation: null,
|
||||
entity_type: 'NAME',
|
||||
start: 0,
|
||||
end: 0,
|
||||
});
|
||||
renderWithProviders(<PresidioDetectedEntities entities={[e]} />);
|
||||
|
||||
await user.click(screen.getByText('NAME'));
|
||||
// No recognizer/explanation rows
|
||||
expect(screen.queryByText('Recognizer:')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Explanation:')).not.toBeInTheDocument();
|
||||
// Position still renders
|
||||
expect(screen.getByText('Characters 0-0')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
import React, { useState } from "react";
|
||||
|
||||
interface RecognitionMetadata {
|
||||
recognizer_name: string;
|
||||
recognizer_identifier: string;
|
||||
}
|
||||
|
||||
export interface GuardrailEntity {
|
||||
end: number;
|
||||
score: number;
|
||||
start: number;
|
||||
entity_type: string;
|
||||
analysis_explanation: string | null;
|
||||
recognition_metadata: RecognitionMetadata;
|
||||
}
|
||||
|
||||
interface PresidioDetectedEntitiesProps {
|
||||
entities: GuardrailEntity[];
|
||||
}
|
||||
|
||||
const getScoreColor = (score: number): string => {
|
||||
if (score >= 0.8) return "text-green-600";
|
||||
return "text-yellow-600";
|
||||
};
|
||||
|
||||
const PresidioDetectedEntities = ({ entities }: PresidioDetectedEntitiesProps) => {
|
||||
const [entityListExpanded, setEntityListExpanded] = useState(true);
|
||||
const [expandedEntities, setExpandedEntities] = useState<Record<number, boolean>>({});
|
||||
|
||||
const toggleEntity = (index: number) => {
|
||||
setExpandedEntities((prev) => ({
|
||||
...prev,
|
||||
[index]: !prev[index],
|
||||
}));
|
||||
};
|
||||
|
||||
if (!entities || entities.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<div
|
||||
className="flex items-center mb-2 cursor-pointer"
|
||||
onClick={() => setEntityListExpanded(!entityListExpanded)}
|
||||
>
|
||||
<svg
|
||||
className={`w-5 h-5 mr-2 transition-transform ${entityListExpanded ? "transform rotate-90" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<h4 className="font-medium">Detected Entities ({entities.length})</h4>
|
||||
</div>
|
||||
|
||||
{entityListExpanded && (
|
||||
<div className="space-y-2">
|
||||
{entities.map((entity, index) => {
|
||||
const isExpanded = expandedEntities[index] || false;
|
||||
|
||||
return (
|
||||
<div key={index} className="border rounded-lg overflow-hidden">
|
||||
<div
|
||||
className="flex items-center justify-between p-3 bg-gray-50 cursor-pointer hover:bg-gray-100"
|
||||
onClick={() => toggleEntity(index)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<svg
|
||||
className={`w-5 h-5 mr-2 transition-transform ${isExpanded ? "transform rotate-90" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span className="font-medium mr-2">{entity.entity_type}</span>
|
||||
<span className={`font-mono ${getScoreColor(entity.score)}`}>
|
||||
Score: {entity.score.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">Position: {entity.start}-{entity.end}</span>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="p-3 border-t bg-white">
|
||||
<div className="grid grid-cols-2 gap-4 mb-2">
|
||||
<div className="space-y-2">
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Entity Type:</span>
|
||||
<span>{entity.entity_type}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Position:</span>
|
||||
<span>Characters {entity.start}-{entity.end}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Confidence:</span>
|
||||
<span className={getScoreColor(entity.score)}>{entity.score.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{entity.recognition_metadata && (
|
||||
<>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Recognizer:</span>
|
||||
<span>{entity.recognition_metadata.recognizer_name}</span>
|
||||
</div>
|
||||
<div className="flex overflow-hidden">
|
||||
<span className="font-medium w-1/3">Identifier:</span>
|
||||
<span className="truncate text-xs font-mono">
|
||||
{entity.recognition_metadata.recognizer_identifier}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{entity.analysis_explanation && (
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Explanation:</span>
|
||||
<span>{entity.analysis_explanation}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PresidioDetectedEntities;
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
import type {
|
||||
BedrockGuardrailResponse,
|
||||
BedrockAssessment,
|
||||
BedrockGuardrailCoverage,
|
||||
BedrockGuardrailUsage,
|
||||
} from '@/components/view_logs/GuardrailViewer/BedrockGuardrailDetails';
|
||||
|
||||
export interface RecognitionMetadata {
|
||||
recognizer_name: string;
|
||||
recognizer_identifier: string;
|
||||
}
|
||||
|
||||
export interface GuardrailEntity {
|
||||
end: number;
|
||||
score: number;
|
||||
start: number;
|
||||
entity_type: string;
|
||||
analysis_explanation: string | null;
|
||||
recognition_metadata: RecognitionMetadata;
|
||||
}
|
||||
|
||||
export interface GuardrailInformation {
|
||||
duration: number;
|
||||
end_time: number;
|
||||
start_time: number;
|
||||
guardrail_mode: string;
|
||||
guardrail_name: string;
|
||||
guardrail_status: string;
|
||||
guardrail_response: GuardrailEntity[] | BedrockGuardrailResponse;
|
||||
masked_entity_count: Record<string, number>;
|
||||
guardrail_provider?: string;
|
||||
}
|
||||
|
||||
// ===== Builders =====
|
||||
export const makeEntity = (overrides: Partial<GuardrailEntity> = {}): GuardrailEntity => ({
|
||||
end: 18,
|
||||
start: 5,
|
||||
score: 0.92,
|
||||
entity_type: 'EMAIL_ADDRESS',
|
||||
analysis_explanation: 'Matched via pattern',
|
||||
recognition_metadata: {
|
||||
recognizer_name: 'EmailRecognizer',
|
||||
recognizer_identifier: 'email_v1',
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export const makeMaskedCounts = (overrides: Record<string, number> = {}) => ({
|
||||
EMAIL_ADDRESS: 2,
|
||||
PHONE_NUMBER: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export const makeBedrockUsage = (overrides: Partial<BedrockGuardrailUsage> = {}): BedrockGuardrailUsage => ({
|
||||
contentPolicyUnits: 4,
|
||||
topicPolicyUnits: 2,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export const makeBedrockCoverage = (
|
||||
overrides: Partial<BedrockGuardrailCoverage> = {}
|
||||
): BedrockGuardrailCoverage => ({
|
||||
textCharacters: { guarded: 27, total: 100 },
|
||||
images: { guarded: 1, total: 3 },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export const makeAssessment = (overrides: Partial<BedrockAssessment> = {}): BedrockAssessment => ({
|
||||
wordPolicy: {
|
||||
customWords: [{ action: 'BLOCKED', detected: true, match: 'badword' }],
|
||||
managedWordLists: [{ action: 'ALLOWED', detected: false, match: 'ok', type: 'PROFANITY' }],
|
||||
},
|
||||
contentPolicy: {
|
||||
filters: [
|
||||
{ type: 'HATE', action: 'BLOCKED', detected: true, filterStrength: 'HIGH', confidence: 'MEDIUM' },
|
||||
{ type: 'VIOLENCE', action: 'NONE', detected: false, filterStrength: 'LOW', confidence: 'LOW' },
|
||||
],
|
||||
},
|
||||
topicPolicy: { topics: [{ name: 'weapons', type: 'DENY', detected: true, action: 'BLOCKED' }] },
|
||||
sensitiveInformationPolicy: {
|
||||
piiEntities: [{ type: 'EMAIL', match: 'x@y.com', detected: true, action: 'ANONYMIZED' }],
|
||||
regexes: [{ name: 'ticket', regex: '#[0-9]+', match: '#123', detected: true, action: 'BLOCKED' }],
|
||||
},
|
||||
contextualGroundingPolicy: {
|
||||
filters: [{ type: 'GROUNDING', action: 'BLOCKED', detected: true, score: 0.2, threshold: 0.5 }],
|
||||
},
|
||||
automatedReasoningPolicy: { findings: [{ foo: 'bar' }] },
|
||||
invocationMetrics: {
|
||||
guardrailProcessingLatency: 42,
|
||||
usage: makeBedrockUsage(),
|
||||
guardrailCoverage: makeBedrockCoverage(),
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export const makeBedrockResponse = (
|
||||
overrides: Partial<BedrockGuardrailResponse> = {}
|
||||
): BedrockGuardrailResponse => ({
|
||||
action: 'NONE',
|
||||
outputs: [{ text: 'ok' }],
|
||||
usage: makeBedrockUsage(),
|
||||
guardrailCoverage: makeBedrockCoverage(),
|
||||
assessments: [makeAssessment()],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export const makeGuardrailInformation = (
|
||||
overrides: Partial<GuardrailInformation> = {}
|
||||
): GuardrailInformation => ({
|
||||
guardrail_name: 'pii-rail',
|
||||
guardrail_mode: 'post',
|
||||
guardrail_status: 'success',
|
||||
start_time: 1_700_000_000,
|
||||
end_time: 1_700_000_123,
|
||||
duration: 0.123456,
|
||||
guardrail_response: [makeEntity()],
|
||||
masked_entity_count: makeMaskedCounts(),
|
||||
...overrides,
|
||||
});
|
||||
|
|
@ -17,7 +17,7 @@ import { KeyResponse, Team } from "../key_team_helpers/key_list"
|
|||
import KeyInfoView from "../templates/key_info_view"
|
||||
import { SessionView } from "./SessionView"
|
||||
import { VectorStoreViewer } from "./VectorStoreViewer"
|
||||
import { GuardrailViewer } from "./GuardrailViewer"
|
||||
import GuardrailViewer from "@/components/view_logs/GuardrailViewer/GuardrailViewer"
|
||||
import FilterComponent from "../molecules/filter"
|
||||
import { FilterOption } from "../molecules/filter"
|
||||
import { useLogFilterLogic } from "./log_filter_logic"
|
||||
|
|
|
|||
15
ui/litellm-dashboard/tests/setupTests.ts
Normal file
15
ui/litellm-dashboard/tests/setupTests.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import '@testing-library/jest-dom';
|
||||
import { afterEach, vi } from 'vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// Make toLocaleString deterministic in tests; individual tests can override
|
||||
// This returns ISO-like strings to keep assertions stable.
|
||||
vi.spyOn(Date.prototype, 'toLocaleString').mockImplementation(function (this: Date, ..._args: unknown[]) {
|
||||
const d = this;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
});
|
||||
12
ui/litellm-dashboard/tests/test-utils.tsx
Normal file
12
ui/litellm-dashboard/tests/test-utils.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import React, { PropsWithChildren } from 'react';
|
||||
import { render, RenderOptions } from '@testing-library/react';
|
||||
|
||||
const Providers: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
// Add future providers here (Theme/Router/QueryClient/etc.)
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export const renderWithProviders = (ui: React.ReactElement, options?: RenderOptions) =>
|
||||
render(ui, { wrapper: Providers, ...options });
|
||||
|
||||
export * from '@testing-library/react';
|
||||
17
ui/litellm-dashboard/vitest.config.ts
Normal file
17
ui/litellm-dashboard/vitest.config.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { defineConfig } from 'vitest/config'
|
||||
import { resolve } from "path"
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['tests/setupTests.ts'],
|
||||
globals: true,
|
||||
css: true, // lets you import CSS/modules without extra mocks
|
||||
coverage: { reporter: ['text', 'lcov'] },
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue