n8n
Version:
n8n Workflow Automation Tool
232 lines (224 loc) • 9.64 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.createLlmMockHandler = createLlmMockHandler;
const backend_common_1 = require("@n8n/backend-common");
const di_1 = require("@n8n/di");
const n8n_workflow_1 = require("n8n-workflow");
const instance_ai_1 = require("@n8n/instance-ai");
const api_docs_1 = require("./api-docs");
const node_config_1 = require("./node-config");
const request_sanitizer_1 = require("./request-sanitizer");
const MOCK_SYSTEM_PROMPT = `You generate realistic HTTP responses for one specific request, mocking an API in n8n workflow evaluation.
You get everything you need in the user message: the request (service, method, URL, body, query), API docs for the endpoint, the n8n node's parameters, and optional context (globalContext, nodeHint, scenarioHints). Generate the response directly — do NOT call any tools.
Response SHAPE comes from the API docs; DATA VALUES come from the node config. Use names/IDs from the config exactly (case-sensitive).
Node-config patterns to know:
- "__rl" object: "value" is the selected resource id
- "schema" array: each entry's "id" is the response field name (NOT "displayName"). e.g. {id:"timestamp",displayName:"Timestamp"} → response uses "timestamp"
- Strings starting with "=" are expressions (ignore)
Match THIS request only (URL + method): a node may make multiple sequential calls; reply to the specific one shown. Echo identifiers, placeholders, and reference values from the request back into the response. No pagination — always indicate end of results.
## Output format
Return ONLY a JSON object, no prose, no markdown:
{ "type": "json", "body": { ... } }
{ "type": "binary", "contentType": "application/pdf", "filename": "doc.pdf" }
{ "type": "error", "statusCode": 404, "body": { ... } }
For APIs that return empty responses on success (204/202), use { "type": "json", "body": {} }.`;
const DEFAULT_MAX_RETRIES = 1;
function createLlmMockHandler(options) {
const nodeConfigCache = new Map();
return async (requestOptions, node) => {
if (!nodeConfigCache.has(node.name)) {
nodeConfigCache.set(node.name, (0, node_config_1.extractNodeConfig)(node));
}
return await generateMockResponse(requestOptions, node, {
scenarioHints: options?.scenarioHints,
globalContext: options?.globalContext,
nodeHint: options?.nodeHints?.[node.name],
nodeConfig: nodeConfigCache.get(node.name) ?? '',
maxRetries: options?.maxRetries ?? DEFAULT_MAX_RETRIES,
});
};
}
async function generateMockResponse(request, node, context) {
const serviceName = extractServiceName(request.url);
const endpoint = extractEndpoint(request.url);
const sections = [
'## Request',
`Service: ${serviceName}`,
`Node: "${node.name}" (${node.type})`,
(request.method ?? 'GET') + ' ' + endpoint,
'Generate the response for this EXACT endpoint and method.',
];
if (request.body) {
const sanitized = (0, request_sanitizer_1.redactSecretKeys)(request.body);
const serialized = (0, request_sanitizer_1.truncateForLlm)(JSON.stringify(sanitized));
sections.push(`Body: ${serialized}`);
}
if (request.qs && Object.keys(request.qs).length > 0) {
const sanitizedQs = (0, request_sanitizer_1.redactSecretKeys)(request.qs);
sections.push(`Query: ${JSON.stringify(sanitizedQs)}`);
}
const isGraphQL = endpoint.includes('/graphql') ||
(typeof request.body === 'object' && request.body !== null && 'query' in request.body);
if (isGraphQL) {
sections.push('', '## GraphQL format requirement');
sections.push('This is a GraphQL endpoint. ALL responses MUST use GraphQL response format:', '- Success: { "data": { ...fields matching the query... } }', '- Error: { "errors": [{ "message": "...", "extensions": { "code": "..." } }], "data": null }', 'Never return flat REST-style error objects.');
}
const apiDocs = await (0, api_docs_1.fetchApiDocs)(serviceName, `${request.method ?? 'GET'} ${endpoint} response format`);
sections.push('', '## API documentation', apiDocs);
if (context.nodeConfig) {
sections.push('', '## Node Configuration', context.nodeConfig);
}
if (context.globalContext || context.nodeHint || context.scenarioHints) {
sections.push('', '## Context');
if (context.globalContext)
sections.push(`Data: ${context.globalContext}`);
if (context.nodeHint)
sections.push(`Hint: ${context.nodeHint}`);
if (context.scenarioHints) {
sections.push(`Scenario: ${context.scenarioHints}`);
sections.push(isGraphQL
? '(For error scenarios, use GraphQL error format with "data": null. Don\'t use "type": "error" wrapper.)'
: '(Use "error" type with appropriate statusCode for error scenarios.)');
}
}
const userPrompt = sections.join('\n');
const safeUrl = extractEndpoint(request.url);
let lastError = '';
for (let attempt = 0; attempt <= context.maxRetries; attempt++) {
try {
const spec = await callLlm(userPrompt);
return materializeSpec(spec);
}
catch (error) {
lastError = error instanceof Error ? error.message : String(error);
if (attempt < context.maxRetries) {
di_1.Container.get(backend_common_1.Logger).warn(`[EvalMock] Mock generation failed for ${request.method ?? 'GET'} ${safeUrl}, retrying (${attempt + 1}/${context.maxRetries}): ${lastError}`);
}
}
}
di_1.Container.get(backend_common_1.Logger).error(`[EvalMock] Mock generation failed for ${request.method ?? 'GET'} ${safeUrl} after ${context.maxRetries + 1} attempts: ${lastError}`);
return {
body: { _evalMockError: true, message: `Mock generation failed: ${lastError}` },
headers: { 'content-type': 'application/json' },
statusCode: 200,
};
}
async function callLlm(userPrompt) {
const agent = (0, instance_ai_1.createEvalAgent)('eval-mock-responder', {
instructions: MOCK_SYSTEM_PROMPT,
});
const result = await agent.generate(userPrompt);
const text = (0, instance_ai_1.extractText)(result);
return parseResponseText(text);
}
function parseResponseText(raw) {
let text = raw.trim();
const fencedMatch = text.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/i);
if (fencedMatch) {
text = fencedMatch[1].trim();
}
text = text
.replace(/^```(?:json)?\s*\n?/i, '')
.replace(/\n?\s*```\s*$/i, '')
.trim();
if (text.length > 0 && !text.startsWith('{') && !text.startsWith('[')) {
const extracted = extractJsonObject(text);
if (extracted) {
text = extracted;
}
}
const parsed = (0, n8n_workflow_1.jsonParse)(text);
if (!parsed.type || !['json', 'binary', 'error'].includes(parsed.type)) {
return { type: 'json', body: parsed };
}
return parsed;
}
function materializeSpec(spec) {
switch (spec.type) {
case 'json':
return {
body: spec.body ?? { ok: true },
headers: { 'content-type': 'application/json' },
statusCode: 200,
};
case 'binary': {
const filename = spec.filename ?? 'mock-file.dat';
const contentType = spec.contentType ?? 'application/octet-stream';
const content = `[eval-mock] Synthetic file: ${filename} (${contentType})`;
return {
body: Buffer.from(content),
headers: { 'content-type': contentType },
statusCode: 200,
};
}
case 'error':
return {
body: spec.body ?? { error: 'Mock error' },
headers: { 'content-type': 'application/json' },
statusCode: spec.statusCode ?? 500,
};
default:
return {
body: spec.body ?? { ok: true },
headers: { 'content-type': 'application/json' },
statusCode: 200,
};
}
}
function extractJsonObject(text) {
const start = text.indexOf('{');
if (start < 0)
return undefined;
let depth = 0;
let inString = false;
let escape = false;
for (let i = start; i < text.length; i++) {
const ch = text[i];
if (escape) {
escape = false;
continue;
}
if (ch === '\\' && inString) {
escape = true;
continue;
}
if (ch === '"') {
inString = !inString;
continue;
}
if (inString)
continue;
if (ch === '{')
depth++;
if (ch === '}') {
depth--;
if (depth === 0) {
return text.slice(start, i + 1);
}
}
}
return text.slice(start);
}
function extractServiceName(url) {
try {
const hostname = new URL(url).hostname;
const parts = hostname
.replace(/^api\./, '')
.replace(/^www\./, '')
.split('.');
return parts[0].charAt(0).toUpperCase() + parts[0].slice(1);
}
catch {
return 'Unknown';
}
}
function extractEndpoint(url) {
try {
const parsed = new URL(url);
return parsed.pathname + (parsed.search ? parsed.search : '');
}
catch {
return url;
}
}
//# sourceMappingURL=mock-handler.js.map