Skip to content

Commit 36a5e5e

Browse files
authored
Merge pull request #1048 from b4s36t4/feat/azure-guardrail-plugin
feat: azure plugin support for pii and content safety
2 parents f93d2e4 + 0185a31 commit 36a5e5e

File tree

7 files changed

+747
-0
lines changed

7 files changed

+747
-0
lines changed

plugins/azure/azure.test.ts

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
2+
import { handler as piiHandler } from './pii';
3+
import { handler as contentSafetyHandler } from './contentSafety';
4+
import { HookEventType, PluginContext, PluginParameters } from '../types';
5+
import { AzureCredentials } from './types';
6+
import { pii, contentSafety } from './.creds.json';
7+
8+
describe('Azure Plugins', () => {
9+
beforeEach(() => {
10+
jest.clearAllMocks();
11+
});
12+
13+
describe('PII Plugin', () => {
14+
const mockContext: PluginContext = {
15+
request: {
16+
text: 'My email is [email protected] and SSN is 123-45-6789',
17+
json: {
18+
messages: [
19+
{
20+
role: 'user',
21+
content: 'My email is [email protected] and SSN is 123-45-6789',
22+
},
23+
],
24+
},
25+
},
26+
requestType: 'chatComplete',
27+
};
28+
29+
describe('API Key Authentication', () => {
30+
const params: PluginParameters<{ pii: AzureCredentials }> = {
31+
credentials: {
32+
pii: pii.apiKey as AzureCredentials,
33+
},
34+
redact: true,
35+
apiVersion: '2024-11-01',
36+
};
37+
38+
it('should successfully analyze and redact PII with API key', async () => {
39+
const result = await piiHandler(
40+
mockContext,
41+
params,
42+
'beforeRequestHook'
43+
);
44+
45+
expect(result.error).toBeNull();
46+
expect(result.verdict).toBe(true);
47+
expect(result.transformed).toBe(true);
48+
});
49+
50+
it('should handle API errors gracefully', async () => {
51+
const result = await piiHandler(
52+
mockContext,
53+
{
54+
...params,
55+
credentials: {
56+
pii: {
57+
azureAuthMode: 'apiKey',
58+
resourceName: 'wrong-resurce-name',
59+
apiKey: 'wrong-api-key',
60+
},
61+
},
62+
},
63+
'beforeRequestHook'
64+
);
65+
66+
expect(result.error).toBeDefined();
67+
expect(result.verdict).toBe(true);
68+
expect(result.data).toBeNull();
69+
});
70+
});
71+
72+
describe('Entra ID Authentication', () => {
73+
const params: PluginParameters<{ pii: AzureCredentials }> = {
74+
credentials: {
75+
pii: pii.entra as AzureCredentials,
76+
},
77+
redact: true,
78+
};
79+
80+
it('should successfully analyze and redact PII with Entra ID', async () => {
81+
const result = await piiHandler(
82+
mockContext,
83+
params,
84+
'beforeRequestHook'
85+
);
86+
expect(result.error).toBeNull();
87+
expect(result.verdict).toBe(true);
88+
expect(result.transformed).toBe(true);
89+
});
90+
});
91+
});
92+
93+
describe('Content Safety Plugin', () => {
94+
const mockContext = {
95+
request: {
96+
text: "Fuck you, if you don't answer I'll kill you.",
97+
json: {
98+
messages: [
99+
{
100+
role: 'user',
101+
content: `Fuck you, if you don't answer I'll kill you.`,
102+
},
103+
],
104+
},
105+
},
106+
};
107+
108+
describe('API Key Authentication', () => {
109+
const params: PluginParameters<{ contentSafety: AzureCredentials }> = {
110+
credentials: {
111+
contentSafety: contentSafety.apiKey as AzureCredentials,
112+
},
113+
categories: ['Hate', 'Violence'],
114+
apiVersion: '2024-09-01',
115+
};
116+
117+
it('should successfully analyze content with API key', async () => {
118+
const result = await contentSafetyHandler(
119+
mockContext,
120+
params,
121+
'beforeRequestHook'
122+
);
123+
124+
expect(result.error).toBeNull();
125+
expect(result.verdict).toBe(false);
126+
expect(result.data).toBeDefined();
127+
});
128+
});
129+
130+
describe('Entra ID Authentication', () => {
131+
const params: PluginParameters<{ contentSafety: AzureCredentials }> = {
132+
credentials: {
133+
contentSafety: contentSafety.entra as AzureCredentials,
134+
},
135+
categories: ['Hate', 'Violence'],
136+
apiVersion: '2024-09-01',
137+
};
138+
139+
it('should successfully analyze content with Entra ID', async () => {
140+
const result = await contentSafetyHandler(
141+
mockContext,
142+
params,
143+
'beforeRequestHook'
144+
);
145+
146+
expect(result.error).toBeNull();
147+
expect(result.verdict).toBe(false);
148+
expect(result.data).toBeDefined();
149+
});
150+
151+
it('should detect harmful content correctly', async () => {
152+
const harmfulResponse = {
153+
categoriesAnalysis: [
154+
{
155+
category: 'Hate',
156+
severity: 2, // High severity
157+
},
158+
],
159+
blocklistsMatch: [],
160+
};
161+
162+
const result = await contentSafetyHandler(
163+
mockContext,
164+
params,
165+
'beforeRequestHook'
166+
);
167+
expect(result.error).toBeNull();
168+
expect(result.verdict).toBe(false); // Should be false due to high severity
169+
expect(result.data).toBeDefined();
170+
});
171+
});
172+
});
173+
});

plugins/azure/contentSafety.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import {
2+
HookEventType,
3+
PluginContext,
4+
PluginHandler,
5+
PluginParameters,
6+
} from '../types';
7+
import { post, getText } from '../utils';
8+
import { AzureCredentials } from './types';
9+
import { getAccessToken } from './utils';
10+
11+
const defaultCategories = ['Hate', 'SelfHarm', 'Sexual', 'Violence'];
12+
13+
export const handler: PluginHandler<{
14+
contentSafety: AzureCredentials;
15+
}> = async (
16+
context: PluginContext,
17+
parameters: PluginParameters<{ contentSafety: AzureCredentials }>,
18+
eventType: HookEventType,
19+
options
20+
) => {
21+
let error = null;
22+
let verdict = true;
23+
let data = null;
24+
25+
const credentials = parameters.credentials?.contentSafety;
26+
27+
if (!credentials) {
28+
return {
29+
error: new Error('parameters.credentials.contentSafety must be set'),
30+
verdict: true,
31+
data,
32+
};
33+
}
34+
35+
// Validate required credentials
36+
if (!credentials?.resourceName) {
37+
return {
38+
error: new Error('Content Safety credentials must include resourceName'),
39+
verdict: true,
40+
data,
41+
};
42+
}
43+
44+
// prefer api key over auth mode
45+
if (!credentials?.azureAuthMode && !credentials?.apiKey) {
46+
return {
47+
error: new Error(
48+
'Content Safety credentials must include either apiKey or azureAuthMode'
49+
),
50+
verdict: true,
51+
data,
52+
};
53+
}
54+
55+
const text = getText(context, eventType);
56+
if (!text) {
57+
return {
58+
error: new Error('request or response text is empty'),
59+
verdict: true,
60+
data,
61+
};
62+
}
63+
64+
const apiVersion = parameters.apiVersion || '2024-11-01';
65+
66+
const url = `https://${credentials.resourceName}.cognitiveservices.azure.com/contentsafety/text:analyze?api-version=${apiVersion}`;
67+
68+
const { token, error: tokenError } = await getAccessToken(
69+
credentials as any,
70+
'contentSafety',
71+
options,
72+
options?.env
73+
);
74+
75+
if (tokenError) {
76+
return {
77+
error: tokenError,
78+
verdict: true,
79+
data,
80+
};
81+
}
82+
83+
const headers: Record<string, string> = {
84+
'Content-Type': 'application/json',
85+
'User-Agent': 'portkey-ai-plugin/',
86+
'Ocp-Apim-Subscription-Key': token,
87+
};
88+
89+
if (credentials?.azureAuthMode && credentials?.azureAuthMode !== 'apiKey') {
90+
headers['Authorization'] = `Bearer ${token}`;
91+
delete headers['Ocp-Apim-Subscription-Key'];
92+
}
93+
94+
const request = {
95+
text: text,
96+
categories: parameters.categories || defaultCategories,
97+
blocklistNames: parameters.blocklistNames || [],
98+
};
99+
100+
const timeout = parameters.timeout || 5000;
101+
let response;
102+
try {
103+
response = await post(url, request, { headers }, timeout);
104+
} catch (e) {
105+
return { error: e, verdict: true, data };
106+
}
107+
108+
if (response) {
109+
data = response;
110+
111+
// Check if any category exceeds the threshold (default: 2 - Medium)
112+
const hasHarmfulContent = response.categoriesAnalysis?.some(
113+
(category: any) => {
114+
return category.severity >= (parameters.severity || 2);
115+
}
116+
);
117+
118+
// Check if any blocklist items were hit
119+
const hasBlocklistHit = response.blocklistsMatch?.some((match: any) => {
120+
return match.matchResults.length > 0;
121+
});
122+
123+
verdict = !(hasHarmfulContent || hasBlocklistHit);
124+
}
125+
126+
return {
127+
error,
128+
verdict,
129+
data,
130+
};
131+
};

0 commit comments

Comments
 (0)