Skip to content

Commit cabba2c

Browse files
alban bertoliniclaude
andcommitted
refactor(agent): decouple ai-proxy via factory injection pattern (createAiProvider)
Move @forestadmin/ai-proxy from hard dependency to optional peer dependency by introducing a factory injection pattern. Users who need AI features now explicitly install ai-proxy and inject it via createAiProvider(), following the same pattern as addDataSource(createSqlDataSource(...)). This eliminates ~6 langchain packages from the default install for users who don't use AI features. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4bd08ce commit cabba2c

18 files changed

Lines changed: 284 additions & 275 deletions

File tree

packages/_example/src/forest/agent.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Schema } from './typings';
22
import type { AgentOptions } from '@forestadmin/agent';
33

44
import { createAgent } from '@forestadmin/agent';
5+
import { createAiProvider } from '@forestadmin/ai-proxy';
56
import { createMongoDataSource } from '@forestadmin/datasource-mongo';
67
import { createMongooseDataSource } from '@forestadmin/datasource-mongoose';
78
import { createSequelizeDataSource } from '@forestadmin/datasource-sequelize';
@@ -95,10 +96,10 @@ export default function makeAgent() {
9596
.customizeCollection('comment', customizeComment)
9697
.customizeCollection('review', customizeReview)
9798
.customizeCollection('sales', customizeSales)
98-
.addAi({
99+
.addAi(createAiProvider({
99100
model: 'gpt-4o',
100101
provider: 'openai',
101102
name: 'test',
102103
apiKey: process.env.OPENAI_API_KEY,
103-
});
104+
}));
104105
}

packages/agent/package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
},
1414
"dependencies": {
1515
"@fast-csv/format": "^4.3.5",
16-
"@forestadmin/ai-proxy": "1.4.1",
1716
"@forestadmin/datasource-customizer": "1.67.3",
1817
"@forestadmin/datasource-toolkit": "1.50.1",
1918
"@forestadmin/forestadmin-client": "1.37.10",
@@ -72,11 +71,15 @@
7271
"@paralleldrive/cuid2": "2.2.2"
7372
},
7473
"peerDependencies": {
75-
"@fastify/express": "^1.1.0 || ^2.0.0 || ^3.0.0 || ^4.0.0"
74+
"@fastify/express": "^1.1.0 || ^2.0.0 || ^3.0.0 || ^4.0.0",
75+
"@forestadmin/ai-proxy": ">=1.5.0"
7676
},
7777
"peerDependenciesMeta": {
7878
"@fastify/express": {
7979
"optional": true
80+
},
81+
"@forestadmin/ai-proxy": {
82+
"optional": true
8083
}
8184
}
8285
}

packages/agent/src/agent.ts

Lines changed: 17 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import type { ForestAdminHttpDriverServices } from './services';
33
import type {
44
AgentOptions,
55
AgentOptionsWithDefaults,
6-
AiConfiguration,
76
HttpCallback,
87
} from './types';
98
import type {
@@ -14,7 +13,7 @@ import type {
1413
TCollectionName,
1514
TSchema,
1615
} from '@forestadmin/datasource-customizer';
17-
import type { DataSource, DataSourceFactory } from '@forestadmin/datasource-toolkit';
16+
import type { AiProviderDefinition, DataSource, DataSourceFactory } from '@forestadmin/datasource-toolkit';
1817
import type { ForestSchema } from '@forestadmin/forestadmin-client';
1918

2019
import { DataSourceCustomizer } from '@forestadmin/datasource-customizer';
@@ -47,7 +46,7 @@ export default class Agent<S extends TSchema = TSchema> extends FrameworkMounter
4746
protected nocodeCustomizer: DataSourceCustomizer<S>;
4847
protected customizationService: CustomizationService;
4948
protected schemaGenerator: SchemaGenerator;
50-
protected aiConfigurations: AiConfiguration[] = [];
49+
protected aiProvider: AiProviderDefinition | null = null;
5150

5251
/** Whether MCP server should be mounted */
5352
private mcpEnabled = false;
@@ -222,42 +221,36 @@ export default class Agent<S extends TSchema = TSchema> extends FrameworkMounter
222221
* All AI requests from Forest Admin are forwarded to your agent and processed locally.
223222
* Your data and API keys never transit through Forest Admin servers, ensuring full privacy.
224223
*
225-
* @param configuration - The AI provider configuration
226-
* @param configuration.name - A unique name to identify this AI configuration
227-
* @param configuration.provider - The AI provider to use ('openai')
228-
* @param configuration.apiKey - Your API key for the chosen provider
229-
* @param configuration.model - The model to use (e.g., 'gpt-4o')
224+
* @param provider - An AI provider definition created by a factory (e.g., createAiProvider)
230225
* @returns The agent instance for chaining
231226
* @throws Error if addAi is called more than once
232227
*
233228
* @example
234-
* agent.addAi({
229+
* import { createAiProvider } from '@forestadmin/ai-proxy';
230+
*
231+
* agent.addAi(createAiProvider({
235232
* name: 'assistant',
236233
* provider: 'openai',
237234
* apiKey: process.env.OPENAI_API_KEY,
238235
* model: 'gpt-4o',
239-
* });
236+
* }));
240237
*/
241-
addAi(configuration: AiConfiguration): this {
242-
if (this.aiConfigurations.length > 0) {
238+
addAi(provider: AiProviderDefinition): this {
239+
if (this.aiProvider) {
243240
throw new Error(
244241
'addAi can only be called once. Multiple AI configurations are not supported yet.',
245242
);
246243
}
247244

248-
this.options.logger(
249-
'Warn',
250-
`AI configuration added with model '${configuration.model}'. ` +
251-
'Make sure to test Forest Admin AI features thoroughly to ensure compatibility.',
252-
);
253-
254-
this.aiConfigurations.push(configuration);
245+
this.aiProvider = provider;
255246

256247
return this;
257248
}
258249

259250
protected getRoutes(dataSource: DataSource, services: ForestAdminHttpDriverServices) {
260-
return makeRoutes(dataSource, this.options, services, this.aiConfigurations);
251+
const aiRouter = this.aiProvider?.init(this.options.logger) ?? null;
252+
253+
return makeRoutes(dataSource, this.options, services, aiRouter);
261254
}
262255

263256
/**
@@ -380,9 +373,12 @@ export default class Agent<S extends TSchema = TSchema> extends FrameworkMounter
380373
let schema: Pick<ForestSchema, 'collections'>;
381374

382375
// Get the AI configurations for schema metadata
376+
const aiMeta = this.aiProvider
377+
? [{ name: this.aiProvider.name, provider: this.aiProvider.provider }]
378+
: [];
383379
const { meta } = SchemaGenerator.buildMetadata(
384380
this.customizationService.buildFeatures(),
385-
this.aiConfigurations,
381+
aiMeta,
386382
);
387383

388384
// When using experimental no-code features even in production we need to build a new schema

packages/agent/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export function createAgent<S extends TSchema = TSchema>(options: AgentOptions):
99

1010
export { Agent };
1111
export { AgentOptions } from './types';
12+
export type { AiProviderDefinition } from './types';
1213
export * from '@forestadmin/datasource-customizer';
1314

1415
// export is necessary for the agent-generator package

packages/agent/src/routes/ai/ai-proxy.ts

Lines changed: 15 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,9 @@
11
import type { ForestAdminHttpDriverServices } from '../../services';
2-
import type { AgentOptionsWithDefaults, AiConfiguration } from '../../types';
2+
import type { AgentOptionsWithDefaults } from '../../types';
3+
import type { AiRouter } from '@forestadmin/datasource-toolkit';
34
import type KoaRouter from '@koa/router';
45
import type { Context } from 'koa';
56

6-
import {
7-
AIBadRequestError,
8-
AIError,
9-
AINotConfiguredError,
10-
AINotFoundError,
11-
Router as AiProxyRouter,
12-
extractMcpOauthTokensFromHeaders,
13-
injectOauthTokens,
14-
} from '@forestadmin/ai-proxy';
157
import {
168
BadRequestError,
179
NotFoundError,
@@ -23,18 +15,15 @@ import BaseRoute from '../base-route';
2315

2416
export default class AiProxyRoute extends BaseRoute {
2517
readonly type = RouteType.PrivateRoute;
26-
private readonly aiProxyRouter: AiProxyRouter;
18+
private readonly aiRouter: AiRouter;
2719

2820
constructor(
2921
services: ForestAdminHttpDriverServices,
3022
options: AgentOptionsWithDefaults,
31-
aiConfigurations: AiConfiguration[],
23+
aiRouter: AiRouter,
3224
) {
3325
super(services, options);
34-
this.aiProxyRouter = new AiProxyRouter({
35-
aiConfigurations,
36-
logger: this.options.logger,
37-
});
26+
this.aiRouter = aiRouter;
3827
}
3928

4029
setupRoutes(router: KoaRouter): void {
@@ -43,29 +32,26 @@ export default class AiProxyRoute extends BaseRoute {
4332

4433
private async handleAiProxy(context: Context): Promise<void> {
4534
try {
46-
const tokensByMcpServerName = extractMcpOauthTokensFromHeaders(context.request.headers);
47-
48-
const mcpConfigs =
35+
const mcpServerConfigs =
4936
await this.options.forestAdminClient.mcpServerConfigService.getConfiguration();
5037

51-
context.response.body = await this.aiProxyRouter.route({
38+
context.response.body = await this.aiRouter.route({
5239
route: context.params.route,
5340
body: context.request.body,
5441
query: context.query,
55-
mcpConfigs: injectOauthTokens({ mcpConfigs, tokensByMcpServerName }),
42+
mcpServerConfigs,
43+
requestHeaders: context.request.headers,
5644
});
5745
context.response.status = HttpCode.Ok;
5846
} catch (error) {
59-
if (error instanceof AIError) {
60-
this.options.logger('Error', `AI proxy error: ${error.message}`, error);
47+
const err = error as Error & { status?: number };
6148

62-
if (error instanceof AINotConfiguredError) {
63-
throw new UnprocessableError('AI is not configured. Please call addAi() on your agent.');
64-
}
49+
if (typeof err.status === 'number' && err.status >= 400 && err.status < 600) {
50+
this.options.logger('Error', `AI proxy error: ${err.message}`, err);
6551

66-
if (error instanceof AIBadRequestError) throw new BadRequestError(error.message);
67-
if (error instanceof AINotFoundError) throw new NotFoundError(error.message);
68-
throw new UnprocessableError(error.message);
52+
if (err.status === 400) throw new BadRequestError(err.message);
53+
if (err.status === 404) throw new NotFoundError(err.message);
54+
throw new UnprocessableError(err.message);
6955
}
7056

7157
throw error;

packages/agent/src/routes/index.ts

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

66
import CollectionApiChartRoute from './access/api-chart-collection';
77
import DataSourceApiChartRoute from './access/api-chart-datasource';
@@ -168,18 +168,18 @@ function getActionRoutes(
168168
function getAiRoutes(
169169
options: Options,
170170
services: Services,
171-
aiConfigurations: AiConfiguration[],
171+
aiRouter: AiRouter | null,
172172
): BaseRoute[] {
173-
if (aiConfigurations.length === 0) return [];
173+
if (!aiRouter) return [];
174174

175-
return [new AiProxyRoute(services, options, aiConfigurations)];
175+
return [new AiProxyRoute(services, options, aiRouter)];
176176
}
177177

178178
export default function makeRoutes(
179179
dataSource: DataSource,
180180
options: Options,
181181
services: Services,
182-
aiConfigurations: AiConfiguration[] = [],
182+
aiRouter: AiRouter | null = null,
183183
): BaseRoute[] {
184184
const routes = [
185185
...getRootRoutes(options, services),
@@ -189,7 +189,7 @@ export default function makeRoutes(
189189
...getApiChartRoutes(dataSource, options, services),
190190
...getRelatedRoutes(dataSource, options, services),
191191
...getActionRoutes(dataSource, options, services),
192-
...getAiRoutes(options, services, aiConfigurations),
192+
...getAiRoutes(options, services, aiRouter),
193193
];
194194

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

packages/agent/src/types.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
import type { AiConfiguration, AiProvider } from '@forestadmin/ai-proxy';
2-
import type { CompositeId, Logger, LoggerLevel } from '@forestadmin/datasource-toolkit';
1+
import type { AiProviderDefinition, CompositeId, Logger, LoggerLevel } from '@forestadmin/datasource-toolkit';
32
import type { ForestAdminClient } from '@forestadmin/forestadmin-client';
43
import type { IncomingMessage, ServerResponse } from 'http';
54

6-
export type { AiConfiguration, AiProvider };
5+
export type { AiProviderDefinition };
76

87
/** Options to configure behavior of an agent's forestadmin driver */
98
export type AgentOptions = {

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

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

@@ -23,7 +23,7 @@ export default class SchemaGenerator {
2323

2424
static buildMetadata(
2525
features: Record<string, string> | null,
26-
aiConfigurations: AiConfiguration[] = [],
26+
aiProviders: Array<{ name: string; provider: string }> = [],
2727
): Pick<ForestSchema, 'meta'> {
2828
const { version } = require('../../../package.json'); // eslint-disable-line @typescript-eslint/no-var-requires,global-require
2929

@@ -33,8 +33,8 @@ export default class SchemaGenerator {
3333
liana_version: version,
3434
liana_features: features,
3535
ai_llms:
36-
aiConfigurations.length > 0
37-
? aiConfigurations.map(c => ({ name: c.name, provider: c.provider }))
36+
aiProviders.length > 0
37+
? aiProviders.map(c => ({ name: c.name, provider: c.provider }))
3838
: null,
3939
stack: {
4040
engine: 'nodejs',

0 commit comments

Comments
 (0)