Skip to content

Commit a3e2b51

Browse files
authored
feat(agent): addAi customization with OpenAI support (#1420)
1 parent 5d33068 commit a3e2b51

21 files changed

Lines changed: 2065 additions & 619 deletions

packages/_example/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"private": true,
66
"dependencies": {
77
"@faker-js/faker": "^7.6.0",
8-
"@forestadmin/agent": "1.70.9",
8+
"@forestadmin/agent": "1.70.10",
99
"@forestadmin/datasource-dummy": "1.1.60",
1010
"@forestadmin/datasource-mongo": "1.6.1",
1111
"@forestadmin/datasource-mongoose": "1.12.5",

packages/agent/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
},
1414
"dependencies": {
1515
"@fast-csv/format": "^4.3.5",
16+
"@forestadmin/ai-proxy": "1.0.1",
1617
"@forestadmin/datasource-customizer": "1.67.2",
1718
"@forestadmin/datasource-toolkit": "1.50.1",
1819
"@forestadmin/forestadmin-client": "1.37.4",

packages/agent/src/agent.ts

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
22
import type { ForestAdminHttpDriverServices } from './services';
3-
import type { AgentOptions, AgentOptionsWithDefaults, HttpCallback } from './types';
3+
import type {
4+
AgentOptions,
5+
AgentOptionsWithDefaults,
6+
AiConfiguration,
7+
HttpCallback,
8+
} from './types';
49
import type {
510
CollectionCustomizer,
611
DataSourceChartDefinition,
@@ -12,6 +17,7 @@ import type {
1217
import type { DataSource, DataSourceFactory } from '@forestadmin/datasource-toolkit';
1318
import type { ForestSchema } from '@forestadmin/forestadmin-client';
1419

20+
import { isModelSupportingTools } from '@forestadmin/ai-proxy';
1521
import { DataSourceCustomizer } from '@forestadmin/datasource-customizer';
1622
import bodyParser from '@koa/bodyparser';
1723
import cors from '@koa/cors';
@@ -42,6 +48,7 @@ export default class Agent<S extends TSchema = TSchema> extends FrameworkMounter
4248
protected nocodeCustomizer: DataSourceCustomizer<S>;
4349
protected customizationService: CustomizationService;
4450
protected schemaGenerator: SchemaGenerator;
51+
protected aiConfigurations: AiConfiguration[] = [];
4552

4653
/** Whether MCP server should be mounted */
4754
private mcpEnabled = false;
@@ -210,8 +217,55 @@ export default class Agent<S extends TSchema = TSchema> extends FrameworkMounter
210217
return this;
211218
}
212219

220+
/**
221+
* Enable AI features for your Forest Admin panel.
222+
*
223+
* All AI requests from Forest Admin are forwarded to your agent and processed locally.
224+
* Your data and API keys never transit through Forest Admin servers, ensuring full privacy.
225+
*
226+
* @param configuration - The AI provider configuration
227+
* @param configuration.name - A unique name to identify this AI configuration
228+
* @param configuration.provider - The AI provider to use ('openai')
229+
* @param configuration.apiKey - Your API key for the chosen provider
230+
* @param configuration.model - The model to use (e.g., 'gpt-4o')
231+
* @returns The agent instance for chaining
232+
* @throws Error if addAi is called more than once
233+
*
234+
* @example
235+
* agent.addAi({
236+
* name: 'assistant',
237+
* provider: 'openai',
238+
* apiKey: process.env.OPENAI_API_KEY,
239+
* model: 'gpt-4o',
240+
* });
241+
*/
242+
addAi(configuration: AiConfiguration): this {
243+
if (this.aiConfigurations.length > 0) {
244+
throw new Error(
245+
'addAi can only be called once. Multiple AI configurations are not supported yet.',
246+
);
247+
}
248+
249+
if (!isModelSupportingTools(configuration.model)) {
250+
throw new Error(
251+
`Model '${configuration.model}' does not support function calling (tools). ` +
252+
'Please use a compatible model like gpt-4o, gpt-4o-mini, or gpt-4-turbo.',
253+
);
254+
}
255+
256+
this.options.logger(
257+
'Warn',
258+
`AI configuration added with model '${configuration.model}'. ` +
259+
'Make sure to test Forest Admin AI features thoroughly to ensure compatibility.',
260+
);
261+
262+
this.aiConfigurations.push(configuration);
263+
264+
return this;
265+
}
266+
213267
protected getRoutes(dataSource: DataSource, services: ForestAdminHttpDriverServices) {
214-
return makeRoutes(dataSource, this.options, services);
268+
return makeRoutes(dataSource, this.options, services, this.aiConfigurations);
215269
}
216270

217271
/**
@@ -333,7 +387,11 @@ export default class Agent<S extends TSchema = TSchema> extends FrameworkMounter
333387
// Either load the schema from the file system or build it
334388
let schema: Pick<ForestSchema, 'collections'>;
335389

336-
const { meta } = SchemaGenerator.buildMetadata(this.customizationService.buildFeatures());
390+
// Get the AI configurations for schema metadata
391+
const { meta } = SchemaGenerator.buildMetadata(
392+
this.customizationService.buildFeatures(),
393+
this.aiConfigurations,
394+
);
337395

338396
// When using experimental no-code features even in production we need to build a new schema
339397
if (!experimental?.webhookCustomActions && isProduction) {
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import type { ForestAdminHttpDriverServices } from '../../services';
2+
import type { AgentOptionsWithDefaults, AiConfiguration } from '../../types';
3+
import type KoaRouter from '@koa/router';
4+
import type { Context } from 'koa';
5+
6+
import {
7+
AIBadRequestError,
8+
AIError,
9+
AINotFoundError,
10+
Router as AiProxyRouter,
11+
} from '@forestadmin/ai-proxy';
12+
import {
13+
BadRequestError,
14+
NotFoundError,
15+
UnprocessableError,
16+
} from '@forestadmin/datasource-toolkit';
17+
18+
import { HttpCode, RouteType } from '../../types';
19+
import BaseRoute from '../base-route';
20+
21+
export default class AiProxyRoute extends BaseRoute {
22+
readonly type = RouteType.PrivateRoute;
23+
private readonly aiProxyRouter: AiProxyRouter;
24+
25+
constructor(
26+
services: ForestAdminHttpDriverServices,
27+
options: AgentOptionsWithDefaults,
28+
aiConfigurations: AiConfiguration[],
29+
) {
30+
super(services, options);
31+
this.aiProxyRouter = new AiProxyRouter({
32+
aiConfigurations,
33+
logger: this.options.logger,
34+
});
35+
}
36+
37+
setupRoutes(router: KoaRouter): void {
38+
router.post('/_internal/ai-proxy/:route', this.handleAiProxy.bind(this));
39+
}
40+
41+
private async handleAiProxy(context: Context): Promise<void> {
42+
try {
43+
context.response.body = await this.aiProxyRouter.route({
44+
route: context.params.route,
45+
body: context.request.body,
46+
query: context.query,
47+
mcpConfigs: await this.options.forestAdminClient.mcpServerConfigService.getConfiguration(),
48+
});
49+
context.response.status = HttpCode.Ok;
50+
} catch (error) {
51+
if (error instanceof AIError) {
52+
this.options.logger('Error', `AI proxy error: ${error.message}`, error);
53+
54+
if (error instanceof AIBadRequestError) throw new BadRequestError(error.message);
55+
if (error instanceof AINotFoundError) throw new NotFoundError(error.message);
56+
throw new UnprocessableError(error.message);
57+
}
58+
59+
throw error;
60+
}
61+
}
62+
}

packages/agent/src/routes/index.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { ForestAdminHttpDriverServices as Services } from '../services';
2-
import type { AgentOptionsWithDefaults as Options } from '../types';
2+
import type { AiConfiguration, AgentOptionsWithDefaults as Options } from '../types';
33
import type BaseRoute from './base-route';
44
import type { DataSource } from '@forestadmin/datasource-toolkit';
55

@@ -14,6 +14,7 @@ import Get from './access/get';
1414
import List from './access/list';
1515
import ListRelated from './access/list-related';
1616
import NativeQueryDatasource from './access/native-query-datasource';
17+
import AiProxyRoute from './ai/ai-proxy';
1718
import Capabilities from './capabilities';
1819
import ActionRoute from './modification/action/action';
1920
import AssociateRelated from './modification/associate-related';
@@ -164,10 +165,21 @@ function getActionRoutes(
164165
return routes;
165166
}
166167

168+
function getAiRoutes(
169+
options: Options,
170+
services: Services,
171+
aiConfigurations: AiConfiguration[],
172+
): BaseRoute[] {
173+
if (aiConfigurations.length === 0) return [];
174+
175+
return [new AiProxyRoute(services, options, aiConfigurations)];
176+
}
177+
167178
export default function makeRoutes(
168179
dataSource: DataSource,
169180
options: Options,
170181
services: Services,
182+
aiConfigurations: AiConfiguration[] = [],
171183
): BaseRoute[] {
172184
const routes = [
173185
...getRootRoutes(options, services),
@@ -177,6 +189,7 @@ export default function makeRoutes(
177189
...getApiChartRoutes(dataSource, options, services),
178190
...getRelatedRoutes(dataSource, options, services),
179191
...getActionRoutes(dataSource, options, services),
192+
...getAiRoutes(options, services, aiConfigurations),
180193
];
181194

182195
// Ensure routes and middlewares are loaded in the right order.

packages/agent/src/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import type { AiConfiguration, AiProvider } from '@forestadmin/ai-proxy';
12
import type { CompositeId, Logger, LoggerLevel } from '@forestadmin/datasource-toolkit';
23
import type { ForestAdminClient } from '@forestadmin/forestadmin-client';
34
import type { IncomingMessage, ServerResponse } from 'http';
45

6+
export type { AiConfiguration, AiProvider };
7+
58
/** Options to configure behavior of an agent's forestadmin driver */
69
export type AgentOptions = {
710
authSecret: string;

packages/agent/src/utils/forest-schema/generator.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { AgentOptionsWithDefaults } from '../../types';
1+
import type { AgentOptionsWithDefaults, AiConfiguration } from '../../types';
22
import type { DataSource } from '@forestadmin/datasource-toolkit';
33
import type { ForestSchema } from '@forestadmin/forestadmin-client';
44

@@ -21,14 +21,21 @@ export default class SchemaGenerator {
2121
};
2222
}
2323

24-
static buildMetadata(features: Record<string, string> | null): Pick<ForestSchema, 'meta'> {
24+
static buildMetadata(
25+
features: Record<string, string> | null,
26+
aiConfigurations: AiConfiguration[] = [],
27+
): Pick<ForestSchema, 'meta'> {
2528
const { version } = require('../../../package.json'); // eslint-disable-line @typescript-eslint/no-var-requires,global-require
2629

2730
return {
2831
meta: {
2932
liana: 'forest-nodejs-agent',
3033
liana_version: version,
3134
liana_features: features,
35+
ai_llms:
36+
aiConfigurations.length > 0
37+
? aiConfigurations.map(c => ({ name: c.name, provider: c.provider }))
38+
: null,
3239
stack: {
3340
engine: 'nodejs',
3441
engine_version: process.versions && process.versions.node,

packages/agent/test/agent.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ describe('Agent', () => {
128128
liana: 'forest-nodejs-agent',
129129
liana_version: expect.stringMatching(/\d+\.\d+\.\d+.*/),
130130
liana_features: null,
131+
ai_llms: null,
131132
stack: expect.anything(),
132133
},
133134
});
@@ -156,6 +157,7 @@ describe('Agent', () => {
156157
liana_features: {
157158
'webhook-custom-actions': expect.stringMatching(/\d+\.\d+\.\d+.*/),
158159
},
160+
ai_llms: null,
159161
stack: expect.anything(),
160162
},
161163
});
@@ -398,4 +400,93 @@ describe('Agent', () => {
398400
);
399401
});
400402
});
403+
404+
describe('addAi', () => {
405+
const options = factories.forestAdminHttpDriverOptions.build({
406+
isProduction: false,
407+
forestAdminClient: factories.forestAdminClient.build({ postSchema: mockPostSchema }),
408+
});
409+
410+
test('should store the AI configuration', () => {
411+
const agent = new Agent(options);
412+
const result = agent.addAi({
413+
name: 'gpt4o',
414+
provider: 'openai',
415+
apiKey: 'test-key',
416+
model: 'gpt-4o',
417+
});
418+
419+
expect(result).toBe(agent);
420+
});
421+
422+
test('should throw an error when addAi is called more than once', () => {
423+
const agent = new Agent(options);
424+
425+
agent.addAi({
426+
name: 'gpt4o',
427+
provider: 'openai',
428+
apiKey: 'test-key',
429+
model: 'gpt-4o',
430+
});
431+
432+
expect(() =>
433+
agent.addAi({
434+
name: 'gpt4o-mini',
435+
provider: 'openai',
436+
apiKey: 'another-key',
437+
model: 'gpt-4o-mini',
438+
}),
439+
).toThrow('addAi can only be called once. Multiple AI configurations are not supported yet.');
440+
});
441+
442+
test('should throw an error when model does not support tools', () => {
443+
const agent = new Agent(options);
444+
445+
expect(() =>
446+
agent.addAi({
447+
name: 'gpt4-base',
448+
provider: 'openai',
449+
apiKey: 'test-key',
450+
model: 'gpt-4',
451+
}),
452+
).toThrow(
453+
"Model 'gpt-4' does not support function calling (tools). " +
454+
'Please use a compatible model like gpt-4o, gpt-4o-mini, or gpt-4-turbo.',
455+
);
456+
});
457+
458+
test('should include ai_llms in schema meta when AI is configured', async () => {
459+
const agent = new Agent(options);
460+
agent.addAi({
461+
name: 'gpt4o',
462+
provider: 'openai',
463+
apiKey: 'test-key',
464+
model: 'gpt-4o',
465+
});
466+
467+
await agent.start();
468+
469+
expect(mockPostSchema).toHaveBeenCalledWith(
470+
expect.objectContaining({
471+
meta: expect.objectContaining({
472+
ai_llms: [{ name: 'gpt4o', provider: 'openai' }],
473+
}),
474+
}),
475+
);
476+
});
477+
478+
test('should not include ai_llms in schema meta when AI is not configured', async () => {
479+
const agent = new Agent(options);
480+
481+
await agent.start();
482+
483+
expect(mockPostSchema).toHaveBeenCalledWith(
484+
expect.objectContaining({
485+
meta: expect.objectContaining({
486+
ai_llms: null,
487+
}),
488+
}),
489+
);
490+
});
491+
});
401492
});

0 commit comments

Comments
 (0)