@kaia-team/n8n-nodes-kaia
Version:
n8n node to interact with Kaia API for signal capture and security gating
372 lines (371 loc) • 16.4 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ModelGate = void 0;
const n8n_workflow_1 = require("n8n-workflow");
const crypto_1 = require("crypto");
class ModelGate {
constructor() {
this.description = {
displayName: 'KAIA Model Gate',
name: 'modelGate',
icon: 'file:kaia.svg',
group: ['transform'],
version: 1,
subtitle: '={{$parameter["model"]}}',
description: 'Intercept LLM input and output to record all interactions for AI Act compliance',
defaults: {
name: 'KAIA Model Gate',
},
// eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node
inputs: [
{
displayName: 'Model',
type: n8n_workflow_1.NodeConnectionTypes.AiLanguageModel,
required: true,
maxConnections: 1,
},
],
outputs: [
{
displayName: 'Model',
type: n8n_workflow_1.NodeConnectionTypes.AiLanguageModel,
},
],
credentials: [
{
name: 'kaiaApi',
required: true,
},
],
properties: [
{
displayName: 'Connect a language model on the canvas to intercept its calls',
name: 'modelNotice',
type: 'notice',
default: '',
description: 'This node requires a connected LangChain model to intercept its input and output',
},
],
};
}
async supplyData(itemIndex) {
// Get credentials - required
const credentials = await this.getCredentials('kaiaApi');
const baseUrl = credentials?.baseUrl || '';
const token = credentials?.token || '';
if (!baseUrl || !token) {
throw new n8n_workflow_1.NodeOperationError(this.getNode(), 'Kaia API credentials are required');
}
// Get the connected model from the input connection
const model = await this.getInputConnectionData(n8n_workflow_1.NodeConnectionTypes.AiLanguageModel, itemIndex);
if (!model) {
throw new n8n_workflow_1.NodeOperationError(this.getNode(), 'No model connected. Please connect a language model to the Model input.');
}
// Get workflow context for signal metadata
const workflow = this.getWorkflow();
const currentNode = this.getNode();
const workflowId = workflow.id?.toString() || '';
const workflowName = workflow.name || '';
const executionId = this.getExecutionId() || '';
const nodeId = currentNode.id || '';
const nodeName = currentNode.name || '';
// Create a callback handler to intercept LLM calls
// Capture context variables in closure
const helpers = this.helpers;
const requestUrl = `${baseUrl}/api/signals/remember`;
const authHeader = `Bearer ${token}`;
// Create a simple callback handler object that LangChain can use
const callbackHandler = {
name: 'KaiaModelGate',
startTime: 0,
inputPrompt: '',
modelName: 'unknown',
provider: 'unknown',
async handleLLMStart(llm, prompts, runId) {
this.startTime = Date.now();
this.inputPrompt = JSON.stringify(prompts, null, 2);
// Extract model metadata
if (llm) {
this.modelName = llm.modelName || llm.model || llm.id || 'unknown';
this.provider = llm.constructor?.name || 'unknown';
}
// Send input signal asynchronously (non-blocking)
this.sendInputSignal(helpers, requestUrl, authHeader, workflowId, workflowName, executionId, nodeId, nodeName).catch(() => {
// Silently fail - don't block model execution
});
},
async handleLLMEnd(output, runId) {
const endTime = Date.now();
const latencyMs = endTime - this.startTime;
// Extract token information if available
const usage = output.llmOutput?.tokenUsage || output.usage || {};
const promptTokens = usage.promptTokens || 0;
const completionTokens = usage.completionTokens || 0;
const totalTokens = usage.totalTokens || (promptTokens + completionTokens);
// Extract response content
const responseContent = output.generations?.[0]?.[0]?.text ||
output.text ||
JSON.stringify(output, null, 2);
// Send output signal asynchronously (non-blocking)
this.sendOutputSignal(helpers, requestUrl, authHeader, workflowId, workflowName, executionId, nodeId, nodeName, responseContent, {
provider: this.provider,
model: this.modelName,
promptTokens,
completionTokens,
totalTokens,
latencyMs,
}).catch(() => {
// Silently fail - don't block model execution
});
},
async sendInputSignal(helpers, requestUrl, authHeader, workflowId, workflowName, executionId, nodeId, nodeName) {
const inputContentHash = (0, crypto_1.createHash)('sha256').update(this.inputPrompt).digest('hex');
const inputSignalBody = {
workflowId,
workflowName,
executionId,
nodeId,
nodeName,
signalType: 'llm_input',
content: this.inputPrompt,
contentHash: inputContentHash,
llmContext: {
provider: this.provider,
model: this.modelName,
},
priority: 'high',
context_level: 'raw',
};
try {
await helpers.httpRequest({
headers: {
'Content-Type': 'application/json',
Authorization: authHeader,
},
method: 'POST',
url: requestUrl,
body: inputSignalBody,
json: true,
});
}
catch {
// Non-blocking - errors are silently ignored
}
},
async sendOutputSignal(helpers, requestUrl, authHeader, workflowId, workflowName, executionId, nodeId, nodeName, outputContent, llmContext) {
const outputContentHash = (0, crypto_1.createHash)('sha256').update(outputContent).digest('hex');
const outputSignalBody = {
workflowId,
workflowName,
executionId,
nodeId,
nodeName,
signalType: 'llm_output',
content: outputContent,
contentHash: outputContentHash,
llmContext,
priority: 'high',
context_level: 'raw',
};
try {
await helpers.httpRequest({
headers: {
'Content-Type': 'application/json',
Authorization: authHeader,
},
method: 'POST',
url: requestUrl,
body: outputSignalBody,
json: true,
});
}
catch {
// Non-blocking - errors are silently ignored
}
},
};
// Intercept model calls WITHOUT wrapping the instance
// This preserves the exact original model instance so n8n's type checks pass
// We'll wrap the model's invoke/generate methods to intercept calls
const originalInvoke = model.invoke;
const originalGenerate = model.generate;
const originalCall = model.call;
// Wrap invoke method
if (typeof originalInvoke === 'function') {
model.invoke = async function (...args) {
const startTime = Date.now();
const prompts = args[0] || {};
// Extract model metadata
const modelName = this.modelName || this.model || this.id || 'unknown';
const provider = this.constructor?.name || 'unknown';
// Send input signal
const inputContent = JSON.stringify(prompts, null, 2);
const inputContentHash = (0, crypto_1.createHash)('sha256').update(inputContent).digest('hex');
const inputSignalBody = {
workflowId,
workflowName,
executionId,
nodeId,
nodeName,
signalType: 'llm_input',
content: inputContent,
contentHash: inputContentHash,
llmContext: {
provider,
model: modelName,
},
priority: 'high',
context_level: 'raw',
};
// Send input signal asynchronously (non-blocking)
helpers.httpRequest({
headers: {
'Content-Type': 'application/json',
Authorization: authHeader,
},
method: 'POST',
url: requestUrl,
body: inputSignalBody,
json: true,
}).catch(() => {
// Silently fail
});
// Call original method with callbacks
const callbacks = this.callbacks || [];
const allCallbacks = Array.isArray(callbacks) ? [...callbacks, callbackHandler] : [callbacks, callbackHandler];
const result = await originalInvoke.apply(this, args);
// Send output signal
const endTime = Date.now();
const latencyMs = endTime - startTime;
const outputContent = JSON.stringify(result, null, 2);
const outputContentHash = (0, crypto_1.createHash)('sha256').update(outputContent).digest('hex');
// Extract token usage if available
const usage = result.usage || result.llmOutput?.tokenUsage || {};
const promptTokens = usage.promptTokens || 0;
const completionTokens = usage.completionTokens || 0;
const totalTokens = usage.totalTokens || (promptTokens + completionTokens);
const outputSignalBody = {
workflowId,
workflowName,
executionId,
nodeId,
nodeName,
signalType: 'llm_output',
content: outputContent,
contentHash: outputContentHash,
llmContext: {
provider,
model: modelName,
promptTokens,
completionTokens,
totalTokens,
latencyMs,
},
priority: 'high',
context_level: 'raw',
};
// Send output signal asynchronously (non-blocking)
helpers.httpRequest({
headers: {
'Content-Type': 'application/json',
Authorization: authHeader,
},
method: 'POST',
url: requestUrl,
body: outputSignalBody,
json: true,
}).catch(() => {
// Silently fail
});
return result;
};
}
// Also wrap generate if it exists
if (typeof originalGenerate === 'function') {
model.generate = async function (...args) {
const startTime = Date.now();
const prompts = args[0] || [];
// Extract model metadata
const modelName = this.modelName || this.model || this.id || 'unknown';
const provider = this.constructor?.name || 'unknown';
// Send input signal
const inputContent = JSON.stringify(prompts, null, 2);
const inputContentHash = (0, crypto_1.createHash)('sha256').update(inputContent).digest('hex');
const inputSignalBody = {
workflowId,
workflowName,
executionId,
nodeId,
nodeName,
signalType: 'llm_input',
content: inputContent,
contentHash: inputContentHash,
llmContext: {
provider,
model: modelName,
},
priority: 'high',
context_level: 'raw',
};
helpers.httpRequest({
headers: {
'Content-Type': 'application/json',
Authorization: authHeader,
},
method: 'POST',
url: requestUrl,
body: inputSignalBody,
json: true,
}).catch(() => { });
const result = await originalGenerate.apply(this, args);
// Send output signal
const endTime = Date.now();
const latencyMs = endTime - startTime;
const outputContent = JSON.stringify(result, null, 2);
const outputContentHash = (0, crypto_1.createHash)('sha256').update(outputContent).digest('hex');
const usage = result.llmOutput?.tokenUsage || result.usage || {};
const promptTokens = usage.promptTokens || 0;
const completionTokens = usage.completionTokens || 0;
const totalTokens = usage.totalTokens || (promptTokens + completionTokens);
const outputSignalBody = {
workflowId,
workflowName,
executionId,
nodeId,
nodeName,
signalType: 'llm_output',
content: outputContent,
contentHash: outputContentHash,
llmContext: {
provider,
model: modelName,
promptTokens,
completionTokens,
totalTokens,
latencyMs,
},
priority: 'high',
context_level: 'raw',
};
helpers.httpRequest({
headers: {
'Content-Type': 'application/json',
Authorization: authHeader,
},
method: 'POST',
url: requestUrl,
body: outputSignalBody,
json: true,
}).catch(() => { });
return result;
};
}
// Return the ORIGINAL model instance - no wrapping, preserves all type checks
const wrappedModel = model;
// Return the wrapped model so it can be passed through to the AI Agent
return {
response: wrappedModel,
};
}
}
exports.ModelGate = ModelGate;