Files
simstudioai--sim/apps/sim/app/api/guardrails/validate/route.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

420 lines
12 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
import { type NextRequest, NextResponse } from 'next/server'
import { guardrailsValidateContract } from '@/lib/api/contracts'
import { parseRequest } from '@/lib/api/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { validateHallucination } from '@/lib/guardrails/validate_hallucination'
import { validateJson } from '@/lib/guardrails/validate_json'
import { validatePII } from '@/lib/guardrails/validate_pii'
import { validateRegex } from '@/lib/guardrails/validate_regex'
import {
assertPermissionsAllowed,
ModelNotAllowedError,
ProviderNotAllowedError,
} from '@/ee/access-control/utils/permission-check'
import { getProviderFromModel } from '@/providers/utils'
const logger = createLogger('GuardrailsValidateAPI')
export const POST = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
logger.info(`[${requestId}] Guardrails validation request received`)
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(guardrailsValidateContract, request, {})
if (!parsed.success) return parsed.response
const { body } = parsed.data
const {
validationType,
input,
regex,
knowledgeBaseId,
threshold,
topK,
model,
apiKey,
azureEndpoint,
azureApiVersion,
vertexProject,
vertexLocation,
vertexCredential,
bedrockAccessKeyId,
bedrockSecretKey,
bedrockRegion,
workflowId,
piiEntityTypes,
piiMode,
piiLanguage,
} = body
if (!validationType) {
return NextResponse.json({
success: true,
output: {
passed: false,
validationType: 'unknown',
input: input || '',
error: 'Missing required field: validationType',
},
})
}
if (input === undefined || input === null) {
return NextResponse.json({
success: true,
output: {
passed: false,
validationType,
input: '',
error: 'Input is missing or undefined',
},
})
}
if (
validationType !== 'json' &&
validationType !== 'regex' &&
validationType !== 'hallucination' &&
validationType !== 'pii'
) {
return NextResponse.json({
success: true,
output: {
passed: false,
validationType,
input: input || '',
error: 'Invalid validationType. Must be "json", "regex", "hallucination", or "pii"',
},
})
}
if (validationType === 'regex' && !regex) {
return NextResponse.json({
success: true,
output: {
passed: false,
validationType,
input: input || '',
error: 'Regex pattern is required for regex validation',
},
})
}
if (validationType === 'hallucination' && !model) {
return NextResponse.json({
success: true,
output: {
passed: false,
validationType,
input: input || '',
error: 'Model is required for hallucination validation',
},
})
}
let resolvedWorkspaceId: string | undefined
if (validationType === 'hallucination' && model) {
if (!workflowId || typeof workflowId !== 'string') {
return NextResponse.json({
success: true,
output: {
passed: false,
validationType,
input: input || '',
error:
'Workflow context is required for hallucination validation. Call this endpoint via a workflow execution, not directly.',
},
})
}
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId: auth.userId,
action: 'read',
})
if (!authorization.allowed || !authorization.workflow?.workspaceId) {
return NextResponse.json({
success: true,
output: {
passed: false,
validationType,
input: input || '',
error: authorization.message || 'Workflow not found or access denied.',
},
})
}
resolvedWorkspaceId = authorization.workflow.workspaceId
try {
await assertPermissionsAllowed({
userId: auth.userId,
workspaceId: resolvedWorkspaceId,
model,
})
} catch (err) {
if (err instanceof ProviderNotAllowedError || err instanceof ModelNotAllowedError) {
return NextResponse.json({
success: true,
output: {
passed: false,
validationType,
input: input || '',
error: err.message,
},
})
}
throw err
}
// Gate the actor's usage before incurring hosted LLM + RAG cost. In a normal
// workflow run this already passed at preprocessing; this also blocks direct
// calls to this route by an over-limit or frozen actor.
const usage = await checkActorUsageLimits(auth.userId, resolvedWorkspaceId)
if (usage.isExceeded) {
return NextResponse.json(
{ error: usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' },
{ status: 402 }
)
}
if (vertexCredential && getProviderFromModel(model) === 'vertex') {
const vertexCredAccess = await authorizeCredentialUse(request, {
credentialId: vertexCredential,
workflowId,
requireWorkflowIdForInternal: false,
})
if (!vertexCredAccess.ok) {
logger.warn(`[${requestId}] Vertex credential access denied`, {
error: vertexCredAccess.error,
credentialId: vertexCredential,
})
return NextResponse.json(
{ error: vertexCredAccess.error || 'Unauthorized' },
{ status: 401 }
)
}
}
}
const inputStr = convertInputToString(input)
logger.info(`[${requestId}] Executing validation locally`, {
validationType,
inputType: typeof input,
})
const authHeaders = {
cookie: request.headers.get('cookie') || undefined,
authorization: request.headers.get('authorization') || undefined,
}
const validationResult = await executeValidation(
validationType,
inputStr,
regex,
knowledgeBaseId,
threshold,
topK,
model,
apiKey,
{
azureEndpoint,
azureApiVersion,
vertexProject,
vertexLocation,
vertexCredential,
bedrockAccessKeyId,
bedrockSecretKey,
bedrockRegion,
},
workflowId,
resolvedWorkspaceId,
piiEntityTypes,
piiMode,
piiLanguage,
authHeaders,
requestId
)
// Bill the guardrail's LLM scoring cost (hallucination only; BYOK/non-hosted
// already resolve to 0). Attributed to the caller + the workflow's workspace
// so it lands in the per-member meter. Best-effort — never fail validation on
// a billing error.
if (
resolvedWorkspaceId &&
typeof validationResult.cost === 'number' &&
validationResult.cost > 0
) {
const { recordUsage } = await import('@/lib/billing/core/usage-log')
await recordUsage({
userId: auth.userId,
workspaceId: resolvedWorkspaceId,
entries: [
{
category: 'model',
source: 'workflow',
description: `guardrail-hallucination:${model ?? 'unknown'}`,
cost: validationResult.cost,
sourceReference: `guardrail:${workflowId ?? 'unknown'}:${requestId}`,
},
],
}).catch((billingError) => {
logger.error(`[${requestId}] Failed to record guardrail usage`, { error: billingError })
})
}
logger.info(`[${requestId}] Validation completed`, {
passed: validationResult.passed,
hasError: !!validationResult.error,
score: validationResult.score,
})
return NextResponse.json({
success: true,
output: {
passed: validationResult.passed,
validationType,
input,
error: validationResult.error,
score: validationResult.score,
reasoning: validationResult.reasoning,
detectedEntities: validationResult.detectedEntities,
maskedText: validationResult.maskedText,
},
})
} catch (error: any) {
logger.error(`[${requestId}] Guardrails validation failed`, { error })
return NextResponse.json({
success: true,
output: {
passed: false,
validationType: 'unknown',
input: '',
error: error.message || 'Validation failed due to unexpected error',
},
})
}
})
/**
* Convert input to string for validation
*/
function convertInputToString(input: any): string {
if (typeof input === 'string') {
return input
}
if (input === null || input === undefined) {
return ''
}
if (typeof input === 'object') {
return JSON.stringify(input)
}
return String(input)
}
/**
* Execute validation using TypeScript validators
*/
async function executeValidation(
validationType: string,
inputStr: string,
regex: string | undefined,
knowledgeBaseId: string | undefined,
threshold: string | undefined,
topK: string | undefined,
model: string | undefined,
apiKey: string | undefined,
providerCredentials: {
azureEndpoint?: string
azureApiVersion?: string
vertexProject?: string
vertexLocation?: string
vertexCredential?: string
bedrockAccessKeyId?: string
bedrockSecretKey?: string
bedrockRegion?: string
},
workflowId: string | undefined,
workspaceId: string | undefined,
piiEntityTypes: string[] | undefined,
piiMode: string | undefined,
piiLanguage: string | undefined,
authHeaders: { cookie?: string; authorization?: string } | undefined,
requestId: string
): Promise<{
passed: boolean
error?: string
score?: number
reasoning?: string
detectedEntities?: any[]
maskedText?: string
cost?: number
}> {
// Use TypeScript validators for all validation types
if (validationType === 'json') {
return validateJson(inputStr)
}
if (validationType === 'regex') {
if (!regex) {
return {
passed: false,
error: 'Regex pattern is required',
}
}
return validateRegex(inputStr, regex)
}
if (validationType === 'hallucination') {
if (!knowledgeBaseId) {
return {
passed: false,
error: 'Knowledge base ID is required for hallucination check',
}
}
if (!model) {
return {
passed: false,
error: 'Model is required for hallucination validation',
}
}
return await validateHallucination({
userInput: inputStr,
knowledgeBaseId,
threshold: threshold != null ? Number.parseFloat(threshold) : 3, // Default threshold is 3 (confidence score, scores < 3 fail)
topK: topK ? Number.parseInt(topK) : 10, // Default topK is 10
model: model,
apiKey,
providerCredentials,
workflowId,
workspaceId,
authHeaders,
requestId,
})
}
if (validationType === 'pii') {
return await validatePII({
text: inputStr,
entityTypes: piiEntityTypes || [], // Empty array = detect all PII types
mode: (piiMode as 'block' | 'mask') || 'block', // Default to block mode
language: piiLanguage || 'en',
requestId,
})
}
return {
passed: false,
error: 'Unknown validation type',
}
}