n8n
Version:
n8n Workflow Automation Tool
313 lines (303 loc) • 15.8 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.createLlmMockHandler = createLlmMockHandler;
exports.buildDateAnchors = buildDateAnchors;
const backend_common_1 = require("@n8n/backend-common");
const di_1 = require("@n8n/di");
const instance_ai_1 = require("@n8n/instance-ai");
const n8n_core_1 = require("n8n-core");
const zod_1 = require("zod");
const api_docs_1 = require("./api-docs");
const mock_quirks_1 = require("./mock-quirks");
const node_config_1 = require("./node-config");
const request_binary_redactor_1 = require("./request-binary-redactor");
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).
**Procedure — follow in order:**
1. Call \`get_endpoint_quirks\` first, always. It returns any known guidance specific to this endpoint, or confirms there are none. Treat its output as authoritative.
2. Generate the response that satisfies the API docs, node config, scenario context, and any quirk guidance from step 1.
3. Call \`submit_response\` exactly once to deliver the response. Do not write the response as text.
**Write operations (POST / PUT / PATCH that create or modify a resource):**
Return the FULL resource object the API produces on success — not a minimal acknowledgement-only response. When the docs show multiple response variants for one endpoint, default to the FULL/complete one. Use a partial/minimal variant only if the request body contains the explicit field that triggers it (e.g. \`template\`, \`async: true\`).
Each request is mocked independently. Even when the same node makes multiple similar calls in a workflow, every call must produce a fully-shaped response on its own — never shortcut later calls.
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)
**Time-relative fields.** The user prompt ends with a "## Date anchors" block listing today's date plus a handful of relative anchors (yesterday, 7 days ago, etc.). EVERY timestamp, date, hourly/daily entry, and time-relative field in your response MUST be derived from those anchors — never from training data or from the example dates in the API documentation. Workflows commonly filter mock responses by today's date; values outside the current window are silently discarded and the scenario fails.
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.
For APIs that return empty responses on success (204/202), call submit_response with type="json" and body={}.
**Binary / file responses.** Pick \`type: "binary"\` when the request URL or node parameters indicate a file download — Telegram \`getFile\` / \`/file/bot...\`, Google Drive \`alt=media\`, Dropbox \`/files/download\`, OneDrive \`/items/{id}/content\`, S3 \`GetObject\`, OpenAI \`audio/transcriptions\` source file, or any path containing \`/download\`, \`/file\`, \`/attachment\`, \`/media\`, \`/image\`, \`/voice\`, \`/audio\`, \`/export\`. Always set \`contentType\` (real MIME like \`application/pdf\`, \`audio/ogg\`, \`image/png\`) and \`filename\` (with the correct extension). Use \`sizeHint\` only when the scenario hints mention file size constraints (e.g. "rejects files > 100KB"). Do NOT pick \`binary\` for JSON metadata endpoints like Slack \`files.upload\`, \`files.info\`, or Telegram \`getFile\` (which returns a JSON envelope describing the file — the binary comes from the follow-up \`/file/bot.../path\` request).`;
const DEFAULT_MAX_RETRIES = 1;
const ERROR_PREVIEW_MAX = 400;
const ERROR_DETAIL_MAX = 300;
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 dateAnchors = buildDateAnchors(new Date());
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 binarySafe = (0, request_binary_redactor_1.redactBinaryBody)(request.body, getContentType(request.headers));
const sanitized = (0, request_sanitizer_1.redactSecretKeys)(binarySafe);
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.)');
}
}
sections.push('', '## Date anchors', dateAnchors);
const userPrompt = sections.join('\n');
const safeUrl = extractEndpoint(request.url);
let lastError = '';
const requestPath = extractEndpointPath(request.url);
const requestMethod = request.method ?? 'GET';
const requestHostname = extractHostname(request.url);
for (let attempt = 0; attempt <= context.maxRetries; attempt++) {
try {
const spec = await callLlm(userPrompt, {
serviceName,
method: requestMethod,
pathname: requestPath,
hostname: requestHostname,
});
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,
};
}
const submitResponseSchema = zod_1.z.object({
type: zod_1.z
.enum(['json', 'binary', 'error'])
.describe('"json" for normal JSON responses; "binary" for file downloads; "error" for non-2xx responses.'),
body: zod_1.z
.union([zod_1.z.record(zod_1.z.unknown()), zod_1.z.array(zod_1.z.unknown()), zod_1.z.null()])
.optional()
.describe('The decoded response body. Must be an object, an array (for list endpoints), or null (for empty 204-style responses). Required for type="json" and type="error". Omit for type="binary".'),
statusCode: zod_1.z
.number()
.int()
.optional()
.describe('HTTP status code. Required for type="error". Omit for json/binary.'),
contentType: zod_1.z
.string()
.optional()
.describe('MIME type. Required for type="binary". Omit otherwise.'),
filename: zod_1.z.string().optional().describe('Filename for type="binary". Omit otherwise.'),
sizeHint: zod_1.z
.enum(['small', 'medium', 'large'])
.optional()
.describe('Optional padding hint for type="binary". "small" (default) is the minimum valid fixture; "medium" pads to ~64KB; "large" pads to ~1MB. Use only when the scenario hints mention file size constraints.'),
});
function createSubmitResponseTool(capture) {
return new instance_ai_1.Tool('submit_response')
.description('Submit your final mock HTTP response. Call this exactly once.')
.input(submitResponseSchema)
.handler(async (input) => {
capture.spec = input;
return 'Response submitted.';
})
.build();
}
function createQuirksLookupTool(serviceName, method, pathname, hostname) {
return new instance_ai_1.Tool('get_endpoint_quirks')
.description('Returns guidance about known mocking quirks for the current request. Always call before submit_response.')
.input(zod_1.z.object({}))
.handler(async () => {
const guidance = (0, mock_quirks_1.findMockQuirks)(serviceName, method, pathname, hostname);
if (guidance.length === 0) {
return 'No specific quirks for this endpoint. Follow the API docs and the system rules.';
}
return guidance.join('\n\n');
})
.build();
}
async function callLlm(userPrompt, requestInfo) {
const capture = {};
const agent = (0, instance_ai_1.createEvalAgent)('eval-mock-responder', {
instructions: MOCK_SYSTEM_PROMPT,
})
.tool(createQuirksLookupTool(requestInfo.serviceName, requestInfo.method, requestInfo.pathname, requestInfo.hostname))
.tool(createSubmitResponseTool(capture));
const result = await agent.generate(userPrompt);
if (!capture.spec) {
const text = (0, instance_ai_1.extractText)(result);
const edge = ERROR_PREVIEW_MAX / 2;
const preview = text.length > ERROR_PREVIEW_MAX ? `${text.slice(0, edge)}…${text.slice(-edge)}` : text;
const errDetail = result.error
? result.error instanceof Error
? result.error.message
: JSON.stringify(result.error).slice(0, ERROR_DETAIL_MAX)
: '';
const errPart = errDetail ? ` error=${errDetail}` : '';
throw new Error(`Agent did not call submit_response. finishReason=${result.finishReason ?? 'unknown'}${errPart} text="${preview.replace(/\s+/g, ' ').trim()}"`);
}
return capture.spec;
}
function materializeSpec(spec) {
switch (spec.type) {
case 'json':
return {
body: spec.body === undefined ? { ok: true } : spec.body,
headers: { 'content-type': 'application/json' },
statusCode: 200,
};
case 'binary': {
const filename = spec.filename ?? 'mock-file.dat';
const contentType = spec.contentType ?? 'application/octet-stream';
const body = (0, n8n_core_1.synthesizeBinaryFixture)(contentType, filename, { sizeHint: spec.sizeHint });
return {
body,
headers: {
'content-type': contentType,
'content-disposition': `attachment; filename="${filename}"`,
'content-length': String(body.length),
},
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 getContentType(headers) {
if (!headers)
return undefined;
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() !== 'content-type')
continue;
if (typeof value === 'string')
return value;
if (Array.isArray(value) && typeof value[0] === 'string')
return value[0];
return undefined;
}
return undefined;
}
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;
}
}
function extractEndpointPath(url) {
try {
return new URL(url).pathname;
}
catch {
return url;
}
}
function extractHostname(url) {
try {
return new URL(url).hostname;
}
catch {
return undefined;
}
}
function buildDateAnchors(now) {
const labels = [
['today', 0],
['yesterday', -1],
['7 days ago', -7],
['14 days ago', -14],
['30 days ago', -30],
['1 day from now', 1],
['7 days from now', 7],
];
const lines = labels.map(([label, dayOffset]) => {
const d = new Date(now);
d.setUTCDate(d.getUTCDate() + dayOffset);
const isoDate = d.toISOString().slice(0, 10);
return label === 'today'
? `- ${label}: ${isoDate} (full timestamp ${now.toISOString()})`
: `- ${label}: ${isoDate}`;
});
return lines.join('\n');
}
//# sourceMappingURL=mock-handler.js.map