diff --git a/packages/_example/src/forest/agent.ts b/packages/_example/src/forest/agent.ts index 8534595db3..c5a8abf2cf 100644 --- a/packages/_example/src/forest/agent.ts +++ b/packages/_example/src/forest/agent.ts @@ -2,7 +2,6 @@ import type { Schema } from './typings'; import type { AgentOptions } from '@forestadmin/agent'; import { createAgent } from '@forestadmin/agent'; -import { createAiProvider } from '@forestadmin/ai-proxy'; import { createMongoDataSource } from '@forestadmin/datasource-mongo'; import { createMongooseDataSource } from '@forestadmin/datasource-mongoose'; import { createSequelizeDataSource } from '@forestadmin/datasource-sequelize'; @@ -96,13 +95,5 @@ export default function makeAgent() { .customizeCollection('post', customizePost) .customizeCollection('comment', customizeComment) .customizeCollection('review', customizeReview) - .customizeCollection('sales', customizeSales) - .addAi( - createAiProvider({ - model: 'gpt-4o', - provider: 'openai', - name: 'test', - apiKey: process.env.OPENAI_API_KEY, - }), - ); + .customizeCollection('sales', customizeSales); } diff --git a/packages/agent-toolkit/CLAUDE.md b/packages/agent-toolkit/CLAUDE.md index 8fa24cf446..f25d594f29 100644 --- a/packages/agent-toolkit/CLAUDE.md +++ b/packages/agent-toolkit/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Overview -`@forestadmin/agent-toolkit` is the lowest-level shared package in the monorepo. It holds two cross-cutting contracts that sibling packages depend on: the `BusinessError` hierarchy and the AI-proxy provider interfaces. It deliberately has **zero `@forestadmin/*` dependencies** so that any package can depend on it without creating a cycle. +`@forestadmin/agent-toolkit` is the lowest-level shared package in the monorepo. It holds the cross-cutting `BusinessError` hierarchy that sibling packages depend on. It deliberately has **zero `@forestadmin/*` dependencies** so that any package can depend on it without creating a cycle. ## Architecture @@ -13,10 +13,7 @@ The package is tiny but load-bearing — its value is in being the single source - **`src/errors.ts` — `BusinessError` hierarchy.** `BusinessError` plus the HTTP-flavored subclasses (`ValidationError`, `BadRequestError`, `UnprocessableError`, `ForbiddenError`, `NotFoundError`, `UnauthorizedError`, `TooManyRequestsError`, `InternalServerError`). The agent's error middleware maps these to HTTP statuses. `datasource-toolkit/src/errors.ts` **re-exports `BusinessError`/`ValidationError` from here** for backward compatibility and builds its own domain errors on top, so this is the canonical definition — changing it ripples outward. - Subclasses set `baseBusinessErrorName` so error type can be detected across mismatched package versions. **Use `BusinessError.isOfType(err, SomeError)` rather than `instanceof`** — `instanceof` is unreliable when two packages resolve to different copies of this module. -- **`src/interfaces/ai.ts` — AI provider contract.** Type-only interfaces (`AiProviderDefinition`, `AiProviderMeta`, `AiRouter`) that define how an AI provider plugs into the agent. `@forestadmin/ai-proxy` *implements* these (via `createAiProvider`); `@forestadmin/agent` *consumes* them (in its `ai-proxy` route and schema generator). `AiRouter.route()` implementations should throw the `BusinessError` subclasses above for correct HTTP mapping. - - `Logger`/`LoggerLevel` here are **inlined copies** of datasource-toolkit's types (to avoid the dependency). They must stay structurally compatible — if datasource-toolkit's `Logger` changes, update these by hand. - -- **`src/index.ts`** re-exports everything; AI symbols are exported as `type` (no runtime code). +- **`src/index.ts`** re-exports the error hierarchy. ## Commands diff --git a/packages/agent-toolkit/src/index.ts b/packages/agent-toolkit/src/index.ts index b4d405c10d..f72bc43e28 100644 --- a/packages/agent-toolkit/src/index.ts +++ b/packages/agent-toolkit/src/index.ts @@ -1,2 +1 @@ export * from './errors'; -export type { AiProviderDefinition, AiProviderMeta, AiRouter } from './interfaces/ai'; diff --git a/packages/agent-toolkit/src/interfaces/ai.ts b/packages/agent-toolkit/src/interfaces/ai.ts deleted file mode 100644 index b8c82eddae..0000000000 --- a/packages/agent-toolkit/src/interfaces/ai.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Inlined from datasource-toolkit/factory.ts to avoid a dependency on a lower-level package. - * Must stay structurally compatible with datasource-toolkit's Logger & LoggerLevel. - * If those types change, update these copies accordingly. - */ -export type LoggerLevel = 'Debug' | 'Info' | 'Warn' | 'Error'; -export type Logger = (level: LoggerLevel, message: string, error?: Error) => void; - -/** Metadata describing a configured AI provider, used in schema reporting and logging. */ -export interface AiProviderMeta { - name: string; - provider: string; - model: string; -} - -export interface AiRouter { - /** - * Route a request to the AI proxy. - * - * Implementations should throw BusinessError subclasses (BadRequestError, NotFoundError, - * UnprocessableError) for proper HTTP status mapping by the agent's error middleware. - */ - route(args: { - route: string; - body?: unknown; - query?: Record; - toolConfigs?: unknown; - headers?: Record; - }): Promise; -} - -export interface AiProviderDefinition { - providers: AiProviderMeta[]; - init(logger: Logger): AiRouter; -} diff --git a/packages/agent/CLAUDE.md b/packages/agent/CLAUDE.md index 88cda10fa0..7d5701e5bf 100644 --- a/packages/agent/CLAUDE.md +++ b/packages/agent/CLAUDE.md @@ -15,7 +15,7 @@ The `Agent` class (`src/agent.ts`, default export, aliased as `AgentBuilder` in - **Routes** (`src/routes/`, assembled by `makeRoutes` in `routes/index.ts`) are generated dynamically per datasource: root routes, then CRUD/capabilities/native-query per collection, api-charts, related routes (built by relation type — `ManyToMany`/`OneToMany` get list/count/csv/associate/dissociate, `OneToOne`/`ManyToOne` get update-relation), action routes, plus opt-in AI and workflow-executor routes. There is no per-segment route. Each route extends `BaseRoute`/`CollectionRoute` and registers itself onto a `@koa/router`. Routes are sorted by `RouteType` (`src/types.ts`) so logger/error/auth middleware load before private routes — order is load-bearing, do not reorder by alphabet. - **Services** (`src/services/`, built by `makeServices`) are the shared dependencies injected into every route: `authorization` (delegates permission checks to `@forestadmin/forestadmin-client`), `serializer` (JSON:API), `chartHandler`, and `segmentQueryHandler` (applies the requested segment as a filter inside the `list`/`count` routes via `handleLiveQuerySegmentFilter` — this is where segments are handled, not at route generation). - **`FrameworkMounter`** (`src/framework-mounter.ts`, the `Agent` base class) handles mounting onto Express/Fastify/Koa/NestJS/standalone. It mounts forest routes at `/{prefix}/forest` and remounts the router in place on every `restart()` (triggered by `onRefreshCustomizations`) without restarting the HTTP server. -- **MCP and AI** are opt-in. `mountAiMcpServer()` defers loading `@forestadmin/mcp-server` via dynamic `import()` so non-MCP users don't pay for it; `addAi()` wires an `@forestadmin/ai-proxy` provider. The MCP HTTP callback is injected ahead of the body parser via `McpMiddleware`. +- **MCP** is opt-in. `mountAiMcpServer()` defers loading `@forestadmin/mcp-server` via dynamic `import()` so non-MCP users don't pay for it. The MCP HTTP callback is injected ahead of the body parser via `McpMiddleware`. `SchemaGenerator` (`src/utils/forest-schema/`) builds the `.forestadmin-schema.json` apimap from the datasource; it is re-exported for the `agent-generator` package. diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index a8e3b2397d..05278c0c8e 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -7,7 +7,6 @@ import type { McpRouteMatcher, WorkflowExecutorEmbedOptions, } from './types'; -import type { AiProviderDefinition } from '@forestadmin/agent-toolkit'; import type { CollectionCustomizer, DataSourceChartDefinition, @@ -51,7 +50,6 @@ export default class Agent extends FrameworkMounter protected nocodeCustomizer: DataSourceCustomizer; protected customizationService: CustomizationService; protected schemaGenerator: SchemaGenerator; - protected aiProvider: AiProviderDefinition | null = null; /** Whether MCP server should be mounted */ private mcpEnabled = false; @@ -266,52 +264,6 @@ export default class Agent extends FrameworkMounter return this; } - /** - * Enable AI features for your Forest Admin panel. - * - * All AI requests from Forest Admin are forwarded to your agent and processed locally. - * Your data and API keys never transit through Forest Admin servers, ensuring full privacy. - * - * Requires the `@forestadmin/ai-proxy` package to be installed: - * ```bash - * npm install @forestadmin/ai-proxy - * ``` - * - * @see {@link https://docs.forestadmin.com/developer-guide-agents-nodejs/agent-customization/ai/self-hosted-ai} - * @param provider - An AI provider definition created via `createAiProvider` from `@forestadmin/ai-proxy` - * @returns The agent instance for chaining - * @throws Error if addAi is called more than once - * - * @example - * import { createAiProvider } from '@forestadmin/ai-proxy'; - * - * agent.addAi(createAiProvider({ - * name: 'assistant', - * provider: 'openai', - * apiKey: process.env.OPENAI_API_KEY, - * model: 'gpt-4o', - * })); - */ - addAi(provider: AiProviderDefinition): this { - if (this.aiProvider) { - throw new Error( - 'addAi can only be called once. Multiple AI configurations are not supported yet.', - ); - } - - this.aiProvider = provider; - - for (const p of provider.providers) { - this.options.logger( - 'Warn', - `AI configuration added with model '${p.model}'. ` + - 'Make sure to test Forest Admin AI features thoroughly to ensure compatibility.', - ); - } - - return this; - } - /** * Run a workflow executor in-process, alongside the agent. The agent boots it on start(), * stops it on stop(), and proxies `/_internal/executor/*` to it — no separate deployment. @@ -351,10 +303,7 @@ export default class Agent extends FrameworkMounter } protected getRoutes(dataSource: DataSource, services: ForestAdminHttpDriverServices) { - // init() is called on every start/restart to recreate routing state with a fresh Router. - const aiRouter = this.aiProvider?.init(this.options.logger) ?? null; - - return makeRoutes(dataSource, this.options, services, aiRouter); + return makeRoutes(dataSource, this.options, services); } /** @@ -462,9 +411,7 @@ export default class Agent extends FrameworkMounter } private buildSchemaMeta(): ForestSchema['meta'] { - const aiMeta = this.aiProvider?.providers ?? []; - - return SchemaGenerator.buildMetadata(this.customizationService.buildFeatures(), aiMeta).meta; + return SchemaGenerator.buildMetadata(this.customizationService.buildFeatures()).meta; } private async buildRouterAndSendSchema(): Promise<{ diff --git a/packages/agent/src/routes/ai/ai-proxy.ts b/packages/agent/src/routes/ai/ai-proxy.ts deleted file mode 100644 index bbb746f0b6..0000000000 --- a/packages/agent/src/routes/ai/ai-proxy.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { ForestAdminHttpDriverServices } from '../../services'; -import type { AgentOptionsWithDefaults } from '../../types'; -import type { AiRouter } from '@forestadmin/agent-toolkit'; -import type KoaRouter from '@koa/router'; -import type { Context } from 'koa'; - -import { HttpCode, RouteType } from '../../types'; -import BaseRoute from '../base-route'; - -export default class AiProxyRoute extends BaseRoute { - readonly type = RouteType.PrivateRoute; - private readonly aiRouter: AiRouter; - - constructor( - services: ForestAdminHttpDriverServices, - options: AgentOptionsWithDefaults, - aiRouter: AiRouter, - ) { - super(services, options); - this.aiRouter = aiRouter; - } - - setupRoutes(router: KoaRouter): void { - router.post('/_internal/ai-proxy/:route', this.handleAiProxy.bind(this)); - } - - private async handleAiProxy(context: Context): Promise { - const toolConfigs = - await this.options.forestAdminClient.mcpServerConfigService.getConfiguration(); - - context.response.body = await this.aiRouter.route({ - route: context.params.route, - body: context.request.body, - query: context.query, - headers: context.request.headers, - toolConfigs, - }); - context.response.status = HttpCode.Ok; - } -} diff --git a/packages/agent/src/routes/index.ts b/packages/agent/src/routes/index.ts index 6d626180e2..553c62dfe2 100644 --- a/packages/agent/src/routes/index.ts +++ b/packages/agent/src/routes/index.ts @@ -1,7 +1,6 @@ import type { ForestAdminHttpDriverServices as Services } from '../services'; import type { AgentOptionsWithDefaults as Options } from '../types'; import type BaseRoute from './base-route'; -import type { AiRouter } from '@forestadmin/agent-toolkit'; import type { DataSource } from '@forestadmin/datasource-toolkit'; import CollectionApiChartRoute from './access/api-chart-collection'; @@ -15,7 +14,6 @@ import Get from './access/get'; import List from './access/list'; import ListRelated from './access/list-related'; import NativeQueryDatasource from './access/native-query-datasource'; -import AiProxyRoute from './ai/ai-proxy'; import Capabilities from './capabilities'; import ActionRoute from './modification/action/action'; import AssociateRelated from './modification/associate-related'; @@ -167,12 +165,6 @@ function getActionRoutes( return routes; } -function getAiRoutes(options: Options, services: Services, aiRouter: AiRouter | null): BaseRoute[] { - if (!aiRouter) return []; - - return [new AiProxyRoute(services, options, aiRouter)]; -} - function getWorkflowExecutorRoutes(options: Options, services: Services): BaseRoute[] { if (!options.workflowExecutorUrl) return []; @@ -183,7 +175,6 @@ export default function makeRoutes( dataSource: DataSource, options: Options, services: Services, - aiRouter: AiRouter | null = null, ): BaseRoute[] { const routes = [ ...getRootRoutes(options, services), @@ -193,7 +184,6 @@ export default function makeRoutes( ...getApiChartRoutes(dataSource, options, services), ...getRelatedRoutes(dataSource, options, services), ...getActionRoutes(dataSource, options, services), - ...getAiRoutes(options, services, aiRouter), ...getWorkflowExecutorRoutes(options, services), ]; diff --git a/packages/agent/src/utils/forest-schema/generator.ts b/packages/agent/src/utils/forest-schema/generator.ts index ede8714b6d..b69848907e 100644 --- a/packages/agent/src/utils/forest-schema/generator.ts +++ b/packages/agent/src/utils/forest-schema/generator.ts @@ -1,5 +1,4 @@ import type { AgentOptionsWithDefaults } from '../../types'; -import type { AiProviderMeta } from '@forestadmin/agent-toolkit'; import type { DataSource } from '@forestadmin/datasource-toolkit'; import type { ForestSchema } from '@forestadmin/forestadmin-client'; @@ -22,10 +21,7 @@ export default class SchemaGenerator { }; } - static buildMetadata( - features: Record | null, - aiProviders: AiProviderMeta[] = [], - ): Pick { + static buildMetadata(features: Record | null): Pick { const { version } = require('../../../package.json'); // eslint-disable-line @typescript-eslint/no-var-requires,global-require return { @@ -33,10 +29,6 @@ export default class SchemaGenerator { liana: 'forest-nodejs-agent', liana_version: version, liana_features: features, - ai_llms: - aiProviders.length > 0 - ? aiProviders.map(p => ({ name: p.name, provider: p.provider, model: p.model })) - : null, stack: { engine: 'nodejs', engine_version: process.versions && process.versions.node, diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index ffe113ff7b..f5109ba8b3 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -1,7 +1,6 @@ /* eslint-disable max-classes-per-file */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { AiProviderDefinition } from '@forestadmin/agent-toolkit'; import type { DataSourceFactory } from '@forestadmin/datasource-toolkit'; import { DataSourceCustomizer } from '@forestadmin/datasource-customizer'; @@ -36,14 +35,6 @@ beforeEach(() => { .mockResolvedValue(factories.dataSource.build()); }); -function createMockAiProvider(overrides: Partial = {}): AiProviderDefinition { - return { - providers: [{ name: 'gpt4o', provider: 'openai', model: 'gpt-4o' }], - init: jest.fn().mockReturnValue({ route: jest.fn() }), - ...overrides, - }; -} - describe('Agent', () => { describe('Development', () => { const options = factories.forestAdminHttpDriverOptions.build({ @@ -138,7 +129,6 @@ describe('Agent', () => { liana: 'forest-nodejs-agent', liana_version: expect.stringMatching(/\d+\.\d+\.\d+.*/), liana_features: null, - ai_llms: null, stack: expect.anything(), }, }); @@ -167,7 +157,6 @@ describe('Agent', () => { liana_features: { 'webhook-custom-actions': expect.stringMatching(/\d+\.\d+\.\d+.*/), }, - ai_llms: null, stack: expect.anything(), }, }); @@ -639,127 +628,4 @@ describe('Agent', () => { ); }); }); - - describe('addAi', () => { - const options = factories.forestAdminHttpDriverOptions.build({ - isProduction: false, - forestAdminClient: factories.forestAdminClient.build({ postSchema: mockPostSchema }), - }); - - test('should store the AI provider and return agent for chaining', () => { - const agent = new Agent(options); - const provider = createMockAiProvider(); - const result = agent.addAi(provider); - - expect(result).toBe(agent); - }); - - test('should throw an error when addAi is called more than once', () => { - const agent = new Agent(options); - - agent.addAi( - createMockAiProvider({ - providers: [{ name: 'gpt4o', provider: 'openai', model: 'gpt-4o' }], - }), - ); - - expect(() => - agent.addAi( - createMockAiProvider({ - providers: [{ name: 'gpt4o-mini', provider: 'openai', model: 'gpt-4o-mini' }], - }), - ), - ).toThrow('addAi can only be called once. Multiple AI configurations are not supported yet.'); - }); - - test('should log a warning with model name when addAi is called', () => { - const mockLogger = jest.fn(); - const agentOptions = factories.forestAdminHttpDriverOptions.build({ - isProduction: false, - logger: mockLogger, - forestAdminClient: factories.forestAdminClient.build({ postSchema: mockPostSchema }), - }); - - const agent = new Agent(agentOptions); - agent.addAi( - createMockAiProvider({ - providers: [{ name: 'gpt4o', provider: 'openai', model: 'gpt-4o' }], - }), - ); - - expect(mockLogger).toHaveBeenCalledWith('Warn', expect.stringContaining("model 'gpt-4o'")); - }); - - test('should log a warning for each provider when addAi is called with multiple providers', () => { - const mockLogger = jest.fn(); - const agentOptions = factories.forestAdminHttpDriverOptions.build({ - isProduction: false, - logger: mockLogger, - forestAdminClient: factories.forestAdminClient.build({ postSchema: mockPostSchema }), - }); - - const agent = new Agent(agentOptions); - agent.addAi( - createMockAiProvider({ - providers: [ - { name: 'gpt4o', provider: 'openai', model: 'gpt-4o' }, - { name: 'claude', provider: 'anthropic', model: 'claude-sonnet-4-5-20250929' }, - ], - }), - ); - - expect(mockLogger).toHaveBeenCalledWith('Warn', expect.stringContaining("model 'gpt-4o'")); - expect(mockLogger).toHaveBeenCalledWith( - 'Warn', - expect.stringContaining("model 'claude-sonnet-4-5-20250929'"), - ); - expect(mockLogger).toHaveBeenCalledTimes(2); - }); - - test('should call init with logger on start to create AI router', async () => { - const realMakeRoutes = jest.requireActual('../src/routes').default; - mockMakeRoutes.mockImplementation(realMakeRoutes); - - const provider = createMockAiProvider(); - const agent = new Agent(options); - agent.addAi(provider); - - await agent.start(); - - expect(provider.init).toHaveBeenCalledWith(options.logger); - }); - - test('should include ai_llms in schema meta when AI is configured', async () => { - const agent = new Agent(options); - agent.addAi( - createMockAiProvider({ - providers: [{ name: 'gpt4o', provider: 'openai', model: 'gpt-4o' }], - }), - ); - - await agent.start(); - - expect(mockPostSchema).toHaveBeenCalledWith( - expect.objectContaining({ - meta: expect.objectContaining({ - ai_llms: [{ name: 'gpt4o', provider: 'openai', model: 'gpt-4o' }], - }), - }), - ); - }); - - test('should not include ai_llms in schema meta when AI is not configured', async () => { - const agent = new Agent(options); - - await agent.start(); - - expect(mockPostSchema).toHaveBeenCalledWith( - expect.objectContaining({ - meta: expect.objectContaining({ - ai_llms: null, - }), - }), - ); - }); - }); }); diff --git a/packages/agent/test/routes/ai/ai-proxy.test.ts b/packages/agent/test/routes/ai/ai-proxy.test.ts deleted file mode 100644 index 8e42f7fc98..0000000000 --- a/packages/agent/test/routes/ai/ai-proxy.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import type { AiRouter } from '@forestadmin/agent-toolkit'; - -import { createMockContext } from '@shopify/jest-koa-mocks'; - -import AiProxyRoute from '../../../src/routes/ai/ai-proxy'; -import { HttpCode, RouteType } from '../../../src/types'; -import * as factories from '../../__factories__'; - -describe('AiProxyRoute', () => { - const options = factories.forestAdminHttpDriverOptions.build(); - const services = factories.forestAdminHttpDriverServices.build(); - const router = factories.router.mockAllMethods().build(); - - let mockRoute: jest.Mock; - let aiRouter: AiRouter; - - beforeEach(() => { - jest.clearAllMocks(); - mockRoute = jest.fn(); - aiRouter = { route: mockRoute }; - }); - - describe('constructor', () => { - test('should have RouteType.PrivateRoute', () => { - const route = new AiProxyRoute(services, options, aiRouter); - - expect(route.type).toBe(RouteType.PrivateRoute); - }); - }); - - describe('setupRoutes', () => { - test('should register POST route at /_internal/ai-proxy/:route', () => { - const route = new AiProxyRoute(services, options, aiRouter); - route.setupRoutes(router); - - expect(router.post).toHaveBeenCalledWith('/_internal/ai-proxy/:route', expect.any(Function)); - }); - }); - - describe('handleAiProxy', () => { - test('should return 200 with response body on successful request', async () => { - const route = new AiProxyRoute(services, options, aiRouter); - const expectedResponse = { result: 'success' }; - mockRoute.mockResolvedValueOnce(expectedResponse); - - const context = createMockContext({ - customProperties: { - params: { route: 'ai-query' }, - query: {}, - }, - requestBody: { messages: [] }, - }); - - await (route as any).handleAiProxy(context); - - expect(context.response.status).toBe(HttpCode.Ok); - expect(context.response.body).toEqual(expectedResponse); - }); - - test('should pass route, body, query, toolConfigs and headers to router', async () => { - const route = new AiProxyRoute(services, options, aiRouter); - mockRoute.mockResolvedValueOnce({}); - - const context = createMockContext({ - customProperties: { - params: { route: 'ai-query' }, - }, - requestBody: { messages: [{ role: 'user', content: 'Hello' }] }, - }); - context.query = { 'ai-name': 'gpt4' }; - - await (route as any).handleAiProxy(context); - - expect(mockRoute).toHaveBeenCalledWith({ - route: 'ai-query', - body: { messages: [{ role: 'user', content: 'Hello' }] }, - query: { 'ai-name': 'gpt4' }, - toolConfigs: undefined, - headers: context.request.headers, - }); - }); - - test('should pass toolConfigs from forestAdminClient to router', async () => { - const route = new AiProxyRoute(services, options, aiRouter); - mockRoute.mockResolvedValueOnce({}); - - const mcpConfigs = { - server1: { type: 'http' as const, url: 'https://server1.com' }, - }; - jest - .spyOn(options.forestAdminClient.mcpServerConfigService, 'getConfiguration') - .mockResolvedValueOnce(mcpConfigs); - - const context = createMockContext({ - customProperties: { - params: { route: 'ai-query' }, - }, - requestBody: { messages: [] }, - }); - context.query = {}; - - await (route as any).handleAiProxy(context); - - expect(mockRoute).toHaveBeenCalledWith( - expect.objectContaining({ - toolConfigs: mcpConfigs, - headers: context.request.headers, - }), - ); - }); - - test('should let errors from aiRouter propagate unchanged', async () => { - const route = new AiProxyRoute(services, options, aiRouter); - const error = new Error('AI error'); - mockRoute.mockRejectedValueOnce(error); - - const context = createMockContext({ - customProperties: { - params: { route: 'ai-query' }, - query: {}, - }, - requestBody: {}, - }); - - await expect((route as any).handleAiProxy(context)).rejects.toBe(error); - }); - }); -}); diff --git a/packages/agent/test/routes/index.test.ts b/packages/agent/test/routes/index.test.ts index d2e56b8ddb..b52174213f 100644 --- a/packages/agent/test/routes/index.test.ts +++ b/packages/agent/test/routes/index.test.ts @@ -17,7 +17,6 @@ import Get from '../../src/routes/access/get'; import List from '../../src/routes/access/list'; import ListRelated from '../../src/routes/access/list-related'; import DataSourceNativeQueryRoute from '../../src/routes/access/native-query-datasource'; -import AiProxyRoute from '../../src/routes/ai/ai-proxy'; import Capabilities from '../../src/routes/capabilities'; import AssociateRelated from '../../src/routes/modification/associate-related'; import Create from '../../src/routes/modification/create'; @@ -299,42 +298,5 @@ describe('Route index', () => { expect(lqRoute).toBeTruthy(); }); }); - - describe('with AI router', () => { - test('should not include AI routes when aiRouter is null', () => { - const dataSource = factories.dataSource.buildWithCollections([ - factories.collection.build({ name: 'books' }), - ]); - - const routes = makeRoutes( - dataSource, - factories.forestAdminHttpDriverOptions.build(), - factories.forestAdminHttpDriverServices.build(), - null, - ); - - const aiRoute = routes.find(route => route instanceof AiProxyRoute); - expect(aiRoute).toBeUndefined(); - }); - - test('should include AiProxyRoute when an AI router is provided', () => { - const dataSource = factories.dataSource.buildWithCollections([ - factories.collection.build({ name: 'books' }), - ]); - - const aiRouter = { route: jest.fn() }; - - const routes = makeRoutes( - dataSource, - factories.forestAdminHttpDriverOptions.build(), - factories.forestAdminHttpDriverServices.build(), - aiRouter, - ); - - const aiRoute = routes.find(route => route instanceof AiProxyRoute); - expect(aiRoute).toBeTruthy(); - expect(aiRoute).toBeInstanceOf(AiProxyRoute); - }); - }); }); }); diff --git a/packages/agent/test/utils/forest-schema/generator.test.ts b/packages/agent/test/utils/forest-schema/generator.test.ts index 31c33c57d7..e9a8f11d40 100644 --- a/packages/agent/test/utils/forest-schema/generator.test.ts +++ b/packages/agent/test/utils/forest-schema/generator.test.ts @@ -37,7 +37,6 @@ describe('SchemaGenerator', () => { expect(schema).toStrictEqual({ meta: { - ai_llms: null, liana: 'forest-nodejs-agent', liana_version: expect.any(String), liana_features: null, @@ -57,7 +56,6 @@ describe('SchemaGenerator', () => { expect(schema).toStrictEqual({ meta: { - ai_llms: null, liana: 'forest-nodejs-agent', liana_version: expect.any(String), liana_features: { @@ -71,30 +69,5 @@ describe('SchemaGenerator', () => { }, }); }); - - test('it should serialize ai_llms when AI providers are provided', async () => { - const aiProviders = [ - { name: 'gpt4', provider: 'openai', model: 'gpt-4o' }, - { name: 'claude', provider: 'anthropic', model: 'claude-sonnet-4-5-20250929' }, - ]; - - const schema = await SchemaGenerator.buildMetadata(null, aiProviders); - - expect(schema).toStrictEqual({ - meta: { - ai_llms: [ - { name: 'gpt4', provider: 'openai', model: 'gpt-4o' }, - { name: 'claude', provider: 'anthropic', model: 'claude-sonnet-4-5-20250929' }, - ], - liana: 'forest-nodejs-agent', - liana_version: expect.any(String), - liana_features: null, - stack: { - engine: 'nodejs', - engine_version: expect.any(String), - }, - }, - }); - }); }); }); diff --git a/packages/ai-proxy/CLAUDE.md b/packages/ai-proxy/CLAUDE.md index 2f5a3016ea..e76c2197bc 100644 --- a/packages/ai-proxy/CLAUDE.md +++ b/packages/ai-proxy/CLAUDE.md @@ -4,11 +4,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Overview -`@forestadmin/ai-proxy` is the AI/LLM integration layer of the monorepo. It implements the AI-provider contract owned by `@forestadmin/agent-toolkit` (`AiProviderDefinition` / `AiRouter` from `interfaces/ai.ts`) and is consumed by `@forestadmin/agent` (which mounts it on its `ai-proxy` route), plus `forestadmin-client` and `workflow-executor`. It is a thin pass-through proxy in front of LangChain: it speaks the OpenAI Chat Completions wire format to the frontend, dispatches to OpenAI or Anthropic, and exposes "remote tools" (MCP servers + first-party Forest integrations) for tool-calling. There are two distinct entry surfaces: the **`Router` proxy** (HTTP/wire-format path) and the **`AiClient`** class (in-process path used by `workflow-executor` via `AiClientAdapter`) — see Architecture. +`@forestadmin/ai-proxy` is the AI/LLM integration layer of the monorepo. It is consumed by `forestadmin-client` and `workflow-executor`. It is a thin pass-through proxy in front of LangChain: it speaks the OpenAI Chat Completions wire format, dispatches to OpenAI or Anthropic, and exposes "remote tools" (MCP servers + first-party Forest integrations) for tool-calling. There are two distinct entry surfaces: the **`Router` proxy** (HTTP/wire-format path) and the **`AiClient`** class (in-process path used by `workflow-executor` via `AiClientAdapter`) — see Architecture. ## Architecture -`createAiProvider(config)` (`create-ai-provider.ts`) is the public entry point: it returns an `AiProviderDefinition` whose `init(logger)` builds a `Router` and exposes a single `route(args)`. Everything funnels through **`Router.route`** (`router.ts`), which Zod-validates the request against a discriminated union on `route` (`schemas/route.ts`) — three routes: +**`Router.route`** (`router.ts`) Zod-validates the request against a discriminated union on `route` (`schemas/route.ts`) — three routes: - **`ai-query`** — pick an `AiConfiguration` (by `ai-name`, falling back to the first config with a warning), build a `ProviderDispatcher`, and dispatch the chat completion. - **`invoke-remote-tool`** — execute one tool by sanitized name (+ optional `source-id` to disambiguate). diff --git a/packages/ai-proxy/src/create-ai-provider.ts b/packages/ai-proxy/src/create-ai-provider.ts deleted file mode 100644 index 0a2e013bf9..0000000000 --- a/packages/ai-proxy/src/create-ai-provider.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { AiConfiguration } from './provider'; -import type { RouterRouteArgs } from './schemas/route'; -import type { ToolConfig } from './tool-provider-factory'; -import type { AiProviderDefinition, AiRouter } from '@forestadmin/agent-toolkit'; - -import { extractMcpOauthTokensFromHeaders, injectOauthTokens } from './oauth-token-injector'; -import { Router } from './router'; - -function resolveMcpConfigs( - args: Parameters[0], -): Record | undefined { - const tokensByMcpServerName = args.headers - ? extractMcpOauthTokensFromHeaders(args.headers) - : undefined; - - return injectOauthTokens({ - configs: args.toolConfigs as Record | undefined, - tokensByMcpServerName, - }); -} - -// eslint-disable-next-line import/prefer-default-export -export function createAiProvider(config: AiConfiguration): AiProviderDefinition { - return { - providers: [{ name: config.name, provider: config.provider, model: config.model }], - init(logger) { - const router = new Router({ aiConfigurations: [config], logger }); - - return { - // Cast is safe: AiRouter.route accepts any string, but Router validates - // it at runtime via Zod against the allowed literal union (RouterRouteArgs). - route: args => - router.route({ - route: args.route, - body: args.body, - query: args.query, - toolConfigs: resolveMcpConfigs(args), - } as RouterRouteArgs), - }; - }, - }; -} diff --git a/packages/ai-proxy/src/index.ts b/packages/ai-proxy/src/index.ts index 197797caa1..788b2c0cde 100644 --- a/packages/ai-proxy/src/index.ts +++ b/packages/ai-proxy/src/index.ts @@ -3,7 +3,6 @@ import type { Logger } from '@forestadmin/datasource-toolkit'; import ToolSourceChecker from './tool-source-checker'; -export { createAiProvider } from './create-ai-provider'; export { createBaseChatModel } from './create-base-chat-model'; export { default as ProviderDispatcher } from './provider-dispatcher'; diff --git a/packages/ai-proxy/test/create-ai-provider.test.ts b/packages/ai-proxy/test/create-ai-provider.test.ts deleted file mode 100644 index b845e99814..0000000000 --- a/packages/ai-proxy/test/create-ai-provider.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import type { AiConfiguration } from '../src/provider'; - -import { createAiProvider } from '../src/create-ai-provider'; -import { Router } from '../src/router'; - -jest.mock('../src/router'); - -const routeMock = jest.fn(); -jest.mocked(Router).mockImplementation(() => ({ route: routeMock } as any)); - -describe('createAiProvider', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - const config: AiConfiguration = { - name: 'my-ai', - provider: 'openai', - model: 'gpt-4o', - apiKey: 'test-key', - }; - - test('should return providers array from config', () => { - const result = createAiProvider(config); - - expect(result.providers).toEqual([{ name: 'my-ai', provider: 'openai', model: 'gpt-4o' }]); - }); - - test('init should create a Router with the config and logger', () => { - const provider = createAiProvider(config); - const mockLogger = jest.fn(); - provider.init(mockLogger); - - expect(Router).toHaveBeenCalledWith({ - aiConfigurations: [config], - logger: mockLogger, - }); - }); - - describe('route wrapper', () => { - test('should pass route, body, query to underlying Router', async () => { - routeMock.mockResolvedValue({ result: 'ok' }); - const provider = createAiProvider(config); - const aiRouter = provider.init(jest.fn()); - - const result = await aiRouter.route({ - route: 'ai-query', - body: { messages: [] }, - query: { 'ai-name': 'my-ai' }, - }); - - expect(routeMock).toHaveBeenCalledWith( - expect.objectContaining({ - route: 'ai-query', - body: { messages: [] }, - query: { 'ai-name': 'my-ai' }, - toolConfigs: undefined, - }), - ); - expect(result).toEqual({ result: 'ok' }); - }); - - test('should pass toolConfigs to router', async () => { - routeMock.mockResolvedValue({}); - const provider = createAiProvider(config); - const aiRouter = provider.init(jest.fn()); - - await aiRouter.route({ - route: 'remote-tools', - toolConfigs: { server1: { command: 'test', args: [] } }, - }); - - expect(routeMock).toHaveBeenCalledWith( - expect.objectContaining({ - toolConfigs: { server1: { command: 'test', args: [] } }, - }), - ); - }); - - test('should inject OAuth tokens before creating tool providers', async () => { - routeMock.mockResolvedValue({}); - const provider = createAiProvider(config); - const aiRouter = provider.init(jest.fn()); - const oauthTokens = JSON.stringify({ server1: 'Bearer token123' }); - - await aiRouter.route({ - route: 'remote-tools', - toolConfigs: { server1: { type: 'http', url: 'https://server1.com' } }, - headers: { 'x-mcp-oauth-tokens': oauthTokens }, - }); - - expect(routeMock).toHaveBeenCalledWith( - expect.objectContaining({ - toolConfigs: { - server1: { - type: 'http', - url: 'https://server1.com', - headers: { Authorization: 'Bearer token123' }, - }, - }, - }), - ); - }); - - test('should throw AIBadRequestError when x-mcp-oauth-tokens header contains invalid JSON', () => { - const provider = createAiProvider(config); - const aiRouter = provider.init(jest.fn()); - - expect(() => - aiRouter.route({ - route: 'remote-tools', - toolConfigs: { server1: { type: 'http', url: 'https://server1.com' } }, - headers: { 'x-mcp-oauth-tokens': '{ invalid json }' }, - }), - ).toThrow('Invalid JSON in x-mcp-oauth-tokens header'); - }); - - test('should pass empty tool providers when no toolConfigs provided', async () => { - routeMock.mockResolvedValue({}); - const provider = createAiProvider(config); - const aiRouter = provider.init(jest.fn()); - - await aiRouter.route({ route: 'remote-tools' }); - - expect(routeMock).toHaveBeenCalledWith( - expect.objectContaining({ toolConfigs: undefined }), - ); - }); - }); -}); diff --git a/packages/forestadmin-client/src/schema/types.ts b/packages/forestadmin-client/src/schema/types.ts index 5307fe7a45..8ae26ea5dd 100644 --- a/packages/forestadmin-client/src/schema/types.ts +++ b/packages/forestadmin-client/src/schema/types.ts @@ -6,7 +6,6 @@ export type ForestSchema = { liana: string; liana_version: string; liana_features: Record | null; - ai_llms?: Array<{ name: string; provider: string; model: string }> | null; stack: { engine: string; engine_version: string; diff --git a/packages/forestadmin-client/test/forest-admin-client-with-cache.test.ts b/packages/forestadmin-client/test/forest-admin-client-with-cache.test.ts index 993592013a..cd8a3e5d5b 100644 --- a/packages/forestadmin-client/test/forest-admin-client-with-cache.test.ts +++ b/packages/forestadmin-client/test/forest-admin-client-with-cache.test.ts @@ -66,7 +66,6 @@ describe('ForestAdminClientWithCache', () => { liana: 'forest-nodejs-agent', liana_version: '1.0.0', liana_features: null, - ai_llms: null, stack: { engine: 'nodejs', engine_version: '16.0.0' }, }, });