-
Notifications
You must be signed in to change notification settings - Fork 1.1k
[New Plugin] Lakera Guard #1637
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
teddyamkie-lakera
wants to merge
2
commits into
Portkey-AI:main
Choose a base branch
from
teddyamkie-lakera:plugin/lakera-guard
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+620
−0
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,232 @@ | ||
| import { | ||
| HookEventType, | ||
| PluginContext, | ||
| PluginHandler, | ||
| PluginParameters, | ||
| } from '../types'; | ||
| import { HttpError, post } from '../utils'; | ||
| import { | ||
| applyMasksToMessages, | ||
| isOnlyPiiViolation, | ||
| type PayloadItem, | ||
| } from './redaction'; | ||
|
|
||
| function normalizeMessages(messages: any[]): any[] { | ||
| if (!messages?.length) return []; | ||
| return messages.map((message: any) => { | ||
| if (typeof message.content === 'string') return message; | ||
| if (Array.isArray(message.content)) { | ||
| const text = message.content.reduce( | ||
| (acc: string, item: any) => | ||
| acc + (item?.type === 'text' ? `${item.text}\n` : ''), | ||
| '' | ||
| ); | ||
| return { ...message, content: text }; | ||
| } | ||
| return message; | ||
| }); | ||
| } | ||
|
|
||
| function extractMessages( | ||
| context: PluginContext, | ||
| eventType: HookEventType | ||
| ): any[] { | ||
| const reqJson = context.request?.json || {}; | ||
| let messages = reqJson.messages; | ||
| if (messages && Array.isArray(messages)) { | ||
| const base = JSON.parse(JSON.stringify(messages)); | ||
| const normalized = normalizeMessages(base); | ||
| if (eventType === 'afterRequestHook') { | ||
| const rjson = context.response?.json || {}; | ||
| const choices = rjson.choices || []; | ||
| const ch0 = choices[0]; | ||
| if (ch0?.message && ch0.message.content != null) { | ||
| normalized.push({ | ||
| role: ch0.message.role || 'assistant', | ||
| content: ch0.message.content, | ||
| }); | ||
| } | ||
| } | ||
| return normalized; | ||
| } | ||
| const text = context.request?.text; | ||
| if (typeof text === 'string' && text.trim()) { | ||
| const msgs: any[] = [{ role: 'user', content: text }]; | ||
| if (eventType === 'afterRequestHook') { | ||
| const respText = context.response?.text; | ||
| if (typeof respText === 'string' && respText.trim()) { | ||
| msgs.push({ role: 'assistant', content: respText }); | ||
| } | ||
| } | ||
| return msgs; | ||
| } | ||
| return []; | ||
| } | ||
|
|
||
| function portkeyMetadataToLakera( | ||
| meta: Record<string, unknown> | undefined | ||
| ): Record<string, unknown> | undefined { | ||
| if (!meta || typeof meta !== 'object') return undefined; | ||
| const out: Record<string, unknown> = {}; | ||
| const u = meta._user ?? meta.user_id; | ||
| if (u != null) out.user_id = String(u); | ||
| if (meta.session_id != null) out.session_id = String(meta.session_id); | ||
| if (meta.ip_address != null) out.ip_address = String(meta.ip_address); | ||
| return Object.keys(out).length ? out : undefined; | ||
| } | ||
|
|
||
| export const handler: PluginHandler = async ( | ||
| context: PluginContext, | ||
| parameters: PluginParameters, | ||
| eventType: HookEventType | ||
| ) => { | ||
| let error: any = null; | ||
| let verdict = false; | ||
| let data: any = null; | ||
| let transformed = false; | ||
| const transformedData: Record<string, any> = { | ||
| request: { json: null, text: null }, | ||
| response: { json: null, text: null }, | ||
| }; | ||
|
|
||
| try { | ||
| const apiKey = parameters.credentials?.apiKey as string | undefined; | ||
| if (!apiKey) { | ||
| throw new Error( | ||
| 'Missing Lakera apiKey: set credentials.apiKey in the guardrail config' | ||
| ); | ||
| } | ||
| const projectID = parameters.projectID; | ||
|
|
||
| const messages = extractMessages(context, eventType); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. you can use the getText() method from |
||
| if (!messages.length) { | ||
| return { | ||
| error: null, | ||
| verdict: true, | ||
| data: { explanation: 'no messages to screen' }, | ||
| }; | ||
| } | ||
|
|
||
| const apiBase = String( | ||
| (parameters.credentials?.apiBase as string | undefined) ?? | ||
| 'https://api.lakera.ai' | ||
| ).replace(/\/$/, ''); | ||
| const url = `${apiBase}/v2/guard`; | ||
| const body: Record<string, unknown> = { | ||
| messages, | ||
| payload: true, | ||
| breakdown: true, | ||
| }; | ||
| if (projectID) { | ||
| body.project_id = projectID; | ||
| } | ||
|
Comment on lines
+78
to
+122
|
||
| const lm = portkeyMetadataToLakera( | ||
| context.metadata as Record<string, unknown> | ||
| ); | ||
| if (lm) body.metadata = lm; | ||
|
|
||
| const headers = { | ||
| Authorization: `Bearer ${apiKey}`, | ||
| 'Content-Type': 'application/json', | ||
| }; | ||
|
|
||
| const lakeraResp: any = await post( | ||
| url, | ||
| body, | ||
| { headers }, | ||
| parameters.timeout || 30000 | ||
| ); | ||
|
|
||
| const flagged = Boolean(lakeraResp.flagged); | ||
| const breakdown = lakeraResp.breakdown || []; | ||
| const payload = (lakeraResp.payload || []) as PayloadItem[]; | ||
|
|
||
| const safeLog = { ...lakeraResp }; | ||
| // Strip raw spans (contain PII text positions) and internal Lakera IDs | ||
| // (detector_id, policy_id, project_id) from caller-visible data. | ||
| delete safeLog.payload; | ||
| delete safeLog.breakdown; | ||
| data = { | ||
| lakera: { | ||
| ...safeLog, | ||
| detectedTypes: breakdown | ||
| .filter((b: any) => b.detected) | ||
| .map((b: any) => b.detector_type), | ||
| }, | ||
| }; | ||
|
|
||
| if (!flagged) { | ||
| verdict = true; | ||
| return { error, verdict, data }; | ||
| } | ||
|
|
||
| const endInclusive = Boolean(parameters.endInclusive); | ||
|
|
||
| if (isOnlyPiiViolation(breakdown) && payload.length > 0) { | ||
| const { messages: maskedMsgs, warnings } = applyMasksToMessages( | ||
| messages, | ||
| payload, | ||
| endInclusive | ||
| ); | ||
|
|
||
| if (warnings.some((w) => w.includes('multimodal'))) { | ||
| verdict = false; | ||
| return { | ||
| error, | ||
| verdict, | ||
| data: { | ||
| ...data, | ||
| warnings, | ||
| explanation: | ||
| 'multimodal content cannot be masked in this plugin build', | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| if (eventType === 'beforeRequestHook') { | ||
| const reqJson = context.request?.json | ||
| ? JSON.parse(JSON.stringify(context.request.json)) | ||
| : {}; | ||
| reqJson.messages = maskedMsgs; | ||
| transformedData.request.json = reqJson; | ||
| transformed = true; | ||
| } else { | ||
| const respJson = context.response?.json | ||
| ? JSON.parse(JSON.stringify(context.response.json)) | ||
| : {}; | ||
| const choices = respJson.choices || []; | ||
| if (choices[0]?.message && maskedMsgs.length > 0) { | ||
| const last = maskedMsgs[maskedMsgs.length - 1]; | ||
| if (last && last.role === 'assistant') { | ||
| choices[0].message = choices[0].message || {}; | ||
| choices[0].message.content = last.content; | ||
| respJson.choices = choices; | ||
| } | ||
| } | ||
| transformedData.response.json = respJson; | ||
| transformed = true; | ||
| } | ||
|
Comment on lines
+186
to
+208
|
||
|
|
||
| verdict = true; | ||
| return { | ||
| error, | ||
| verdict, | ||
| data: { ...data, warnings }, | ||
| transformedData, | ||
| transformed, | ||
| }; | ||
| } | ||
|
|
||
| verdict = false; | ||
| return { error, verdict, data }; | ||
| } catch (e: any) { | ||
| // Strip stack trace to avoid leaking internal file paths to callers. | ||
| delete e?.stack; | ||
| error = e; | ||
| verdict = false; | ||
| if (e instanceof HttpError) { | ||
| data = { httpStatus: e.response?.status, body: e.response?.body }; | ||
| } | ||
| return { error, verdict, data }; | ||
| } | ||
| }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| { | ||
| "id": "lakera", | ||
| "description": "Lakera Guard — screen prompts and responses via POST /v2/guard. Supports blocking and PII redaction (payload) when only pii/* detectors fire.", | ||
| "credentials": { | ||
| "type": "object", | ||
| "properties": { | ||
| "apiKey": { | ||
| "type": "string", | ||
| "label": "Lakera API key", | ||
| "description": "Create at platform.lakera.ai (Guard API key)", | ||
| "encrypted": true | ||
| }, | ||
| "apiBase": { | ||
| "type": "string", | ||
| "label": "API base URL (optional)", | ||
| "description": "Default https://api.lakera.ai — use a regional host if required" | ||
| } | ||
| }, | ||
| "required": ["apiKey"] | ||
| }, | ||
| "functions": [ | ||
| { | ||
| "name": "Guard — screen content", | ||
| "id": "guard", | ||
| "supportedHooks": ["beforeRequestHook", "afterRequestHook"], | ||
| "type": "guardrail", | ||
|
Comment on lines
+21
to
+26
|
||
| "description": [ | ||
| { | ||
| "type": "subHeading", | ||
| "text": "Calls Lakera Guard /v2/guard with payload+breakdown. Blocks on policy hits; redacts PII spans when breakdown shows only pii/* detectors." | ||
| } | ||
| ], | ||
| "parameters": { | ||
| "type": "object", | ||
| "properties": { | ||
| "projectID": { | ||
| "type": "string", | ||
| "label": "Lakera project ID", | ||
| "description": [ | ||
| { | ||
| "type": "subHeading", | ||
| "text": "Project whose policy defines detectors (recommended)" | ||
| } | ||
| ] | ||
| }, | ||
| "endInclusive": { | ||
| "type": "boolean", | ||
| "label": "Payload end offset is inclusive", | ||
| "description": [ | ||
| { | ||
| "type": "subHeading", | ||
| "text": "Leave false unless your Lakera tier emits inclusive end indices" | ||
| } | ||
| ] | ||
| }, | ||
| "timeout": { | ||
| "type": "number", | ||
| "label": "HTTP timeout (ms)", | ||
| "description": [{ "type": "subHeading", "text": "Default 30000" }] | ||
| } | ||
| }, | ||
| "required": [] | ||
| } | ||
| } | ||
| ] | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.