Skip to content

Commit 7ad421f

Browse files
feat: Add ai-node-sdk package (#25838)
Co-authored-by: Michael Kret <88898367+michael-radency@users.noreply.github.com>
1 parent 095a7f9 commit 7ad421f

32 files changed

Lines changed: 630 additions & 1228 deletions
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
dist
Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
1+
# @n8n/ai-node-sdk
2+
3+
Public SDK for building AI nodes in n8n. This package provides a simplified API for creating chat model and memory nodes without LangChain dependencies.
4+
5+
## Installation in node packages
6+
7+
Include the package in your node packages by updating `peerDependencies`:
8+
9+
```json
10+
{
11+
"peerDependencies": {
12+
"n8n-workflow": "*",
13+
"@n8n/ai-node-sdk": "*"
14+
}
15+
}
16+
```
17+
18+
## Development
19+
20+
```bash
21+
# Build the package
22+
pnpm build
23+
24+
# Run tests
25+
pnpm test
26+
27+
# Run in watch mode
28+
pnpm dev
29+
```
30+
31+
## Chat Model Nodes
32+
33+
Chat model nodes implement the `INodeType` interface and use `supplyModel` to provide model instances.
34+
35+
### Simple Pattern: OpenAI-Compatible Providers
36+
37+
For OpenAI-compatible providers, use the config object pattern with `supplyModel`:
38+
39+
```typescript
40+
import { supplyModel } from '@n8n/ai-node-sdk';
41+
import {
42+
type INodeType,
43+
type INodeTypeDescription,
44+
NodeConnectionTypes,
45+
type SupplyData,
46+
type ISupplyDataFunctions,
47+
} from 'n8n-workflow';
48+
49+
export class LmChatMyProvider implements INodeType {
50+
description: INodeTypeDescription = {
51+
displayName: 'MyProvider Chat Model',
52+
name: 'lmChatMyProvider',
53+
icon: 'fa:robot',
54+
group: ['transform'],
55+
version: [1],
56+
description: 'For advanced usage with an AI chain',
57+
defaults: {
58+
name: 'MyProvider Chat Model',
59+
},
60+
inputs: [],
61+
outputs: [NodeConnectionTypes.AiLanguageModel],
62+
credentials: [{ name: 'myProviderApi', required: true }],
63+
properties: [
64+
{
65+
displayName: 'Model',
66+
name: 'model',
67+
type: 'string',
68+
default: 'my-model',
69+
},
70+
{
71+
displayName: 'Temperature',
72+
name: 'temperature',
73+
type: 'number',
74+
default: 0.7,
75+
},
76+
],
77+
};
78+
79+
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
80+
const credentials = await this.getCredentials('myProviderApi');
81+
const model = this.getNodeParameter('model', itemIndex) as string;
82+
const temperature = this.getNodeParameter('temperature', itemIndex) as number;
83+
84+
// Return config for OpenAI-compatible providers
85+
return supplyModel(this, {
86+
type: 'openai',
87+
baseUrl: credentials.url as string,
88+
apiKey: credentials.apiKey as string,
89+
model,
90+
temperature,
91+
});
92+
}
93+
}
94+
```
95+
96+
### Advanced Pattern: Custom Model Class
97+
98+
For providers with custom APIs, extend `BaseChatModel` and pass an instance to `supplyModel`:
99+
100+
```typescript
101+
import {
102+
BaseChatModel,
103+
supplyModel,
104+
type Message,
105+
type GenerateResult,
106+
type StreamChunk,
107+
type ChatModelConfig,
108+
} from '@n8n/ai-node-sdk';
109+
import {
110+
type INodeType,
111+
type INodeTypeDescription,
112+
NodeConnectionTypes,
113+
type IHttpRequestMethods,
114+
type ISupplyDataFunctions,
115+
type SupplyData,
116+
} from 'n8n-workflow';
117+
import type Stream from 'node:stream';
118+
import { Readable } from 'node:stream';
119+
120+
// Custom model implementation
121+
class MyProviderChatModel extends BaseChatModel {
122+
constructor(
123+
modelId: string,
124+
private requests: {
125+
httpRequest: (
126+
method: IHttpRequestMethods,
127+
url: string,
128+
body?: object,
129+
headers?: Record<string, string>,
130+
) => Promise<{ body: unknown }>;
131+
openStream: (
132+
method: IHttpRequestMethods,
133+
url: string,
134+
body?: object,
135+
headers?: Record<string, string>,
136+
) => Promise<{ body: ReadableStream<Uint8Array> }>;
137+
},
138+
config?: ChatModelConfig,
139+
) {
140+
super('my-provider', modelId, config);
141+
}
142+
143+
async generate(messages: Message[], config?: ChatModelConfig): Promise<GenerateResult> {
144+
// Convert n8n messages to provider format
145+
const providerMessages = messages.map(m => ({
146+
role: m.role,
147+
content: m.content.find(c => c.type === 'text')?.text ?? '',
148+
}));
149+
150+
// Call the provider API
151+
const response = await this.requests.httpRequest('POST', '/chat', {
152+
model: this.modelId,
153+
messages: providerMessages,
154+
temperature: config?.temperature,
155+
});
156+
157+
const body = response.body as any;
158+
159+
return {
160+
finishReason: 'stop',
161+
message: {
162+
id: body.id,
163+
role: 'assistant',
164+
content: [{ type: 'text', text: body.content }],
165+
},
166+
usage: {
167+
promptTokens: body.usage.prompt_tokens,
168+
completionTokens: body.usage.completion_tokens,
169+
totalTokens: body.usage.total_tokens,
170+
},
171+
};
172+
}
173+
174+
async *stream(messages: Message[], config?: ChatModelConfig): AsyncIterable<StreamChunk> {
175+
// Implement streaming...
176+
yield { type: 'text-delta', delta: 'response text' };
177+
yield { type: 'finish', finishReason: 'stop' };
178+
}
179+
}
180+
181+
// Node definition
182+
export class LmChatMyProvider implements INodeType {
183+
description: INodeTypeDescription = {
184+
displayName: 'MyProvider Chat Model',
185+
name: 'lmChatMyProvider',
186+
icon: 'fa:robot',
187+
group: ['transform'],
188+
version: [1],
189+
description: 'For advanced usage with an AI chain',
190+
defaults: {
191+
name: 'MyProvider Chat Model',
192+
},
193+
inputs: [],
194+
outputs: [NodeConnectionTypes.AiLanguageModel],
195+
credentials: [{ name: 'myProviderApi', required: true }],
196+
properties: [
197+
{ displayName: 'Model', name: 'model', type: 'string', default: 'my-model' },
198+
{ displayName: 'Temperature', name: 'temperature', type: 'number', default: 0.7 },
199+
],
200+
};
201+
202+
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
203+
const model = this.getNodeParameter('model', itemIndex) as string;
204+
const temperature = this.getNodeParameter('temperature', itemIndex) as number;
205+
206+
const chatModel = new MyProviderChatModel(
207+
model,
208+
{
209+
httpRequest: async (method, url, body, headers) => {
210+
const response = await this.helpers.httpRequestWithAuthentication.call(
211+
this,
212+
'myProviderApi',
213+
{ method, url, body, headers },
214+
);
215+
return { body: response };
216+
},
217+
openStream: async (method, url, body, headers) => {
218+
const response = (await this.helpers.httpRequestWithAuthentication.call(
219+
this,
220+
'myProviderApi',
221+
{ method, url, body, headers, encoding: 'stream' },
222+
)) as Stream.Readable;
223+
return { body: Readable.toWeb(response) as ReadableStream<Uint8Array> };
224+
},
225+
},
226+
{ temperature },
227+
);
228+
229+
return supplyModel(this, chatModel);
230+
}
231+
}
232+
```
233+
234+
## Memory Nodes
235+
236+
Memory nodes implement the `INodeType` interface and use `supplyMemory` to provide memory instances.
237+
238+
### Pattern: Custom Storage with Windowed Memory
239+
240+
Extend `BaseChatHistory` to implement storage, then wrap it with `WindowedChatMemory` and pass to `supplyMemory`:
241+
242+
```typescript
243+
import {
244+
BaseChatHistory,
245+
WindowedChatMemory,
246+
supplyMemory,
247+
type Message,
248+
} from '@n8n/ai-node-sdk';
249+
import {
250+
type INodeType,
251+
type INodeTypeDescription,
252+
NodeConnectionTypes,
253+
type ISupplyDataFunctions,
254+
type SupplyData,
255+
} from 'n8n-workflow';
256+
257+
// Custom storage implementation
258+
class MyDbChatHistory extends BaseChatHistory {
259+
constructor(
260+
private sessionId: string,
261+
private apiKey: string,
262+
private httpRequest: any,
263+
) {
264+
super();
265+
}
266+
267+
async getMessages(): Promise<Message[]> {
268+
const data = await this.httpRequest({
269+
method: 'GET',
270+
url: `/sessions/${this.sessionId}/messages`,
271+
headers: { Authorization: `Bearer ${this.apiKey}` },
272+
json: true,
273+
});
274+
275+
return data.messages.map((m: any) => ({
276+
role: m.role,
277+
content: [{ type: 'text', text: m.content }],
278+
}));
279+
}
280+
281+
async addMessage(message: Message): Promise<void> {
282+
const text = message.content.find(c => c.type === 'text')?.text ?? '';
283+
await this.httpRequest({
284+
method: 'POST',
285+
url: `/sessions/${this.sessionId}/messages`,
286+
headers: { Authorization: `Bearer ${this.apiKey}` },
287+
body: { role: message.role, content: text },
288+
json: true,
289+
});
290+
}
291+
292+
async clear(): Promise<void> {
293+
await this.httpRequest({
294+
method: 'DELETE',
295+
url: `/sessions/${this.sessionId}`,
296+
headers: { Authorization: `Bearer ${this.apiKey}` },
297+
});
298+
}
299+
}
300+
301+
// Memory node
302+
export class MemoryMyDb implements INodeType {
303+
description: INodeTypeDescription = {
304+
displayName: 'MyDB Memory',
305+
name: 'memoryMyDb',
306+
icon: 'fa:database',
307+
group: ['transform'],
308+
version: [1],
309+
description: 'Store conversation history in MyDB',
310+
defaults: {
311+
name: 'MyDB Memory',
312+
},
313+
inputs: [],
314+
outputs: [NodeConnectionTypes.AiMemory],
315+
credentials: [{ name: 'myDbApi', required: true }],
316+
properties: [
317+
{
318+
displayName: 'Session ID',
319+
name: 'sessionId',
320+
type: 'string',
321+
default: '={{ $json.sessionId }}',
322+
},
323+
{
324+
displayName: 'Window Size',
325+
name: 'windowSize',
326+
type: 'number',
327+
default: 10,
328+
description: 'Number of recent message pairs to keep',
329+
},
330+
],
331+
};
332+
333+
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
334+
const credentials = await this.getCredentials('myDbApi');
335+
const sessionId = this.getNodeParameter('sessionId', itemIndex) as string;
336+
const windowSize = this.getNodeParameter('windowSize', itemIndex) as number;
337+
338+
const history = new MyDbChatHistory(
339+
sessionId,
340+
credentials.apiKey as string,
341+
this.helpers.httpRequest,
342+
);
343+
344+
const memory = new WindowedChatMemory(history, { windowSize });
345+
346+
return supplyMemory(this, memory);
347+
}
348+
}
349+
```
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { defineConfig, globalIgnores } from 'eslint/config';
2+
import { nodeConfig } from '@n8n/eslint-config/node';
3+
4+
export default defineConfig(nodeConfig, globalIgnores(['dist/**']), {
5+
rules: {
6+
'@typescript-eslint/no-explicit-any': 'warn',
7+
'@typescript-eslint/no-unsafe-assignment': 'warn',
8+
'@typescript-eslint/no-unsafe-call': 'warn',
9+
'@typescript-eslint/no-unsafe-member-access': 'warn',
10+
'@typescript-eslint/no-unsafe-return': 'warn',
11+
'@typescript-eslint/naming-convention': 'warn',
12+
},
13+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/** @type {import('jest').Config} */
2+
module.exports = {
3+
...require('../../../jest.config'),
4+
collectCoverageFrom: ['src/**/*.ts'],
5+
coveragePathIgnorePatterns: ['src/index.ts'],
6+
};

0 commit comments

Comments
 (0)