From 5d96ba185be5ea49b276b013e48fb34d1d295ac4 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sat, 7 Feb 2026 11:54:14 +0100 Subject: [PATCH 01/18] 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 --- packages/_example/src/forest/agent.ts | 9 +- packages/agent/package.json | 7 +- packages/agent/src/agent.ts | 38 ++-- packages/agent/src/index.ts | 1 + packages/agent/src/routes/ai/ai-proxy.ts | 44 ++--- packages/agent/src/routes/index.ts | 14 +- packages/agent/src/types.ts | 5 +- .../src/utils/forest-schema/generator.ts | 8 +- packages/agent/test/agent.test.ts | 64 +++---- .../agent/test/routes/ai/ai-proxy.test.ts | 180 +++++++----------- packages/agent/test/routes/index.test.ts | 50 +---- .../utils/forest-schema/generator.test.ts | 12 +- packages/ai-proxy/src/create-ai-provider.ts | 14 ++ packages/ai-proxy/src/index.ts | 1 + packages/ai-proxy/src/router.ts | 36 +++- .../ai-proxy/test/create-ai-provider.test.ts | 62 ++++++ packages/datasource-toolkit/src/index.ts | 1 + .../datasource-toolkit/src/interfaces/ai.ts | 17 ++ 18 files changed, 289 insertions(+), 274 deletions(-) create mode 100644 packages/ai-proxy/src/create-ai-provider.ts create mode 100644 packages/ai-proxy/test/create-ai-provider.test.ts create mode 100644 packages/datasource-toolkit/src/interfaces/ai.ts diff --git a/packages/_example/src/forest/agent.ts b/packages/_example/src/forest/agent.ts index e3130780ee..a64852843d 100644 --- a/packages/_example/src/forest/agent.ts +++ b/packages/_example/src/forest/agent.ts @@ -2,6 +2,7 @@ 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'; @@ -94,5 +95,11 @@ export default function makeAgent() { .customizeCollection('post', customizePost) .customizeCollection('comment', customizeComment) .customizeCollection('review', customizeReview) - .customizeCollection('sales', customizeSales); + .customizeCollection('sales', customizeSales) + .addAi(createAiProvider({ + model: 'gpt-4o', + provider: 'openai', + name: 'test', + apiKey: process.env.OPENAI_API_KEY, + })); } diff --git a/packages/agent/package.json b/packages/agent/package.json index 8e9d40e4a5..b9e5f680d3 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -13,7 +13,6 @@ }, "dependencies": { "@fast-csv/format": "^4.3.5", - "@forestadmin/ai-proxy": "1.4.1", "@forestadmin/datasource-customizer": "1.67.3", "@forestadmin/datasource-toolkit": "1.50.1", "@forestadmin/forestadmin-client": "1.37.10", @@ -72,11 +71,15 @@ "@paralleldrive/cuid2": "2.2.2" }, "peerDependencies": { - "@fastify/express": "^1.1.0 || ^2.0.0 || ^3.0.0 || ^4.0.0" + "@fastify/express": "^1.1.0 || ^2.0.0 || ^3.0.0 || ^4.0.0", + "@forestadmin/ai-proxy": ">=1.5.0" }, "peerDependenciesMeta": { "@fastify/express": { "optional": true + }, + "@forestadmin/ai-proxy": { + "optional": true } } } diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 6a611aeb0d..616780a775 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -3,7 +3,6 @@ import type { ForestAdminHttpDriverServices } from './services'; import type { AgentOptions, AgentOptionsWithDefaults, - AiConfiguration, HttpCallback, } from './types'; import type { @@ -14,7 +13,7 @@ import type { TCollectionName, TSchema, } from '@forestadmin/datasource-customizer'; -import type { DataSource, DataSourceFactory } from '@forestadmin/datasource-toolkit'; +import type { AiProviderDefinition, DataSource, DataSourceFactory } from '@forestadmin/datasource-toolkit'; import type { ForestSchema } from '@forestadmin/forestadmin-client'; import { DataSourceCustomizer } from '@forestadmin/datasource-customizer'; @@ -47,7 +46,7 @@ export default class Agent extends FrameworkMounter protected nocodeCustomizer: DataSourceCustomizer; protected customizationService: CustomizationService; protected schemaGenerator: SchemaGenerator; - protected aiConfigurations: AiConfiguration[] = []; + protected aiProvider: AiProviderDefinition | null = null; /** Whether MCP server should be mounted */ private mcpEnabled = false; @@ -222,42 +221,36 @@ export default class Agent extends FrameworkMounter * 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. * - * @param configuration - The AI provider configuration - * @param configuration.name - A unique name to identify this AI configuration - * @param configuration.provider - The AI provider to use ('openai') - * @param configuration.apiKey - Your API key for the chosen provider - * @param configuration.model - The model to use (e.g., 'gpt-4o') + * @param provider - An AI provider definition created by a factory (e.g., createAiProvider) * @returns The agent instance for chaining * @throws Error if addAi is called more than once * * @example - * agent.addAi({ + * import { createAiProvider } from '@forestadmin/ai-proxy'; + * + * agent.addAi(createAiProvider({ * name: 'assistant', * provider: 'openai', * apiKey: process.env.OPENAI_API_KEY, * model: 'gpt-4o', - * }); + * })); */ - addAi(configuration: AiConfiguration): this { - if (this.aiConfigurations.length > 0) { + addAi(provider: AiProviderDefinition): this { + if (this.aiProvider) { throw new Error( 'addAi can only be called once. Multiple AI configurations are not supported yet.', ); } - this.options.logger( - 'Warn', - `AI configuration added with model '${configuration.model}'. ` + - 'Make sure to test Forest Admin AI features thoroughly to ensure compatibility.', - ); - - this.aiConfigurations.push(configuration); + this.aiProvider = provider; return this; } protected getRoutes(dataSource: DataSource, services: ForestAdminHttpDriverServices) { - return makeRoutes(dataSource, this.options, services, this.aiConfigurations); + const aiRouter = this.aiProvider?.init(this.options.logger) ?? null; + + return makeRoutes(dataSource, this.options, services, aiRouter); } /** @@ -380,9 +373,12 @@ export default class Agent extends FrameworkMounter let schema: Pick; // Get the AI configurations for schema metadata + const aiMeta = this.aiProvider + ? [{ name: this.aiProvider.name, provider: this.aiProvider.provider }] + : []; const { meta } = SchemaGenerator.buildMetadata( this.customizationService.buildFeatures(), - this.aiConfigurations, + aiMeta, ); // When using experimental no-code features even in production we need to build a new schema diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 49ade7e5dd..dafa1613c5 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -9,6 +9,7 @@ export function createAgent(options: AgentOptions): export { Agent }; export { AgentOptions } from './types'; +export type { AiProviderDefinition } from './types'; export * from '@forestadmin/datasource-customizer'; // export is necessary for the agent-generator package diff --git a/packages/agent/src/routes/ai/ai-proxy.ts b/packages/agent/src/routes/ai/ai-proxy.ts index c36b308c89..b871ffa86b 100644 --- a/packages/agent/src/routes/ai/ai-proxy.ts +++ b/packages/agent/src/routes/ai/ai-proxy.ts @@ -1,17 +1,9 @@ import type { ForestAdminHttpDriverServices } from '../../services'; -import type { AgentOptionsWithDefaults, AiConfiguration } from '../../types'; +import type { AgentOptionsWithDefaults } from '../../types'; +import type { AiRouter } from '@forestadmin/datasource-toolkit'; import type KoaRouter from '@koa/router'; import type { Context } from 'koa'; -import { - AIBadRequestError, - AIError, - AINotConfiguredError, - AINotFoundError, - Router as AiProxyRouter, - extractMcpOauthTokensFromHeaders, - injectOauthTokens, -} from '@forestadmin/ai-proxy'; import { BadRequestError, NotFoundError, @@ -23,18 +15,15 @@ import BaseRoute from '../base-route'; export default class AiProxyRoute extends BaseRoute { readonly type = RouteType.PrivateRoute; - private readonly aiProxyRouter: AiProxyRouter; + private readonly aiRouter: AiRouter; constructor( services: ForestAdminHttpDriverServices, options: AgentOptionsWithDefaults, - aiConfigurations: AiConfiguration[], + aiRouter: AiRouter, ) { super(services, options); - this.aiProxyRouter = new AiProxyRouter({ - aiConfigurations, - logger: this.options.logger, - }); + this.aiRouter = aiRouter; } setupRoutes(router: KoaRouter): void { @@ -43,29 +32,26 @@ export default class AiProxyRoute extends BaseRoute { private async handleAiProxy(context: Context): Promise { try { - const tokensByMcpServerName = extractMcpOauthTokensFromHeaders(context.request.headers); - - const mcpConfigs = + const mcpServerConfigs = await this.options.forestAdminClient.mcpServerConfigService.getConfiguration(); - context.response.body = await this.aiProxyRouter.route({ + context.response.body = await this.aiRouter.route({ route: context.params.route, body: context.request.body, query: context.query, - mcpConfigs: injectOauthTokens({ mcpConfigs, tokensByMcpServerName }), + mcpServerConfigs, + requestHeaders: context.request.headers, }); context.response.status = HttpCode.Ok; } catch (error) { - if (error instanceof AIError) { - this.options.logger('Error', `AI proxy error: ${error.message}`, error); + const err = error as Error & { status?: number }; - if (error instanceof AINotConfiguredError) { - throw new UnprocessableError('AI is not configured. Please call addAi() on your agent.'); - } + if (typeof err.status === 'number' && err.status >= 400 && err.status < 600) { + this.options.logger('Error', `AI proxy error: ${err.message}`, err); - if (error instanceof AIBadRequestError) throw new BadRequestError(error.message); - if (error instanceof AINotFoundError) throw new NotFoundError(error.message); - throw new UnprocessableError(error.message); + if (err.status === 400) throw new BadRequestError(err.message); + if (err.status === 404) throw new NotFoundError(err.message); + throw new UnprocessableError(err.message); } throw error; diff --git a/packages/agent/src/routes/index.ts b/packages/agent/src/routes/index.ts index cf9d4ab6d6..1a19c1ce88 100644 --- a/packages/agent/src/routes/index.ts +++ b/packages/agent/src/routes/index.ts @@ -1,7 +1,7 @@ import type { ForestAdminHttpDriverServices as Services } from '../services'; -import type { AiConfiguration, AgentOptionsWithDefaults as Options } from '../types'; +import type { AgentOptionsWithDefaults as Options } from '../types'; import type BaseRoute from './base-route'; -import type { DataSource } from '@forestadmin/datasource-toolkit'; +import type { AiRouter, DataSource } from '@forestadmin/datasource-toolkit'; import CollectionApiChartRoute from './access/api-chart-collection'; import DataSourceApiChartRoute from './access/api-chart-datasource'; @@ -168,18 +168,18 @@ function getActionRoutes( function getAiRoutes( options: Options, services: Services, - aiConfigurations: AiConfiguration[], + aiRouter: AiRouter | null, ): BaseRoute[] { - if (aiConfigurations.length === 0) return []; + if (!aiRouter) return []; - return [new AiProxyRoute(services, options, aiConfigurations)]; + return [new AiProxyRoute(services, options, aiRouter)]; } export default function makeRoutes( dataSource: DataSource, options: Options, services: Services, - aiConfigurations: AiConfiguration[] = [], + aiRouter: AiRouter | null = null, ): BaseRoute[] { const routes = [ ...getRootRoutes(options, services), @@ -189,7 +189,7 @@ export default function makeRoutes( ...getApiChartRoutes(dataSource, options, services), ...getRelatedRoutes(dataSource, options, services), ...getActionRoutes(dataSource, options, services), - ...getAiRoutes(options, services, aiConfigurations), + ...getAiRoutes(options, services, aiRouter), ]; // Ensure routes and middlewares are loaded in the right order. diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index ec3c823b29..b6977b3fe3 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -1,9 +1,8 @@ -import type { AiConfiguration, AiProvider } from '@forestadmin/ai-proxy'; -import type { CompositeId, Logger, LoggerLevel } from '@forestadmin/datasource-toolkit'; +import type { AiProviderDefinition, CompositeId, Logger, LoggerLevel } from '@forestadmin/datasource-toolkit'; import type { ForestAdminClient } from '@forestadmin/forestadmin-client'; import type { IncomingMessage, ServerResponse } from 'http'; -export type { AiConfiguration, AiProvider }; +export type { AiProviderDefinition }; /** Options to configure behavior of an agent's forestadmin driver */ export type AgentOptions = { diff --git a/packages/agent/src/utils/forest-schema/generator.ts b/packages/agent/src/utils/forest-schema/generator.ts index 28047768e9..c3855424bc 100644 --- a/packages/agent/src/utils/forest-schema/generator.ts +++ b/packages/agent/src/utils/forest-schema/generator.ts @@ -1,4 +1,4 @@ -import type { AgentOptionsWithDefaults, AiConfiguration } from '../../types'; +import type { AgentOptionsWithDefaults } from '../../types'; import type { DataSource } from '@forestadmin/datasource-toolkit'; import type { ForestSchema } from '@forestadmin/forestadmin-client'; @@ -23,7 +23,7 @@ export default class SchemaGenerator { static buildMetadata( features: Record | null, - aiConfigurations: AiConfiguration[] = [], + aiProviders: Array<{ name: string; provider: string }> = [], ): Pick { const { version } = require('../../../package.json'); // eslint-disable-line @typescript-eslint/no-var-requires,global-require @@ -33,8 +33,8 @@ export default class SchemaGenerator { liana_version: version, liana_features: features, ai_llms: - aiConfigurations.length > 0 - ? aiConfigurations.map(c => ({ name: c.name, provider: c.provider })) + aiProviders.length > 0 + ? aiProviders.map(c => ({ name: c.name, provider: c.provider })) : null, stack: { engine: 'nodejs', diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 5874f26866..c289cbe933 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -1,7 +1,7 @@ /* eslint-disable max-classes-per-file */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { DataSourceFactory } from '@forestadmin/datasource-toolkit'; +import type { AiProviderDefinition, DataSourceFactory } from '@forestadmin/datasource-toolkit'; import { DataSourceCustomizer } from '@forestadmin/datasource-customizer'; import * as McpServer from '@forestadmin/mcp-server'; @@ -34,6 +34,17 @@ beforeEach(() => { .mockResolvedValue(factories.dataSource.build()); }); +function createMockAiProvider( + overrides: Partial = {}, +): AiProviderDefinition { + return { + name: 'gpt4o', + provider: 'openai', + init: jest.fn().mockReturnValue({ route: jest.fn() }), + ...overrides, + }; +} + describe('Agent', () => { describe('Development', () => { const options = factories.forestAdminHttpDriverOptions.build({ @@ -407,14 +418,10 @@ describe('Agent', () => { forestAdminClient: factories.forestAdminClient.build({ postSchema: mockPostSchema }), }); - test('should store the AI configuration', () => { + test('should store the AI provider and return agent for chaining', () => { const agent = new Agent(options); - const result = agent.addAi({ - name: 'gpt4o', - provider: 'openai', - apiKey: 'test-key', - model: 'gpt-4o', - }); + const provider = createMockAiProvider(); + const result = agent.addAi(provider); expect(result).toBe(agent); }); @@ -422,50 +429,29 @@ describe('Agent', () => { test('should throw an error when addAi is called more than once', () => { const agent = new Agent(options); - agent.addAi({ - name: 'gpt4o', - provider: 'openai', - apiKey: 'test-key', - model: 'gpt-4o', - }); + agent.addAi(createMockAiProvider({ name: 'gpt4o' })); - expect(() => - agent.addAi({ - name: 'gpt4o-mini', - provider: 'openai', - apiKey: 'another-key', - model: 'gpt-4o-mini', - }), - ).toThrow('addAi can only be called once. Multiple AI configurations are not supported yet.'); + expect(() => agent.addAi(createMockAiProvider({ name: 'gpt4o-mini' }))).toThrow( + 'addAi can only be called once. Multiple AI configurations are not supported yet.', + ); }); - test('should throw an error on start when model does not support tools', async () => { - // Use the real makeRoutes to trigger validation in AiProxyRouter + 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); - agent.addAi({ - name: 'gpt4-base', - provider: 'openai', - apiKey: 'test-key', - model: 'gpt-4', - }); + await agent.start(); - await expect(agent.start()).rejects.toThrow( - "Model 'gpt-4' does not support tools. Please use a model that supports function calling.", - ); + 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({ - name: 'gpt4o', - provider: 'openai', - apiKey: 'test-key', - model: 'gpt-4o', - }); + agent.addAi(createMockAiProvider({ name: 'gpt4o', provider: 'openai' })); await agent.start(); diff --git a/packages/agent/test/routes/ai/ai-proxy.test.ts b/packages/agent/test/routes/ai/ai-proxy.test.ts index fd4eb83793..808c15132f 100644 --- a/packages/agent/test/routes/ai/ai-proxy.test.ts +++ b/packages/agent/test/routes/ai/ai-proxy.test.ts @@ -1,12 +1,5 @@ -// eslint-disable-next-line import/no-extraneous-dependencies -import { - AIBadRequestError, - AIError, - AINotConfiguredError, - AINotFoundError, - AIToolNotFoundError, - AIUnprocessableError, -} from '@forestadmin/ai-proxy'; +import type { AiRouter } from '@forestadmin/datasource-toolkit'; + import { BadRequestError, NotFoundError, @@ -18,39 +11,23 @@ import AiProxyRoute from '../../../src/routes/ai/ai-proxy'; import { HttpCode, RouteType } from '../../../src/types'; import * as factories from '../../__factories__'; -const mockRoute = jest.fn(); - -jest.mock('@forestadmin/ai-proxy', () => { - const actual = jest.requireActual('@forestadmin/ai-proxy'); - - return { - ...actual, - Router: jest.fn().mockImplementation(() => ({ - route: mockRoute, - })), - }; -}); - describe('AiProxyRoute', () => { const options = factories.forestAdminHttpDriverOptions.build(); const services = factories.forestAdminHttpDriverServices.build(); const router = factories.router.mockAllMethods().build(); - const aiConfigurations = [ - { - name: 'gpt4', - provider: 'openai' as const, - apiKey: 'test-key', - model: 'gpt-4o', - }, - ]; + + 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, aiConfigurations); + const route = new AiProxyRoute(services, options, aiRouter); expect(route.type).toBe(RouteType.PrivateRoute); }); @@ -58,7 +35,7 @@ describe('AiProxyRoute', () => { describe('setupRoutes', () => { test('should register POST route at /_internal/ai-proxy/:route', () => { - const route = new AiProxyRoute(services, options, aiConfigurations); + const route = new AiProxyRoute(services, options, aiRouter); route.setupRoutes(router); expect(router.post).toHaveBeenCalledWith('/_internal/ai-proxy/:route', expect.any(Function)); @@ -67,7 +44,7 @@ describe('AiProxyRoute', () => { describe('handleAiProxy', () => { test('should return 200 with response body on successful request', async () => { - const route = new AiProxyRoute(services, options, aiConfigurations); + const route = new AiProxyRoute(services, options, aiRouter); const expectedResponse = { result: 'success' }; mockRoute.mockResolvedValueOnce(expectedResponse); @@ -85,8 +62,8 @@ describe('AiProxyRoute', () => { expect(context.response.body).toEqual(expectedResponse); }); - test('should pass route, body, query, mcpConfigs and tokensByMcpServerName to router', async () => { - const route = new AiProxyRoute(services, options, aiConfigurations); + test('should pass route, body, query, mcpServerConfigs and requestHeaders to router', async () => { + const route = new AiProxyRoute(services, options, aiRouter); mockRoute.mockResolvedValueOnce({}); const context = createMockContext({ @@ -95,7 +72,6 @@ describe('AiProxyRoute', () => { }, requestBody: { messages: [{ role: 'user', content: 'Hello' }] }, }); - // Set query directly on context as createMockContext doesn't handle it properly context.query = { 'ai-name': 'gpt4' }; await (route as any).handleAiProxy(context); @@ -104,31 +80,29 @@ describe('AiProxyRoute', () => { route: 'ai-query', body: { messages: [{ role: 'user', content: 'Hello' }] }, query: { 'ai-name': 'gpt4' }, - mcpConfigs: undefined, // mcpServerConfigService.getConfiguration returns undefined in test + mcpServerConfigs: undefined, + requestHeaders: context.request.headers, }); }); - test('should inject oauth tokens into mcpConfigs when header is provided', async () => { - const route = new AiProxyRoute(services, options, aiConfigurations); + test('should pass mcpServerConfigs from forestAdminClient to router', async () => { + const route = new AiProxyRoute(services, options, aiRouter); mockRoute.mockResolvedValueOnce({}); const mcpConfigs = { configs: { server1: { type: 'http' as const, url: 'https://server1.com' }, - server2: { type: 'http' as const, url: 'https://server2.com' }, }, }; jest .spyOn(options.forestAdminClient.mcpServerConfigService, 'getConfiguration') .mockResolvedValueOnce(mcpConfigs); - const tokens = { server1: 'Bearer token1', server2: 'Bearer token2' }; const context = createMockContext({ customProperties: { params: { route: 'ai-query' }, }, requestBody: { messages: [] }, - headers: { 'x-mcp-oauth-tokens': JSON.stringify(tokens) }, }); context.query = {}; @@ -136,46 +110,18 @@ describe('AiProxyRoute', () => { expect(mockRoute).toHaveBeenCalledWith( expect.objectContaining({ - mcpConfigs: { - configs: { - server1: { - type: 'http', - url: 'https://server1.com', - headers: { Authorization: 'Bearer token1' }, - }, - server2: { - type: 'http', - url: 'https://server2.com', - headers: { Authorization: 'Bearer token2' }, - }, - }, - }, + mcpServerConfigs: mcpConfigs, + requestHeaders: context.request.headers, }), ); }); - test('should throw BadRequestError when x-mcp-oauth-tokens header contains invalid JSON', async () => { - const route = new AiProxyRoute(services, options, aiConfigurations); - - const context = createMockContext({ - customProperties: { - params: { route: 'ai-query' }, - }, - requestBody: { messages: [] }, - headers: { 'x-mcp-oauth-tokens': '{ invalid json }' }, - }); - context.query = {}; - - await expect((route as any).handleAiProxy(context)).rejects.toThrow(BadRequestError); - await expect((route as any).handleAiProxy(context)).rejects.toThrow( - 'Invalid JSON in x-mcp-oauth-tokens header', - ); - }); - describe('error handling', () => { - test('should convert AINotConfiguredError to UnprocessableError with agent-specific message', async () => { - const route = new AiProxyRoute(services, options, aiConfigurations); - mockRoute.mockRejectedValueOnce(new AINotConfiguredError()); + test('should convert error with status 422 to UnprocessableError with original message', async () => { + const route = new AiProxyRoute(services, options, aiRouter); + const error = new Error('AI is not configured') as Error & { status: number }; + error.status = 422; + mockRoute.mockRejectedValueOnce(error); const context = createMockContext({ customProperties: { @@ -187,28 +133,15 @@ describe('AiProxyRoute', () => { await expect((route as any).handleAiProxy(context)).rejects.toMatchObject({ name: 'UnprocessableError', - message: 'AI is not configured. Please call addAi() on your agent.', + message: 'AI is not configured', }); }); - test('should convert AIToolNotFoundError to NotFoundError', async () => { - const route = new AiProxyRoute(services, options, aiConfigurations); - mockRoute.mockRejectedValueOnce(new AIToolNotFoundError('tool-name')); - - const context = createMockContext({ - customProperties: { - params: { route: 'invoke-remote-tool' }, - query: { 'tool-name': 'unknown-tool' }, - }, - requestBody: {}, - }); - - await expect((route as any).handleAiProxy(context)).rejects.toThrow(NotFoundError); - }); - - test('should convert AINotFoundError to NotFoundError', async () => { - const route = new AiProxyRoute(services, options, aiConfigurations); - mockRoute.mockRejectedValueOnce(new AINotFoundError('Resource not found')); + test('should convert error with status 404 to NotFoundError', async () => { + const route = new AiProxyRoute(services, options, aiRouter); + const error = new Error('Resource not found') as Error & { status: number }; + error.status = 404; + mockRoute.mockRejectedValueOnce(error); const context = createMockContext({ customProperties: { @@ -221,9 +154,11 @@ describe('AiProxyRoute', () => { await expect((route as any).handleAiProxy(context)).rejects.toThrow(NotFoundError); }); - test('should convert AIBadRequestError to BadRequestError', async () => { - const route = new AiProxyRoute(services, options, aiConfigurations); - mockRoute.mockRejectedValueOnce(new AIBadRequestError('Invalid input')); + test('should convert error with status 400 to BadRequestError', async () => { + const route = new AiProxyRoute(services, options, aiRouter); + const error = new Error('Invalid input') as Error & { status: number }; + error.status = 400; + mockRoute.mockRejectedValueOnce(error); const context = createMockContext({ customProperties: { @@ -236,9 +171,11 @@ describe('AiProxyRoute', () => { await expect((route as any).handleAiProxy(context)).rejects.toThrow(BadRequestError); }); - test('should convert AIUnprocessableError to UnprocessableError', async () => { - const route = new AiProxyRoute(services, options, aiConfigurations); - mockRoute.mockRejectedValueOnce(new AIUnprocessableError('Invalid input')); + test('should convert error with other 4xx/5xx status to UnprocessableError', async () => { + const route = new AiProxyRoute(services, options, aiRouter); + const error = new Error('Server error') as Error & { status: number }; + error.status = 500; + mockRoute.mockRejectedValueOnce(error); const context = createMockContext({ customProperties: { @@ -251,38 +188,51 @@ describe('AiProxyRoute', () => { await expect((route as any).handleAiProxy(context)).rejects.toThrow(UnprocessableError); }); - test('should convert generic AIError to UnprocessableError', async () => { - const route = new AiProxyRoute(services, options, aiConfigurations); - mockRoute.mockRejectedValueOnce(new AIError('Generic AI error')); + test('should re-throw unknown errors unchanged', async () => { + const route = new AiProxyRoute(services, options, aiRouter); + const unknownError = new Error('Unknown error'); + mockRoute.mockRejectedValueOnce(unknownError); const context = createMockContext({ customProperties: { params: { route: 'ai-query' }, - query: {}, }, requestBody: {}, }); + context.query = {}; - await expect((route as any).handleAiProxy(context)).rejects.toThrow(UnprocessableError); + const promise = (route as any).handleAiProxy(context); + + await expect(promise).rejects.toBe(unknownError); + expect(unknownError).not.toBeInstanceOf(UnprocessableError); }); - test('should re-throw unknown errors unchanged', async () => { - const route = new AiProxyRoute(services, options, aiConfigurations); - const unknownError = new Error('Unknown error'); - mockRoute.mockRejectedValueOnce(unknownError); + test('should log AI proxy errors before converting them', async () => { + const mockLogger = jest.fn(); + const optionsWithLogger = factories.forestAdminHttpDriverOptions.build({ + logger: mockLogger, + }); + const route = new AiProxyRoute(services, optionsWithLogger, aiRouter); + + const error = new Error('Some AI error') as Error & { status: number }; + error.status = 422; + mockRoute.mockRejectedValueOnce(error); const context = createMockContext({ customProperties: { params: { route: 'ai-query' }, + query: {}, }, requestBody: {}, }); - context.query = {}; - const promise = (route as any).handleAiProxy(context); + await expect((route as any).handleAiProxy(context)).rejects.toThrow(UnprocessableError); - await expect(promise).rejects.toBe(unknownError); - expect(unknownError).not.toBeInstanceOf(UnprocessableError); + expect(mockLogger).toHaveBeenCalledWith( + 'Error', + 'AI proxy error: Some AI error', + expect.any(Error), + ); }); }); }); diff --git a/packages/agent/test/routes/index.test.ts b/packages/agent/test/routes/index.test.ts index d2dc94aac8..d2e56b8ddb 100644 --- a/packages/agent/test/routes/index.test.ts +++ b/packages/agent/test/routes/index.test.ts @@ -300,8 +300,8 @@ describe('Route index', () => { }); }); - describe('with AI configurations', () => { - test('should not include AI routes when aiConfigurations is empty', () => { + describe('with AI router', () => { + test('should not include AI routes when aiRouter is null', () => { const dataSource = factories.dataSource.buildWithCollections([ factories.collection.build({ name: 'books' }), ]); @@ -310,69 +310,31 @@ describe('Route index', () => { dataSource, factories.forestAdminHttpDriverOptions.build(), factories.forestAdminHttpDriverServices.build(), - [], + null, ); const aiRoute = routes.find(route => route instanceof AiProxyRoute); expect(aiRoute).toBeUndefined(); }); - test('should include AiProxyRoute when AI configurations are provided', () => { + test('should include AiProxyRoute when an AI router is provided', () => { const dataSource = factories.dataSource.buildWithCollections([ factories.collection.build({ name: 'books' }), ]); - const aiConfigurations = [ - { - name: 'gpt4', - provider: 'openai' as const, - apiKey: 'test-key', - model: 'gpt-4o', - }, - ]; + const aiRouter = { route: jest.fn() }; const routes = makeRoutes( dataSource, factories.forestAdminHttpDriverOptions.build(), factories.forestAdminHttpDriverServices.build(), - aiConfigurations, + aiRouter, ); const aiRoute = routes.find(route => route instanceof AiProxyRoute); expect(aiRoute).toBeTruthy(); expect(aiRoute).toBeInstanceOf(AiProxyRoute); }); - - test('should include only one AiProxyRoute even with multiple AI configurations', () => { - const dataSource = factories.dataSource.buildWithCollections([ - factories.collection.build({ name: 'books' }), - ]); - - const aiConfigurations = [ - { - name: 'gpt4', - provider: 'openai' as const, - apiKey: 'test-key', - model: 'gpt-4o', - }, - { - name: 'gpt3', - provider: 'openai' as const, - apiKey: 'test-key-2', - model: 'gpt-3.5-turbo', - }, - ]; - - const routes = makeRoutes( - dataSource, - factories.forestAdminHttpDriverOptions.build(), - factories.forestAdminHttpDriverServices.build(), - aiConfigurations, - ); - - const aiRoutes = routes.filter(route => route instanceof AiProxyRoute); - expect(aiRoutes).toHaveLength(1); - }); }); }); }); diff --git a/packages/agent/test/utils/forest-schema/generator.test.ts b/packages/agent/test/utils/forest-schema/generator.test.ts index 4183b60473..333c56ae4f 100644 --- a/packages/agent/test/utils/forest-schema/generator.test.ts +++ b/packages/agent/test/utils/forest-schema/generator.test.ts @@ -72,19 +72,19 @@ describe('SchemaGenerator', () => { }); }); - test('it should serialize ai_llms when AI configurations are provided', async () => { - const aiConfigurations = [ - { name: 'gpt4', provider: 'openai' as const, apiKey: 'key1', model: 'gpt-4o' }, - { name: 'claude', provider: 'openai' as const, apiKey: 'key2', model: 'claude-3' }, + test('it should serialize ai_llms when AI providers are provided', async () => { + const aiProviders = [ + { name: 'gpt4', provider: 'openai' }, + { name: 'claude', provider: 'anthropic' }, ]; - const schema = await SchemaGenerator.buildMetadata(null, aiConfigurations); + const schema = await SchemaGenerator.buildMetadata(null, aiProviders); expect(schema).toStrictEqual({ meta: { ai_llms: [ { name: 'gpt4', provider: 'openai' }, - { name: 'claude', provider: 'openai' }, + { name: 'claude', provider: 'anthropic' }, ], liana: 'forest-nodejs-agent', liana_version: expect.any(String), diff --git a/packages/ai-proxy/src/create-ai-provider.ts b/packages/ai-proxy/src/create-ai-provider.ts new file mode 100644 index 0000000000..d1389bcca0 --- /dev/null +++ b/packages/ai-proxy/src/create-ai-provider.ts @@ -0,0 +1,14 @@ +import type { AiProviderDefinition } from '@forestadmin/datasource-toolkit'; +import type { AiConfiguration } from './provider'; + +import { Router } from './router'; + +export function createAiProvider(config: AiConfiguration): AiProviderDefinition { + return { + name: config.name, + provider: config.provider, + init(logger) { + return new Router({ aiConfigurations: [config], logger }); + }, + }; +} diff --git a/packages/ai-proxy/src/index.ts b/packages/ai-proxy/src/index.ts index bee805b44d..dfa50e46ed 100644 --- a/packages/ai-proxy/src/index.ts +++ b/packages/ai-proxy/src/index.ts @@ -2,6 +2,7 @@ import type { McpConfiguration } from './mcp-client'; import McpConfigChecker from './mcp-config-checker'; +export { createAiProvider } from './create-ai-provider'; export * from './provider-dispatcher'; export * from './remote-tools'; export * from './router'; diff --git a/packages/ai-proxy/src/router.ts b/packages/ai-proxy/src/router.ts index 5812df10b8..f4051ba273 100644 --- a/packages/ai-proxy/src/router.ts +++ b/packages/ai-proxy/src/router.ts @@ -7,6 +7,7 @@ import type { z } from 'zod'; import { AIBadRequestError, AIModelNotSupportedError } from './errors'; import McpClient from './mcp-client'; +import { extractMcpOauthTokensFromHeaders, injectOauthTokens } from './oauth-token-injector'; import { ProviderDispatcher } from './provider-dispatcher'; import { RemoteTools } from './remote-tools'; import { routeArgsSchema } from './schemas/route'; @@ -59,7 +60,13 @@ export class Router { * - invoke-remote-tool: Execute a remote tool by name with the provided inputs * - remote-tools: Return the list of available remote tools definitions */ - async route(args: RouteArgs & { mcpConfigs?: McpConfiguration }) { + async route( + args: RouteArgs & { + mcpConfigs?: McpConfiguration; + mcpServerConfigs?: unknown; + requestHeaders?: Record; + }, + ) { // Validate input with Zod schema const result = routeArgsSchema.safeParse(args); @@ -71,8 +78,10 @@ export class Router { let mcpClient: McpClient | undefined; try { - if (args.mcpConfigs) { - mcpClient = new McpClient(args.mcpConfigs, this.logger); + const resolvedMcpConfigs = this.resolveMcpConfigs(args); + + if (resolvedMcpConfigs) { + mcpClient = new McpClient(resolvedMcpConfigs, this.logger); } const remoteTools = new RemoteTools( @@ -142,6 +151,27 @@ export class Router { .join('; '); } + private resolveMcpConfigs(args: { + mcpConfigs?: McpConfiguration; + mcpServerConfigs?: unknown; + requestHeaders?: Record; + }): McpConfiguration | undefined { + // Backward compat: if mcpConfigs is already provided, use it directly + if (args.mcpConfigs) return args.mcpConfigs; + + // New path: mcpServerConfigs + requestHeaders → extract tokens and inject + if (args.mcpServerConfigs) { + const mcpConfigs = args.mcpServerConfigs as McpConfiguration; + const tokensByMcpServerName = args.requestHeaders + ? extractMcpOauthTokensFromHeaders(args.requestHeaders) + : undefined; + + return injectOauthTokens({ mcpConfigs, tokensByMcpServerName }); + } + + return undefined; + } + private getAiConfiguration(aiName?: string): AiConfiguration | null { if (this.aiConfigurations.length === 0) return null; diff --git a/packages/ai-proxy/test/create-ai-provider.test.ts b/packages/ai-proxy/test/create-ai-provider.test.ts new file mode 100644 index 0000000000..7fe28d1ed4 --- /dev/null +++ b/packages/ai-proxy/test/create-ai-provider.test.ts @@ -0,0 +1,62 @@ +import type { AiConfiguration } from '../src/provider'; + +import { createAiProvider } from '../src/create-ai-provider'; +import { Router } from '../src/router'; + +jest.mock('../src/router'); + +describe('createAiProvider', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should return an AiProviderDefinition with name and provider from config', () => { + const config: AiConfiguration = { + name: 'my-ai', + provider: 'openai', + model: 'gpt-4o', + apiKey: 'test-key', + }; + + const result = createAiProvider(config); + + expect(result.name).toBe('my-ai'); + expect(result.provider).toBe('openai'); + expect(typeof result.init).toBe('function'); + }); + + test('init should create a Router with the config and logger', () => { + const config: AiConfiguration = { + name: 'my-ai', + provider: 'openai', + model: 'gpt-4o', + apiKey: 'test-key', + }; + + const provider = createAiProvider(config); + const mockLogger = jest.fn(); + provider.init(mockLogger); + + expect(Router).toHaveBeenCalledWith({ + aiConfigurations: [config], + logger: mockLogger, + }); + }); + + test('init should return the Router instance', () => { + const config: AiConfiguration = { + name: 'my-ai', + provider: 'openai', + model: 'gpt-4o', + apiKey: 'test-key', + }; + + const mockRouterInstance = { route: jest.fn() }; + jest.mocked(Router).mockImplementation(() => mockRouterInstance as any); + + const provider = createAiProvider(config); + const result = provider.init(jest.fn()); + + expect(result).toBe(mockRouterInstance); + }); +}); diff --git a/packages/datasource-toolkit/src/index.ts b/packages/datasource-toolkit/src/index.ts index 5a60731a29..50da913bea 100644 --- a/packages/datasource-toolkit/src/index.ts +++ b/packages/datasource-toolkit/src/index.ts @@ -1,6 +1,7 @@ // Misc export * from './errors'; export * from './factory'; +export type { AiProviderDefinition, AiRouter } from './interfaces/ai'; export { MAP_ALLOWED_OPERATORS_FOR_COLUMN_TYPE as allowedOperatorsForColumnType } from './validation/rules'; // Base Collection & DataSource diff --git a/packages/datasource-toolkit/src/interfaces/ai.ts b/packages/datasource-toolkit/src/interfaces/ai.ts new file mode 100644 index 0000000000..6eed359b19 --- /dev/null +++ b/packages/datasource-toolkit/src/interfaces/ai.ts @@ -0,0 +1,17 @@ +import type { Logger } from '../factory'; + +export interface AiRouter { + route(args: { + route: string; + body?: unknown; + query?: Record; + mcpServerConfigs?: unknown; + requestHeaders?: Record; + }): Promise; +} + +export interface AiProviderDefinition { + name: string; + provider: string; + init(logger: Logger): AiRouter; +} From 4e68eff5c5abe0e951a96bbc3df3f9ef186ffb10 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sat, 7 Feb 2026 12:04:34 +0100 Subject: [PATCH 02/18] refactor(agent): use providers array in AiProviderDefinition for future multi-provider support Change AiProviderDefinition interface from single name/provider to providers: Array<{name, provider}> so that createAiProvider can later accept variadic configs without breaking changes. Co-Authored-By: Claude Opus 4.6 --- packages/agent/src/agent.ts | 4 +--- packages/agent/test/agent.test.ts | 13 ++++++++----- packages/ai-proxy/src/create-ai-provider.ts | 3 +-- packages/ai-proxy/test/create-ai-provider.test.ts | 5 ++--- packages/datasource-toolkit/src/interfaces/ai.ts | 3 +-- 5 files changed, 13 insertions(+), 15 deletions(-) diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 616780a775..31de0ed1ed 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -373,9 +373,7 @@ export default class Agent extends FrameworkMounter let schema: Pick; // Get the AI configurations for schema metadata - const aiMeta = this.aiProvider - ? [{ name: this.aiProvider.name, provider: this.aiProvider.provider }] - : []; + const aiMeta = this.aiProvider?.providers ?? []; const { meta } = SchemaGenerator.buildMetadata( this.customizationService.buildFeatures(), aiMeta, diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index c289cbe933..23720782c7 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -38,8 +38,7 @@ function createMockAiProvider( overrides: Partial = {}, ): AiProviderDefinition { return { - name: 'gpt4o', - provider: 'openai', + providers: [{ name: 'gpt4o', provider: 'openai' }], init: jest.fn().mockReturnValue({ route: jest.fn() }), ...overrides, }; @@ -429,9 +428,13 @@ describe('Agent', () => { test('should throw an error when addAi is called more than once', () => { const agent = new Agent(options); - agent.addAi(createMockAiProvider({ name: 'gpt4o' })); + agent.addAi(createMockAiProvider({ providers: [{ name: 'gpt4o', provider: 'openai' }] })); - expect(() => agent.addAi(createMockAiProvider({ name: 'gpt4o-mini' }))).toThrow( + expect(() => + agent.addAi( + createMockAiProvider({ providers: [{ name: 'gpt4o-mini', provider: 'openai' }] }), + ), + ).toThrow( 'addAi can only be called once. Multiple AI configurations are not supported yet.', ); }); @@ -451,7 +454,7 @@ describe('Agent', () => { test('should include ai_llms in schema meta when AI is configured', async () => { const agent = new Agent(options); - agent.addAi(createMockAiProvider({ name: 'gpt4o', provider: 'openai' })); + agent.addAi(createMockAiProvider({ providers: [{ name: 'gpt4o', provider: 'openai' }] })); await agent.start(); diff --git a/packages/ai-proxy/src/create-ai-provider.ts b/packages/ai-proxy/src/create-ai-provider.ts index d1389bcca0..d3c23ab53b 100644 --- a/packages/ai-proxy/src/create-ai-provider.ts +++ b/packages/ai-proxy/src/create-ai-provider.ts @@ -5,8 +5,7 @@ import { Router } from './router'; export function createAiProvider(config: AiConfiguration): AiProviderDefinition { return { - name: config.name, - provider: config.provider, + providers: [{ name: config.name, provider: config.provider }], init(logger) { return new Router({ aiConfigurations: [config], logger }); }, diff --git a/packages/ai-proxy/test/create-ai-provider.test.ts b/packages/ai-proxy/test/create-ai-provider.test.ts index 7fe28d1ed4..34b2a9ca16 100644 --- a/packages/ai-proxy/test/create-ai-provider.test.ts +++ b/packages/ai-proxy/test/create-ai-provider.test.ts @@ -10,7 +10,7 @@ describe('createAiProvider', () => { jest.clearAllMocks(); }); - test('should return an AiProviderDefinition with name and provider from config', () => { + test('should return an AiProviderDefinition with providers array from config', () => { const config: AiConfiguration = { name: 'my-ai', provider: 'openai', @@ -20,8 +20,7 @@ describe('createAiProvider', () => { const result = createAiProvider(config); - expect(result.name).toBe('my-ai'); - expect(result.provider).toBe('openai'); + expect(result.providers).toEqual([{ name: 'my-ai', provider: 'openai' }]); expect(typeof result.init).toBe('function'); }); diff --git a/packages/datasource-toolkit/src/interfaces/ai.ts b/packages/datasource-toolkit/src/interfaces/ai.ts index 6eed359b19..0bb7390b48 100644 --- a/packages/datasource-toolkit/src/interfaces/ai.ts +++ b/packages/datasource-toolkit/src/interfaces/ai.ts @@ -11,7 +11,6 @@ export interface AiRouter { } export interface AiProviderDefinition { - name: string; - provider: string; + providers: Array<{ name: string; provider: string }>; init(logger: Logger): AiRouter; } From c2dc25fc53a61f9544beb7583050acc53fef15ec Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sat, 7 Feb 2026 12:05:24 +0100 Subject: [PATCH 03/18] docs(agent): document @forestadmin/ai-proxy install requirement in addAi Co-Authored-By: Claude Opus 4.6 --- packages/agent/src/agent.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 31de0ed1ed..1dd11f6ec5 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -221,7 +221,12 @@ export default class Agent extends FrameworkMounter * 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. * - * @param provider - An AI provider definition created by a factory (e.g., createAiProvider) + * Requires the `@forestadmin/ai-proxy` package to be installed: + * ```bash + * npm install @forestadmin/ai-proxy + * ``` + * + * @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 * From 218abb57b0d401bddbc4fb517c333e8c639bf7c4 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sat, 7 Feb 2026 12:26:11 +0100 Subject: [PATCH 04/18] fix(ai-proxy): address PR review findings - Remove redundant .map() identity transform in schema generator - Add runtime validation for mcpServerConfigs before unsafe cast - Add JSDoc documenting AiRouter error contract - Fix lint/prettier formatting issues - Add tests for resolveMcpConfigs path (OAuth injection, backward compat, invalid input) Co-Authored-By: Claude Opus 4.6 --- packages/agent/src/agent.ts | 12 +-- packages/agent/src/routes/index.ts | 6 +- packages/agent/src/types.ts | 7 +- .../src/utils/forest-schema/generator.ts | 5 +- packages/agent/test/agent.test.ts | 8 +- packages/ai-proxy/src/create-ai-provider.ts | 3 +- packages/ai-proxy/src/router.ts | 8 ++ packages/ai-proxy/test/router.test.ts | 91 +++++++++++++++++++ .../datasource-toolkit/src/interfaces/ai.ts | 7 ++ 9 files changed, 124 insertions(+), 23 deletions(-) diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 1dd11f6ec5..bea967a9b8 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -1,10 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { ForestAdminHttpDriverServices } from './services'; -import type { - AgentOptions, - AgentOptionsWithDefaults, - HttpCallback, -} from './types'; +import type { AgentOptions, AgentOptionsWithDefaults, HttpCallback } from './types'; import type { CollectionCustomizer, DataSourceChartDefinition, @@ -13,7 +9,11 @@ import type { TCollectionName, TSchema, } from '@forestadmin/datasource-customizer'; -import type { AiProviderDefinition, DataSource, DataSourceFactory } from '@forestadmin/datasource-toolkit'; +import type { + AiProviderDefinition, + DataSource, + DataSourceFactory, +} from '@forestadmin/datasource-toolkit'; import type { ForestSchema } from '@forestadmin/forestadmin-client'; import { DataSourceCustomizer } from '@forestadmin/datasource-customizer'; diff --git a/packages/agent/src/routes/index.ts b/packages/agent/src/routes/index.ts index 1a19c1ce88..8673b45d6a 100644 --- a/packages/agent/src/routes/index.ts +++ b/packages/agent/src/routes/index.ts @@ -165,11 +165,7 @@ function getActionRoutes( return routes; } -function getAiRoutes( - options: Options, - services: Services, - aiRouter: AiRouter | null, -): BaseRoute[] { +function getAiRoutes(options: Options, services: Services, aiRouter: AiRouter | null): BaseRoute[] { if (!aiRouter) return []; return [new AiProxyRoute(services, options, aiRouter)]; diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index b6977b3fe3..86d3355726 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -1,4 +1,9 @@ -import type { AiProviderDefinition, CompositeId, Logger, LoggerLevel } from '@forestadmin/datasource-toolkit'; +import type { + AiProviderDefinition, + CompositeId, + Logger, + LoggerLevel, +} from '@forestadmin/datasource-toolkit'; import type { ForestAdminClient } from '@forestadmin/forestadmin-client'; import type { IncomingMessage, ServerResponse } from 'http'; diff --git a/packages/agent/src/utils/forest-schema/generator.ts b/packages/agent/src/utils/forest-schema/generator.ts index c3855424bc..7d32dde6e5 100644 --- a/packages/agent/src/utils/forest-schema/generator.ts +++ b/packages/agent/src/utils/forest-schema/generator.ts @@ -32,10 +32,7 @@ export default class SchemaGenerator { liana: 'forest-nodejs-agent', liana_version: version, liana_features: features, - ai_llms: - aiProviders.length > 0 - ? aiProviders.map(c => ({ name: c.name, provider: c.provider })) - : null, + ai_llms: aiProviders.length > 0 ? aiProviders : 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 23720782c7..761b843784 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -34,9 +34,7 @@ beforeEach(() => { .mockResolvedValue(factories.dataSource.build()); }); -function createMockAiProvider( - overrides: Partial = {}, -): AiProviderDefinition { +function createMockAiProvider(overrides: Partial = {}): AiProviderDefinition { return { providers: [{ name: 'gpt4o', provider: 'openai' }], init: jest.fn().mockReturnValue({ route: jest.fn() }), @@ -434,9 +432,7 @@ describe('Agent', () => { agent.addAi( createMockAiProvider({ providers: [{ name: 'gpt4o-mini', provider: 'openai' }] }), ), - ).toThrow( - 'addAi can only be called once. Multiple AI configurations are not supported yet.', - ); + ).toThrow('addAi can only be called once. Multiple AI configurations are not supported yet.'); }); test('should call init with logger on start to create AI router', async () => { diff --git a/packages/ai-proxy/src/create-ai-provider.ts b/packages/ai-proxy/src/create-ai-provider.ts index d3c23ab53b..f53abed66f 100644 --- a/packages/ai-proxy/src/create-ai-provider.ts +++ b/packages/ai-proxy/src/create-ai-provider.ts @@ -1,8 +1,9 @@ -import type { AiProviderDefinition } from '@forestadmin/datasource-toolkit'; import type { AiConfiguration } from './provider'; +import type { AiProviderDefinition } from '@forestadmin/datasource-toolkit'; import { Router } from './router'; +// eslint-disable-next-line import/prefer-default-export export function createAiProvider(config: AiConfiguration): AiProviderDefinition { return { providers: [{ name: config.name, provider: config.provider }], diff --git a/packages/ai-proxy/src/router.ts b/packages/ai-proxy/src/router.ts index f4051ba273..f4252d19cf 100644 --- a/packages/ai-proxy/src/router.ts +++ b/packages/ai-proxy/src/router.ts @@ -161,6 +161,14 @@ export class Router { // New path: mcpServerConfigs + requestHeaders → extract tokens and inject if (args.mcpServerConfigs) { + if ( + typeof args.mcpServerConfigs !== 'object' || + args.mcpServerConfigs === null || + !('configs' in args.mcpServerConfigs) + ) { + throw new AIBadRequestError('Invalid MCP server configuration: missing "configs" property'); + } + const mcpConfigs = args.mcpServerConfigs as McpConfiguration; const tokensByMcpServerName = args.requestHeaders ? extractMcpOauthTokensFromHeaders(args.requestHeaders) diff --git a/packages/ai-proxy/test/router.test.ts b/packages/ai-proxy/test/router.test.ts index 82abb1dbf1..a0cd683019 100644 --- a/packages/ai-proxy/test/router.test.ts +++ b/packages/ai-proxy/test/router.test.ts @@ -387,6 +387,97 @@ describe('route', () => { }); }); + describe('resolveMcpConfigs (mcpServerConfigs path)', () => { + it('creates McpClient from mcpServerConfigs when provided', async () => { + const router = new Router({}); + + await router.route({ + route: 'remote-tools', + mcpServerConfigs: { configs: { server1: { command: 'test', args: [] } } }, + }); + + expect(MockedMcpClient).toHaveBeenCalledWith( + { configs: { server1: { command: 'test', args: [] } } }, + undefined, + ); + }); + + it('injects OAuth tokens from requestHeaders into mcpServerConfigs', async () => { + const router = new Router({}); + const oauthTokens = JSON.stringify({ server1: 'Bearer token123' }); + + await router.route({ + route: 'remote-tools', + mcpServerConfigs: { + configs: { server1: { type: 'http', url: 'https://server1.com' } }, + }, + requestHeaders: { 'x-mcp-oauth-tokens': oauthTokens }, + }); + + expect(MockedMcpClient).toHaveBeenCalledWith( + { + configs: { + server1: { + type: 'http', + url: 'https://server1.com', + headers: { Authorization: 'Bearer token123' }, + }, + }, + }, + undefined, + ); + }); + + it('uses mcpServerConfigs without token injection when requestHeaders is absent', async () => { + const router = new Router({}); + + await router.route({ + route: 'remote-tools', + mcpServerConfigs: { configs: { server1: { command: 'test', args: [] } } }, + }); + + expect(MockedMcpClient).toHaveBeenCalledWith( + { configs: { server1: { command: 'test', args: [] } } }, + undefined, + ); + }); + + it('prefers mcpConfigs over mcpServerConfigs for backward compatibility', async () => { + const router = new Router({}); + const directMcpConfigs = { configs: { direct: { command: 'direct', args: [] } } }; + + await router.route({ + route: 'remote-tools', + mcpConfigs: directMcpConfigs, + mcpServerConfigs: { configs: { server: { command: 'server', args: [] } } }, + }); + + expect(MockedMcpClient).toHaveBeenCalledWith(directMcpConfigs, undefined); + }); + + it('throws AIBadRequestError when mcpServerConfigs has invalid shape', async () => { + const router = new Router({}); + + await expect( + router.route({ + route: 'remote-tools', + mcpServerConfigs: 'invalid', + }), + ).rejects.toThrow('Invalid MCP server configuration: missing "configs" property'); + }); + + it('throws AIBadRequestError when mcpServerConfigs is missing configs property', async () => { + const router = new Router({}); + + await expect( + router.route({ + route: 'remote-tools', + mcpServerConfigs: { notConfigs: {} }, + }), + ).rejects.toThrow('Invalid MCP server configuration: missing "configs" property'); + }); + }); + describe('Model validation', () => { it('throws AIModelNotSupportedError when model does not support tools', () => { expect( diff --git a/packages/datasource-toolkit/src/interfaces/ai.ts b/packages/datasource-toolkit/src/interfaces/ai.ts index 0bb7390b48..39e6c141cf 100644 --- a/packages/datasource-toolkit/src/interfaces/ai.ts +++ b/packages/datasource-toolkit/src/interfaces/ai.ts @@ -1,6 +1,13 @@ import type { Logger } from '../factory'; export interface AiRouter { + /** + * Route a request to the AI proxy. + * + * Implementations should throw errors with a numeric `status` property (e.g. 400, 404, 422) + * for HTTP-status-based error translation. Errors without a `status` property + * are treated as unexpected internal errors and re-thrown as-is. + */ route(args: { route: string; body?: unknown; From b2e9e760865215b82232253964bbd94a4ec6c0b4 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sat, 7 Feb 2026 12:42:34 +0100 Subject: [PATCH 05/18] refactor(datasource-toolkit): extract AiProviderMeta named type Replace inline `{ name: string; provider: string }` with a named `AiProviderMeta` interface in datasource-toolkit. Use it consistently in AiProviderDefinition, SchemaGenerator, and ForestSchema. Co-Authored-By: Claude Opus 4.6 --- packages/agent/src/utils/forest-schema/generator.ts | 4 ++-- packages/datasource-toolkit/src/index.ts | 2 +- packages/datasource-toolkit/src/interfaces/ai.ts | 8 +++++++- packages/forestadmin-client/src/schema/types.ts | 4 ++-- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/agent/src/utils/forest-schema/generator.ts b/packages/agent/src/utils/forest-schema/generator.ts index 7d32dde6e5..2b6ea478ee 100644 --- a/packages/agent/src/utils/forest-schema/generator.ts +++ b/packages/agent/src/utils/forest-schema/generator.ts @@ -1,5 +1,5 @@ import type { AgentOptionsWithDefaults } from '../../types'; -import type { DataSource } from '@forestadmin/datasource-toolkit'; +import type { AiProviderMeta, DataSource } from '@forestadmin/datasource-toolkit'; import type { ForestSchema } from '@forestadmin/forestadmin-client'; import SchemaGeneratorCollection from './generator-collection'; @@ -23,7 +23,7 @@ export default class SchemaGenerator { static buildMetadata( features: Record | null, - aiProviders: Array<{ name: string; provider: string }> = [], + aiProviders: AiProviderMeta[] = [], ): Pick { const { version } = require('../../../package.json'); // eslint-disable-line @typescript-eslint/no-var-requires,global-require diff --git a/packages/datasource-toolkit/src/index.ts b/packages/datasource-toolkit/src/index.ts index 50da913bea..b00d534e01 100644 --- a/packages/datasource-toolkit/src/index.ts +++ b/packages/datasource-toolkit/src/index.ts @@ -1,7 +1,7 @@ // Misc export * from './errors'; export * from './factory'; -export type { AiProviderDefinition, AiRouter } from './interfaces/ai'; +export type { AiProviderDefinition, AiProviderMeta, AiRouter } from './interfaces/ai'; export { MAP_ALLOWED_OPERATORS_FOR_COLUMN_TYPE as allowedOperatorsForColumnType } from './validation/rules'; // Base Collection & DataSource diff --git a/packages/datasource-toolkit/src/interfaces/ai.ts b/packages/datasource-toolkit/src/interfaces/ai.ts index 39e6c141cf..3698effaa4 100644 --- a/packages/datasource-toolkit/src/interfaces/ai.ts +++ b/packages/datasource-toolkit/src/interfaces/ai.ts @@ -1,5 +1,11 @@ import type { Logger } from '../factory'; +/** Metadata describing a configured AI provider, used in schema reporting. */ +export interface AiProviderMeta { + name: string; + provider: string; +} + export interface AiRouter { /** * Route a request to the AI proxy. @@ -18,6 +24,6 @@ export interface AiRouter { } export interface AiProviderDefinition { - providers: Array<{ name: string; provider: string }>; + providers: AiProviderMeta[]; init(logger: Logger): AiRouter; } diff --git a/packages/forestadmin-client/src/schema/types.ts b/packages/forestadmin-client/src/schema/types.ts index e86d2b296c..9d967b00cb 100644 --- a/packages/forestadmin-client/src/schema/types.ts +++ b/packages/forestadmin-client/src/schema/types.ts @@ -1,4 +1,4 @@ -import type { PrimitiveTypes } from '@forestadmin/datasource-toolkit'; +import type { AiProviderMeta, PrimitiveTypes } from '@forestadmin/datasource-toolkit'; export type ForestSchema = { collections: ForestServerCollection[]; @@ -6,7 +6,7 @@ export type ForestSchema = { liana: string; liana_version: string; liana_features: Record | null; - ai_llms?: Array<{ provider: string }> | null; + ai_llms?: AiProviderMeta[] | null; stack: { engine: string; engine_version: string; From b77d9afbd890f7e93576062bd0d456545b79f220 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sat, 7 Feb 2026 12:56:33 +0100 Subject: [PATCH 06/18] refactor(ai-proxy): make AIError extend BusinessError for native error handling AIError and subclasses now extend BusinessError from datasource-toolkit (BadRequestError, NotFoundError, UnprocessableError). The agent's error middleware handles HTTP status mapping natively, removing the need for duck-typed status checks and error re-wrapping in the route handler. Co-Authored-By: Claude Opus 4.6 --- packages/agent/src/routes/ai/ai-proxy.ts | 42 ++---- .../agent/test/routes/ai/ai-proxy.test.ts | 131 ++---------------- packages/ai-proxy/src/errors.ts | 52 +++---- .../datasource-toolkit/src/interfaces/ai.ts | 5 +- 4 files changed, 52 insertions(+), 178 deletions(-) diff --git a/packages/agent/src/routes/ai/ai-proxy.ts b/packages/agent/src/routes/ai/ai-proxy.ts index b871ffa86b..71844c6b9b 100644 --- a/packages/agent/src/routes/ai/ai-proxy.ts +++ b/packages/agent/src/routes/ai/ai-proxy.ts @@ -4,12 +4,6 @@ import type { AiRouter } from '@forestadmin/datasource-toolkit'; import type KoaRouter from '@koa/router'; import type { Context } from 'koa'; -import { - BadRequestError, - NotFoundError, - UnprocessableError, -} from '@forestadmin/datasource-toolkit'; - import { HttpCode, RouteType } from '../../types'; import BaseRoute from '../base-route'; @@ -31,30 +25,16 @@ export default class AiProxyRoute extends BaseRoute { } private async handleAiProxy(context: Context): Promise { - try { - const mcpServerConfigs = - await this.options.forestAdminClient.mcpServerConfigService.getConfiguration(); - - context.response.body = await this.aiRouter.route({ - route: context.params.route, - body: context.request.body, - query: context.query, - mcpServerConfigs, - requestHeaders: context.request.headers, - }); - context.response.status = HttpCode.Ok; - } catch (error) { - const err = error as Error & { status?: number }; - - if (typeof err.status === 'number' && err.status >= 400 && err.status < 600) { - this.options.logger('Error', `AI proxy error: ${err.message}`, err); - - if (err.status === 400) throw new BadRequestError(err.message); - if (err.status === 404) throw new NotFoundError(err.message); - throw new UnprocessableError(err.message); - } - - throw error; - } + const mcpServerConfigs = + await this.options.forestAdminClient.mcpServerConfigService.getConfiguration(); + + context.response.body = await this.aiRouter.route({ + route: context.params.route, + body: context.request.body, + query: context.query, + mcpServerConfigs, + requestHeaders: context.request.headers, + }); + context.response.status = HttpCode.Ok; } } diff --git a/packages/agent/test/routes/ai/ai-proxy.test.ts b/packages/agent/test/routes/ai/ai-proxy.test.ts index 808c15132f..53b9ac8fc6 100644 --- a/packages/agent/test/routes/ai/ai-proxy.test.ts +++ b/packages/agent/test/routes/ai/ai-proxy.test.ts @@ -1,10 +1,5 @@ import type { AiRouter } from '@forestadmin/datasource-toolkit'; -import { - BadRequestError, - NotFoundError, - UnprocessableError, -} from '@forestadmin/datasource-toolkit'; import { createMockContext } from '@shopify/jest-koa-mocks'; import AiProxyRoute from '../../../src/routes/ai/ai-proxy'; @@ -116,124 +111,20 @@ describe('AiProxyRoute', () => { ); }); - describe('error handling', () => { - test('should convert error with status 422 to UnprocessableError with original message', async () => { - const route = new AiProxyRoute(services, options, aiRouter); - const error = new Error('AI is not configured') as Error & { status: number }; - error.status = 422; - mockRoute.mockRejectedValueOnce(error); - - const context = createMockContext({ - customProperties: { - params: { route: 'ai-query' }, - query: {}, - }, - requestBody: {}, - }); - - await expect((route as any).handleAiProxy(context)).rejects.toMatchObject({ - name: 'UnprocessableError', - message: 'AI is not configured', - }); - }); - - test('should convert error with status 404 to NotFoundError', async () => { - const route = new AiProxyRoute(services, options, aiRouter); - const error = new Error('Resource not found') as Error & { status: number }; - error.status = 404; - mockRoute.mockRejectedValueOnce(error); - - const context = createMockContext({ - customProperties: { - params: { route: 'ai-query' }, - query: {}, - }, - requestBody: {}, - }); - - await expect((route as any).handleAiProxy(context)).rejects.toThrow(NotFoundError); - }); - - test('should convert error with status 400 to BadRequestError', async () => { - const route = new AiProxyRoute(services, options, aiRouter); - const error = new Error('Invalid input') as Error & { status: number }; - error.status = 400; - mockRoute.mockRejectedValueOnce(error); - - const context = createMockContext({ - customProperties: { - params: { route: 'ai-query' }, - query: {}, - }, - requestBody: {}, - }); - - await expect((route as any).handleAiProxy(context)).rejects.toThrow(BadRequestError); - }); - - test('should convert error with other 4xx/5xx status to UnprocessableError', async () => { - const route = new AiProxyRoute(services, options, aiRouter); - const error = new Error('Server error') as Error & { status: number }; - error.status = 500; - mockRoute.mockRejectedValueOnce(error); - - const context = createMockContext({ - customProperties: { - params: { route: 'ai-query' }, - query: {}, - }, - requestBody: {}, - }); - - await expect((route as any).handleAiProxy(context)).rejects.toThrow(UnprocessableError); - }); - - test('should re-throw unknown errors unchanged', async () => { - const route = new AiProxyRoute(services, options, aiRouter); - const unknownError = new Error('Unknown error'); - mockRoute.mockRejectedValueOnce(unknownError); - - const context = createMockContext({ - customProperties: { - params: { route: 'ai-query' }, - }, - requestBody: {}, - }); - context.query = {}; - - const promise = (route as any).handleAiProxy(context); + 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); - await expect(promise).rejects.toBe(unknownError); - expect(unknownError).not.toBeInstanceOf(UnprocessableError); + const context = createMockContext({ + customProperties: { + params: { route: 'ai-query' }, + query: {}, + }, + requestBody: {}, }); - test('should log AI proxy errors before converting them', async () => { - const mockLogger = jest.fn(); - const optionsWithLogger = factories.forestAdminHttpDriverOptions.build({ - logger: mockLogger, - }); - const route = new AiProxyRoute(services, optionsWithLogger, aiRouter); - - const error = new Error('Some AI error') as Error & { status: number }; - error.status = 422; - mockRoute.mockRejectedValueOnce(error); - - const context = createMockContext({ - customProperties: { - params: { route: 'ai-query' }, - query: {}, - }, - requestBody: {}, - }); - - await expect((route as any).handleAiProxy(context)).rejects.toThrow(UnprocessableError); - - expect(mockLogger).toHaveBeenCalledWith( - 'Error', - 'AI proxy error: Some AI error', - expect.any(Error), - ); - }); + await expect((route as any).handleAiProxy(context)).rejects.toBe(error); }); }); }); diff --git a/packages/ai-proxy/src/errors.ts b/packages/ai-proxy/src/errors.ts index fc1e5e2507..92e35d3a5a 100644 --- a/packages/ai-proxy/src/errors.ts +++ b/packages/ai-proxy/src/errors.ts @@ -1,33 +1,37 @@ /** - * ------------------------------------- - * ------------------------------------- - * ------------------------------------- - * All custom errors must extend the AIError class. - * This inheritance is crucial for proper error translation - * and consistent handling throughout the system. - * ------------------------------------- - * ------------------------------------- - * ------------------------------------- + * All custom AI errors extend BusinessError subclasses from datasource-toolkit. + * Each error maps to its natural HTTP status via the agent's error middleware. + * + * Hierarchy: + * - AIError (extends UnprocessableError → 422) + * - AINotConfiguredError + * - McpError + * - McpConnectionError, McpConflictError, McpConfigError + * - AIBadRequestError (extends BadRequestError → 400) + * - AIModelNotSupportedError + * - AINotFoundError (extends NotFoundError → 404) + * - AIToolNotFoundError + * - AIUnprocessableError (extends UnprocessableError → 422) + * - OpenAIUnprocessableError, AIToolUnprocessableError */ // eslint-disable-next-line max-classes-per-file -export class AIError extends Error { - readonly status: number; - - constructor(message: string, status = 422) { - if (status < 100 || status > 599) { - throw new RangeError(`Invalid HTTP status code: ${status}`); - } +import { + BadRequestError, + NotFoundError, + UnprocessableError, +} from '@forestadmin/datasource-toolkit'; +export class AIError extends UnprocessableError { + constructor(message: string) { super(message); this.name = 'AIError'; - this.status = status; } } -export class AIBadRequestError extends AIError { +export class AIBadRequestError extends BadRequestError { constructor(message: string) { - super(message, 400); + super(message); this.name = 'AIBadRequestError'; } } @@ -41,23 +45,23 @@ export class AIModelNotSupportedError extends AIBadRequestError { } } -export class AINotFoundError extends AIError { +export class AINotFoundError extends NotFoundError { constructor(message: string) { - super(message, 404); + super(message); this.name = 'AINotFoundError'; } } -export class AIUnprocessableError extends AIError { +export class AIUnprocessableError extends UnprocessableError { constructor(message: string) { - super(message, 422); + super(message); this.name = 'AIUnprocessableError'; } } export class AINotConfiguredError extends AIError { constructor(message = 'AI is not configured') { - super(message, 422); + super(message); this.name = 'AINotConfiguredError'; } } diff --git a/packages/datasource-toolkit/src/interfaces/ai.ts b/packages/datasource-toolkit/src/interfaces/ai.ts index 3698effaa4..e9d1624bfb 100644 --- a/packages/datasource-toolkit/src/interfaces/ai.ts +++ b/packages/datasource-toolkit/src/interfaces/ai.ts @@ -10,9 +10,8 @@ export interface AiRouter { /** * Route a request to the AI proxy. * - * Implementations should throw errors with a numeric `status` property (e.g. 400, 404, 422) - * for HTTP-status-based error translation. Errors without a `status` property - * are treated as unexpected internal errors and re-thrown as-is. + * Implementations should throw BusinessError subclasses (BadRequestError, NotFoundError, + * UnprocessableError) for proper HTTP status mapping by the agent's error middleware. */ route(args: { route: string; From 31f33b1a45b4e203146cb3d9db9f9e5518795d09 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sat, 7 Feb 2026 13:46:03 +0100 Subject: [PATCH 07/18] feat(agent): log warning when AI configuration is added via addAi() Co-Authored-By: Claude Opus 4.6 --- packages/agent/src/agent.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index bea967a9b8..681b616984 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -249,6 +249,13 @@ export default class Agent extends FrameworkMounter this.aiProvider = provider; + const providerNames = provider.providers.map(p => `'${p.name}' (${p.provider})`).join(', '); + this.options.logger( + 'Warn', + `AI configuration added: ${providerNames}. ` + + 'Make sure to test Forest Admin AI features thoroughly to ensure compatibility.', + ); + return this; } From 985262e3e304b9926ff70871380859a41537fe37 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sat, 7 Feb 2026 13:54:26 +0100 Subject: [PATCH 08/18] refactor(datasource-toolkit): add model to AiProviderMeta Include the model name in AiProviderMeta alongside name and provider, so the schema metadata and agent logs accurately reflect the full AI configuration (provider, model, name). Co-Authored-By: Claude Opus 4.6 --- packages/agent/src/agent.ts | 13 ++++++------ packages/agent/test/agent.test.ts | 20 ++++++++++++++----- .../utils/forest-schema/generator.test.ts | 8 ++++---- packages/ai-proxy/src/create-ai-provider.ts | 2 +- .../ai-proxy/test/create-ai-provider.test.ts | 2 +- .../datasource-toolkit/src/interfaces/ai.ts | 3 ++- 6 files changed, 30 insertions(+), 18 deletions(-) diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 681b616984..5dd455f691 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -249,12 +249,13 @@ export default class Agent extends FrameworkMounter this.aiProvider = provider; - const providerNames = provider.providers.map(p => `'${p.name}' (${p.provider})`).join(', '); - this.options.logger( - 'Warn', - `AI configuration added: ${providerNames}. ` + - 'Make sure to test Forest Admin AI features thoroughly to ensure compatibility.', - ); + 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; } diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 761b843784..d8cb8c58ec 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -36,7 +36,7 @@ beforeEach(() => { function createMockAiProvider(overrides: Partial = {}): AiProviderDefinition { return { - providers: [{ name: 'gpt4o', provider: 'openai' }], + providers: [{ name: 'gpt4o', provider: 'openai', model: 'gpt-4o' }], init: jest.fn().mockReturnValue({ route: jest.fn() }), ...overrides, }; @@ -426,11 +426,17 @@ describe('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' }] })); + agent.addAi( + createMockAiProvider({ + providers: [{ name: 'gpt4o', provider: 'openai', model: 'gpt-4o' }], + }), + ); expect(() => agent.addAi( - createMockAiProvider({ providers: [{ name: 'gpt4o-mini', provider: 'openai' }] }), + 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.'); }); @@ -450,14 +456,18 @@ describe('Agent', () => { 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' }] })); + 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' }], + ai_llms: [{ name: 'gpt4o', provider: 'openai', model: 'gpt-4o' }], }), }), ); diff --git a/packages/agent/test/utils/forest-schema/generator.test.ts b/packages/agent/test/utils/forest-schema/generator.test.ts index 333c56ae4f..31c33c57d7 100644 --- a/packages/agent/test/utils/forest-schema/generator.test.ts +++ b/packages/agent/test/utils/forest-schema/generator.test.ts @@ -74,8 +74,8 @@ describe('SchemaGenerator', () => { test('it should serialize ai_llms when AI providers are provided', async () => { const aiProviders = [ - { name: 'gpt4', provider: 'openai' }, - { name: 'claude', provider: 'anthropic' }, + { name: 'gpt4', provider: 'openai', model: 'gpt-4o' }, + { name: 'claude', provider: 'anthropic', model: 'claude-sonnet-4-5-20250929' }, ]; const schema = await SchemaGenerator.buildMetadata(null, aiProviders); @@ -83,8 +83,8 @@ describe('SchemaGenerator', () => { expect(schema).toStrictEqual({ meta: { ai_llms: [ - { name: 'gpt4', provider: 'openai' }, - { name: 'claude', provider: 'anthropic' }, + { 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), diff --git a/packages/ai-proxy/src/create-ai-provider.ts b/packages/ai-proxy/src/create-ai-provider.ts index f53abed66f..232af332e3 100644 --- a/packages/ai-proxy/src/create-ai-provider.ts +++ b/packages/ai-proxy/src/create-ai-provider.ts @@ -6,7 +6,7 @@ import { Router } from './router'; // eslint-disable-next-line import/prefer-default-export export function createAiProvider(config: AiConfiguration): AiProviderDefinition { return { - providers: [{ name: config.name, provider: config.provider }], + providers: [{ name: config.name, provider: config.provider, model: config.model }], init(logger) { return new Router({ aiConfigurations: [config], logger }); }, diff --git a/packages/ai-proxy/test/create-ai-provider.test.ts b/packages/ai-proxy/test/create-ai-provider.test.ts index 34b2a9ca16..a77388025b 100644 --- a/packages/ai-proxy/test/create-ai-provider.test.ts +++ b/packages/ai-proxy/test/create-ai-provider.test.ts @@ -20,7 +20,7 @@ describe('createAiProvider', () => { const result = createAiProvider(config); - expect(result.providers).toEqual([{ name: 'my-ai', provider: 'openai' }]); + expect(result.providers).toEqual([{ name: 'my-ai', provider: 'openai', model: 'gpt-4o' }]); expect(typeof result.init).toBe('function'); }); diff --git a/packages/datasource-toolkit/src/interfaces/ai.ts b/packages/datasource-toolkit/src/interfaces/ai.ts index e9d1624bfb..781bb4fe66 100644 --- a/packages/datasource-toolkit/src/interfaces/ai.ts +++ b/packages/datasource-toolkit/src/interfaces/ai.ts @@ -1,9 +1,10 @@ import type { Logger } from '../factory'; -/** Metadata describing a configured AI provider, used in schema reporting. */ +/** Metadata describing a configured AI provider, used in schema reporting and logging. */ export interface AiProviderMeta { name: string; provider: string; + model: string; } export interface AiRouter { From 1ea9e430832fc1d745eb0b92c51e9257f3f8f5c3 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sat, 7 Feb 2026 14:07:42 +0100 Subject: [PATCH 09/18] fix(agent): send only name and provider in ai_llms schema metadata Keep ai_llms schema payload consistent with main: only { name, provider } are sent to the server. The model field remains in AiProviderMeta for agent-side logging only. Co-Authored-By: Claude Opus 4.6 --- packages/agent/src/utils/forest-schema/generator.ts | 5 ++++- packages/agent/test/agent.test.ts | 2 +- packages/agent/test/utils/forest-schema/generator.test.ts | 4 ++-- packages/forestadmin-client/src/schema/types.ts | 4 ++-- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/agent/src/utils/forest-schema/generator.ts b/packages/agent/src/utils/forest-schema/generator.ts index 2b6ea478ee..6bfa1cb7ec 100644 --- a/packages/agent/src/utils/forest-schema/generator.ts +++ b/packages/agent/src/utils/forest-schema/generator.ts @@ -32,7 +32,10 @@ export default class SchemaGenerator { liana: 'forest-nodejs-agent', liana_version: version, liana_features: features, - ai_llms: aiProviders.length > 0 ? aiProviders : null, + ai_llms: + aiProviders.length > 0 + ? aiProviders.map(p => ({ name: p.name, provider: p.provider })) + : 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 d8cb8c58ec..360090ccd8 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -467,7 +467,7 @@ describe('Agent', () => { expect(mockPostSchema).toHaveBeenCalledWith( expect.objectContaining({ meta: expect.objectContaining({ - ai_llms: [{ name: 'gpt4o', provider: 'openai', model: 'gpt-4o' }], + ai_llms: [{ name: 'gpt4o', provider: 'openai' }], }), }), ); diff --git a/packages/agent/test/utils/forest-schema/generator.test.ts b/packages/agent/test/utils/forest-schema/generator.test.ts index 31c33c57d7..67f90f9a2d 100644 --- a/packages/agent/test/utils/forest-schema/generator.test.ts +++ b/packages/agent/test/utils/forest-schema/generator.test.ts @@ -83,8 +83,8 @@ describe('SchemaGenerator', () => { expect(schema).toStrictEqual({ meta: { ai_llms: [ - { name: 'gpt4', provider: 'openai', model: 'gpt-4o' }, - { name: 'claude', provider: 'anthropic', model: 'claude-sonnet-4-5-20250929' }, + { name: 'gpt4', provider: 'openai' }, + { name: 'claude', provider: 'anthropic' }, ], liana: 'forest-nodejs-agent', liana_version: expect.any(String), diff --git a/packages/forestadmin-client/src/schema/types.ts b/packages/forestadmin-client/src/schema/types.ts index 9d967b00cb..b65e2a5a7e 100644 --- a/packages/forestadmin-client/src/schema/types.ts +++ b/packages/forestadmin-client/src/schema/types.ts @@ -1,4 +1,4 @@ -import type { AiProviderMeta, PrimitiveTypes } from '@forestadmin/datasource-toolkit'; +import type { PrimitiveTypes } from '@forestadmin/datasource-toolkit'; export type ForestSchema = { collections: ForestServerCollection[]; @@ -6,7 +6,7 @@ export type ForestSchema = { liana: string; liana_version: string; liana_features: Record | null; - ai_llms?: AiProviderMeta[] | null; + ai_llms?: Array<{ name: string; provider: string }> | null; stack: { engine: string; engine_version: string; From 0c571aabf5d2a6f2f26b6c412b3c3d8fad0440d6 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sat, 7 Feb 2026 17:53:34 +0100 Subject: [PATCH 10/18] fix(ai-proxy): address PR review findings - Restore "Please call addAi()" guidance for AINotConfiguredError - Add error inheritance tests verifying correct BusinessError subclasses - Add missing tests: addAi logging, mcpServerConfigs null, error types - Improve error hierarchy comment with tree diagram Co-Authored-By: Claude Opus 4.6 --- packages/agent/src/routes/ai/ai-proxy.ts | 28 +++-- packages/agent/test/agent.test.ts | 21 ++++ .../agent/test/routes/ai/ai-proxy.test.ts | 25 +++++ packages/ai-proxy/src/errors.ts | 31 ++++-- packages/ai-proxy/test/errors.test.ts | 103 ++++++++++++++++++ packages/ai-proxy/test/router.test.ts | 16 ++- 6 files changed, 202 insertions(+), 22 deletions(-) create mode 100644 packages/ai-proxy/test/errors.test.ts diff --git a/packages/agent/src/routes/ai/ai-proxy.ts b/packages/agent/src/routes/ai/ai-proxy.ts index 71844c6b9b..b658ca1c5b 100644 --- a/packages/agent/src/routes/ai/ai-proxy.ts +++ b/packages/agent/src/routes/ai/ai-proxy.ts @@ -4,6 +4,8 @@ import type { AiRouter } from '@forestadmin/datasource-toolkit'; import type KoaRouter from '@koa/router'; import type { Context } from 'koa'; +import { UnprocessableError } from '@forestadmin/datasource-toolkit'; + import { HttpCode, RouteType } from '../../types'; import BaseRoute from '../base-route'; @@ -28,13 +30,23 @@ export default class AiProxyRoute extends BaseRoute { const mcpServerConfigs = await this.options.forestAdminClient.mcpServerConfigService.getConfiguration(); - context.response.body = await this.aiRouter.route({ - route: context.params.route, - body: context.request.body, - query: context.query, - mcpServerConfigs, - requestHeaders: context.request.headers, - }); - context.response.status = HttpCode.Ok; + try { + context.response.body = await this.aiRouter.route({ + route: context.params.route, + body: context.request.body, + query: context.query, + mcpServerConfigs, + requestHeaders: context.request.headers, + }); + context.response.status = HttpCode.Ok; + } catch (error) { + if (error instanceof Error && error.name === 'AINotConfiguredError') { + throw new UnprocessableError( + 'AI is not configured. Please call addAi() on your agent.', + ); + } + + throw error; + } } } diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 360090ccd8..387466c75b 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -441,6 +441,27 @@ describe('Agent', () => { ).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 call init with logger on start to create AI router', async () => { const realMakeRoutes = jest.requireActual('../src/routes').default; mockMakeRoutes.mockImplementation(realMakeRoutes); diff --git a/packages/agent/test/routes/ai/ai-proxy.test.ts b/packages/agent/test/routes/ai/ai-proxy.test.ts index 53b9ac8fc6..5283fc846e 100644 --- a/packages/agent/test/routes/ai/ai-proxy.test.ts +++ b/packages/agent/test/routes/ai/ai-proxy.test.ts @@ -1,5 +1,6 @@ import type { AiRouter } from '@forestadmin/datasource-toolkit'; +import { UnprocessableError } from '@forestadmin/datasource-toolkit'; import { createMockContext } from '@shopify/jest-koa-mocks'; import AiProxyRoute from '../../../src/routes/ai/ai-proxy'; @@ -126,5 +127,29 @@ describe('AiProxyRoute', () => { await expect((route as any).handleAiProxy(context)).rejects.toBe(error); }); + + test('should enrich AINotConfiguredError with addAi() guidance', async () => { + const route = new AiProxyRoute(services, options, aiRouter); + const error = new Error('AI is not configured'); + error.name = 'AINotConfiguredError'; + mockRoute.mockRejectedValue(error); + + const context = createMockContext({ + customProperties: { + params: { route: 'ai-query' }, + query: {}, + }, + requestBody: {}, + }); + + const promise = (route as any).handleAiProxy(context); + await expect(promise).rejects.toThrow(UnprocessableError); + + mockRoute.mockRejectedValue(error); + const promise2 = (route as any).handleAiProxy(context); + await expect(promise2).rejects.toThrow( + 'AI is not configured. Please call addAi() on your agent.', + ); + }); }); }); diff --git a/packages/ai-proxy/src/errors.ts b/packages/ai-proxy/src/errors.ts index 92e35d3a5a..f817a4e7c6 100644 --- a/packages/ai-proxy/src/errors.ts +++ b/packages/ai-proxy/src/errors.ts @@ -1,18 +1,25 @@ /** - * All custom AI errors extend BusinessError subclasses from datasource-toolkit. - * Each error maps to its natural HTTP status via the agent's error middleware. + * All custom AI errors extend HTTP-status error classes (BadRequestError, NotFoundError, + * UnprocessableError) from datasource-toolkit. This allows the agent's error middleware + * to map them to their natural HTTP status codes automatically. * * Hierarchy: - * - AIError (extends UnprocessableError → 422) - * - AINotConfiguredError - * - McpError - * - McpConnectionError, McpConflictError, McpConfigError - * - AIBadRequestError (extends BadRequestError → 400) - * - AIModelNotSupportedError - * - AINotFoundError (extends NotFoundError → 404) - * - AIToolNotFoundError - * - AIUnprocessableError (extends UnprocessableError → 422) - * - OpenAIUnprocessableError, AIToolUnprocessableError + * + * UnprocessableError (422) + * ├── AIError (general AI errors) + * │ ├── AINotConfiguredError + * │ └── McpError + * │ ├── McpConnectionError, McpConflictError, McpConfigError + * └── AIUnprocessableError (provider/tool input errors) + * ├── OpenAIUnprocessableError, AIToolUnprocessableError + * + * BadRequestError (400) + * └── AIBadRequestError + * └── AIModelNotSupportedError + * + * NotFoundError (404) + * └── AINotFoundError + * └── AIToolNotFoundError */ // eslint-disable-next-line max-classes-per-file diff --git a/packages/ai-proxy/test/errors.test.ts b/packages/ai-proxy/test/errors.test.ts new file mode 100644 index 0000000000..527f3bab65 --- /dev/null +++ b/packages/ai-proxy/test/errors.test.ts @@ -0,0 +1,103 @@ +import { + BadRequestError, + NotFoundError, + UnprocessableError, +} from '@forestadmin/datasource-toolkit'; + +import { + AIBadRequestError, + AIError, + AIModelNotSupportedError, + AINotConfiguredError, + AINotFoundError, + AIToolNotFoundError, + AIToolUnprocessableError, + AIUnprocessableError, + McpConfigError, + McpConflictError, + McpConnectionError, + McpError, + OpenAIUnprocessableError, +} from '../src/errors'; + +describe('AI Error Hierarchy', () => { + describe('UnprocessableError branch (422)', () => { + test('AIError extends UnprocessableError', () => { + const error = new AIError('test'); + expect(error).toBeInstanceOf(UnprocessableError); + }); + + test('AINotConfiguredError extends UnprocessableError via AIError', () => { + const error = new AINotConfiguredError(); + expect(error).toBeInstanceOf(AIError); + expect(error).toBeInstanceOf(UnprocessableError); + }); + + test('McpError extends UnprocessableError via AIError', () => { + const error = new McpError('test'); + expect(error).toBeInstanceOf(AIError); + expect(error).toBeInstanceOf(UnprocessableError); + }); + + test('McpConnectionError extends UnprocessableError via McpError', () => { + const error = new McpConnectionError('test'); + expect(error).toBeInstanceOf(McpError); + expect(error).toBeInstanceOf(UnprocessableError); + }); + + test('McpConflictError extends UnprocessableError via McpError', () => { + const error = new McpConflictError('entity'); + expect(error).toBeInstanceOf(McpError); + expect(error).toBeInstanceOf(UnprocessableError); + }); + + test('McpConfigError extends UnprocessableError via McpError', () => { + const error = new McpConfigError('test'); + expect(error).toBeInstanceOf(McpError); + expect(error).toBeInstanceOf(UnprocessableError); + }); + + test('AIUnprocessableError extends UnprocessableError', () => { + const error = new AIUnprocessableError('test'); + expect(error).toBeInstanceOf(UnprocessableError); + }); + + test('OpenAIUnprocessableError extends UnprocessableError via AIUnprocessableError', () => { + const error = new OpenAIUnprocessableError('test'); + expect(error).toBeInstanceOf(AIUnprocessableError); + expect(error).toBeInstanceOf(UnprocessableError); + }); + + test('AIToolUnprocessableError extends UnprocessableError via AIUnprocessableError', () => { + const error = new AIToolUnprocessableError('test'); + expect(error).toBeInstanceOf(AIUnprocessableError); + expect(error).toBeInstanceOf(UnprocessableError); + }); + }); + + describe('BadRequestError branch (400)', () => { + test('AIBadRequestError extends BadRequestError', () => { + const error = new AIBadRequestError('test'); + expect(error).toBeInstanceOf(BadRequestError); + }); + + test('AIModelNotSupportedError extends BadRequestError via AIBadRequestError', () => { + const error = new AIModelNotSupportedError('gpt-4'); + expect(error).toBeInstanceOf(AIBadRequestError); + expect(error).toBeInstanceOf(BadRequestError); + }); + }); + + describe('NotFoundError branch (404)', () => { + test('AINotFoundError extends NotFoundError', () => { + const error = new AINotFoundError('test'); + expect(error).toBeInstanceOf(NotFoundError); + }); + + test('AIToolNotFoundError extends NotFoundError via AINotFoundError', () => { + const error = new AIToolNotFoundError('test'); + expect(error).toBeInstanceOf(AINotFoundError); + expect(error).toBeInstanceOf(NotFoundError); + }); + }); +}); diff --git a/packages/ai-proxy/test/router.test.ts b/packages/ai-proxy/test/router.test.ts index a0cd683019..b0c28d4ba1 100644 --- a/packages/ai-proxy/test/router.test.ts +++ b/packages/ai-proxy/test/router.test.ts @@ -463,7 +463,19 @@ describe('route', () => { route: 'remote-tools', mcpServerConfigs: 'invalid', }), - ).rejects.toThrow('Invalid MCP server configuration: missing "configs" property'); + ).rejects.toThrow(AIBadRequestError); + }); + + it('treats mcpServerConfigs null as no configs (falls through to undefined)', async () => { + const router = new Router({}); + + // null is falsy, so it is treated as "no configs provided" — no McpClient is created + await router.route({ + route: 'remote-tools', + mcpServerConfigs: null, + }); + + expect(MockedMcpClient).not.toHaveBeenCalled(); }); it('throws AIBadRequestError when mcpServerConfigs is missing configs property', async () => { @@ -474,7 +486,7 @@ describe('route', () => { route: 'remote-tools', mcpServerConfigs: { notConfigs: {} }, }), - ).rejects.toThrow('Invalid MCP server configuration: missing "configs" property'); + ).rejects.toThrow(AIBadRequestError); }); }); From 378c676f077cd59a977457693d509f11b3657d02 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sat, 7 Feb 2026 17:57:48 +0100 Subject: [PATCH 11/18] fix(agent): fix prettier formatting issues Co-Authored-By: Claude Opus 4.6 --- packages/agent/src/routes/ai/ai-proxy.ts | 4 +--- packages/agent/test/agent.test.ts | 5 +---- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/agent/src/routes/ai/ai-proxy.ts b/packages/agent/src/routes/ai/ai-proxy.ts index b658ca1c5b..344ee93df0 100644 --- a/packages/agent/src/routes/ai/ai-proxy.ts +++ b/packages/agent/src/routes/ai/ai-proxy.ts @@ -41,9 +41,7 @@ export default class AiProxyRoute extends BaseRoute { context.response.status = HttpCode.Ok; } catch (error) { if (error instanceof Error && error.name === 'AINotConfiguredError') { - throw new UnprocessableError( - 'AI is not configured. Please call addAi() on your agent.', - ); + throw new UnprocessableError('AI is not configured. Please call addAi() on your agent.'); } throw error; diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 387466c75b..c955b00eba 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -456,10 +456,7 @@ describe('Agent', () => { }), ); - expect(mockLogger).toHaveBeenCalledWith( - 'Warn', - expect.stringContaining("model 'gpt-4o'"), - ); + expect(mockLogger).toHaveBeenCalledWith('Warn', expect.stringContaining("model 'gpt-4o'")); }); test('should call init with logger on start to create AI router', async () => { From 7261922c2fbe41644174475fcb836f6d340a3b96 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sat, 7 Feb 2026 21:31:57 +0100 Subject: [PATCH 12/18] refactor(ai-proxy): move OAuth injection from Router to createAiProvider wrapper Keep router.ts unchanged from main. The OAuth token extraction and injection logic now lives in the createAiProvider wrapper instead. Co-Authored-By: Claude Opus 4.6 --- packages/ai-proxy/src/create-ai-provider.ts | 23 +++- packages/ai-proxy/src/router.ts | 44 +------ .../ai-proxy/test/create-ai-provider.test.ts | 124 ++++++++++++++---- packages/ai-proxy/test/router.test.ts | 105 +-------------- 4 files changed, 123 insertions(+), 173 deletions(-) diff --git a/packages/ai-proxy/src/create-ai-provider.ts b/packages/ai-proxy/src/create-ai-provider.ts index 232af332e3..190f7b317b 100644 --- a/packages/ai-proxy/src/create-ai-provider.ts +++ b/packages/ai-proxy/src/create-ai-provider.ts @@ -1,6 +1,7 @@ import type { AiConfiguration } from './provider'; import type { AiProviderDefinition } from '@forestadmin/datasource-toolkit'; +import { extractMcpOauthTokensFromHeaders, injectOauthTokens } from './oauth-token-injector'; import { Router } from './router'; // eslint-disable-next-line import/prefer-default-export @@ -8,7 +9,27 @@ export function createAiProvider(config: AiConfiguration): AiProviderDefinition return { providers: [{ name: config.name, provider: config.provider, model: config.model }], init(logger) { - return new Router({ aiConfigurations: [config], logger }); + const router = new Router({ aiConfigurations: [config], logger }); + + return { + route(args) { + const tokensByMcpServerName = args.requestHeaders + ? extractMcpOauthTokensFromHeaders(args.requestHeaders) + : undefined; + + const mcpConfigs = injectOauthTokens({ + mcpConfigs: args.mcpServerConfigs as Parameters[0]['mcpConfigs'], + tokensByMcpServerName, + }); + + return router.route({ + route: args.route, + body: args.body, + query: args.query, + mcpConfigs, + } as any); + }, + }; }, }; } diff --git a/packages/ai-proxy/src/router.ts b/packages/ai-proxy/src/router.ts index f4252d19cf..5812df10b8 100644 --- a/packages/ai-proxy/src/router.ts +++ b/packages/ai-proxy/src/router.ts @@ -7,7 +7,6 @@ import type { z } from 'zod'; import { AIBadRequestError, AIModelNotSupportedError } from './errors'; import McpClient from './mcp-client'; -import { extractMcpOauthTokensFromHeaders, injectOauthTokens } from './oauth-token-injector'; import { ProviderDispatcher } from './provider-dispatcher'; import { RemoteTools } from './remote-tools'; import { routeArgsSchema } from './schemas/route'; @@ -60,13 +59,7 @@ export class Router { * - invoke-remote-tool: Execute a remote tool by name with the provided inputs * - remote-tools: Return the list of available remote tools definitions */ - async route( - args: RouteArgs & { - mcpConfigs?: McpConfiguration; - mcpServerConfigs?: unknown; - requestHeaders?: Record; - }, - ) { + async route(args: RouteArgs & { mcpConfigs?: McpConfiguration }) { // Validate input with Zod schema const result = routeArgsSchema.safeParse(args); @@ -78,10 +71,8 @@ export class Router { let mcpClient: McpClient | undefined; try { - const resolvedMcpConfigs = this.resolveMcpConfigs(args); - - if (resolvedMcpConfigs) { - mcpClient = new McpClient(resolvedMcpConfigs, this.logger); + if (args.mcpConfigs) { + mcpClient = new McpClient(args.mcpConfigs, this.logger); } const remoteTools = new RemoteTools( @@ -151,35 +142,6 @@ export class Router { .join('; '); } - private resolveMcpConfigs(args: { - mcpConfigs?: McpConfiguration; - mcpServerConfigs?: unknown; - requestHeaders?: Record; - }): McpConfiguration | undefined { - // Backward compat: if mcpConfigs is already provided, use it directly - if (args.mcpConfigs) return args.mcpConfigs; - - // New path: mcpServerConfigs + requestHeaders → extract tokens and inject - if (args.mcpServerConfigs) { - if ( - typeof args.mcpServerConfigs !== 'object' || - args.mcpServerConfigs === null || - !('configs' in args.mcpServerConfigs) - ) { - throw new AIBadRequestError('Invalid MCP server configuration: missing "configs" property'); - } - - const mcpConfigs = args.mcpServerConfigs as McpConfiguration; - const tokensByMcpServerName = args.requestHeaders - ? extractMcpOauthTokensFromHeaders(args.requestHeaders) - : undefined; - - return injectOauthTokens({ mcpConfigs, tokensByMcpServerName }); - } - - return undefined; - } - private getAiConfiguration(aiName?: string): AiConfiguration | null { if (this.aiConfigurations.length === 0) return null; diff --git a/packages/ai-proxy/test/create-ai-provider.test.ts b/packages/ai-proxy/test/create-ai-provider.test.ts index a77388025b..43e90bad5d 100644 --- a/packages/ai-proxy/test/create-ai-provider.test.ts +++ b/packages/ai-proxy/test/create-ai-provider.test.ts @@ -5,33 +5,28 @@ 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(); }); - test('should return an AiProviderDefinition with providers array from config', () => { - const config: AiConfiguration = { - name: 'my-ai', - provider: 'openai', - model: 'gpt-4o', - apiKey: 'test-key', - }; + 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' }]); - expect(typeof result.init).toBe('function'); }); test('init should create a Router with the config and logger', () => { - const config: AiConfiguration = { - name: 'my-ai', - provider: 'openai', - model: 'gpt-4o', - apiKey: 'test-key', - }; - const provider = createAiProvider(config); const mockLogger = jest.fn(); provider.init(mockLogger); @@ -42,20 +37,95 @@ describe('createAiProvider', () => { }); }); - test('init should return the Router instance', () => { - const config: AiConfiguration = { - name: 'my-ai', - provider: 'openai', - model: 'gpt-4o', - apiKey: 'test-key', - }; - - const mockRouterInstance = { route: jest.fn() }; - jest.mocked(Router).mockImplementation(() => mockRouterInstance as any); - + test('init should return an AiRouter with a route method', () => { const provider = createAiProvider(config); const result = provider.init(jest.fn()); - expect(result).toBe(mockRouterInstance); + expect(typeof result.route).toBe('function'); + }); + + 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({ + route: 'ai-query', + body: { messages: [] }, + query: { 'ai-name': 'my-ai' }, + mcpConfigs: undefined, + }); + expect(result).toEqual({ result: 'ok' }); + }); + + test('should pass mcpServerConfigs as mcpConfigs to Router when no requestHeaders', async () => { + routeMock.mockResolvedValue({}); + const provider = createAiProvider(config); + const aiRouter = provider.init(jest.fn()); + + await aiRouter.route({ + route: 'remote-tools', + mcpServerConfigs: { configs: { server1: { command: 'test', args: [] } } }, + }); + + expect(routeMock).toHaveBeenCalledWith({ + route: 'remote-tools', + body: undefined, + query: undefined, + mcpConfigs: { configs: { server1: { command: 'test', args: [] } } }, + }); + }); + + test('should inject OAuth tokens from requestHeaders into mcpConfigs', 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', + mcpServerConfigs: { + configs: { server1: { type: 'http', url: 'https://server1.com' } }, + }, + requestHeaders: { 'x-mcp-oauth-tokens': oauthTokens }, + }); + + expect(routeMock).toHaveBeenCalledWith({ + route: 'remote-tools', + body: undefined, + query: undefined, + mcpConfigs: { + configs: { + server1: { + type: 'http', + url: 'https://server1.com', + headers: { Authorization: 'Bearer token123' }, + }, + }, + }, + }); + }); + + test('should pass mcpConfigs as undefined when no mcpServerConfigs provided', async () => { + routeMock.mockResolvedValue({}); + const provider = createAiProvider(config); + const aiRouter = provider.init(jest.fn()); + + await aiRouter.route({ route: 'remote-tools' }); + + expect(routeMock).toHaveBeenCalledWith({ + route: 'remote-tools', + body: undefined, + query: undefined, + mcpConfigs: undefined, + }); + }); }); }); diff --git a/packages/ai-proxy/test/router.test.ts b/packages/ai-proxy/test/router.test.ts index b0c28d4ba1..8a64759b26 100644 --- a/packages/ai-proxy/test/router.test.ts +++ b/packages/ai-proxy/test/router.test.ts @@ -1,7 +1,7 @@ import type { DispatchBody, InvokeRemoteToolArgs, Route } from '../src'; import type { Logger } from '@forestadmin/datasource-toolkit'; -import { AIBadRequestError, AIModelNotSupportedError, Router } from '../src'; +import { AIModelNotSupportedError, Router } from '../src'; import McpClient from '../src/mcp-client'; const invokeToolMock = jest.fn(); @@ -387,109 +387,6 @@ describe('route', () => { }); }); - describe('resolveMcpConfigs (mcpServerConfigs path)', () => { - it('creates McpClient from mcpServerConfigs when provided', async () => { - const router = new Router({}); - - await router.route({ - route: 'remote-tools', - mcpServerConfigs: { configs: { server1: { command: 'test', args: [] } } }, - }); - - expect(MockedMcpClient).toHaveBeenCalledWith( - { configs: { server1: { command: 'test', args: [] } } }, - undefined, - ); - }); - - it('injects OAuth tokens from requestHeaders into mcpServerConfigs', async () => { - const router = new Router({}); - const oauthTokens = JSON.stringify({ server1: 'Bearer token123' }); - - await router.route({ - route: 'remote-tools', - mcpServerConfigs: { - configs: { server1: { type: 'http', url: 'https://server1.com' } }, - }, - requestHeaders: { 'x-mcp-oauth-tokens': oauthTokens }, - }); - - expect(MockedMcpClient).toHaveBeenCalledWith( - { - configs: { - server1: { - type: 'http', - url: 'https://server1.com', - headers: { Authorization: 'Bearer token123' }, - }, - }, - }, - undefined, - ); - }); - - it('uses mcpServerConfigs without token injection when requestHeaders is absent', async () => { - const router = new Router({}); - - await router.route({ - route: 'remote-tools', - mcpServerConfigs: { configs: { server1: { command: 'test', args: [] } } }, - }); - - expect(MockedMcpClient).toHaveBeenCalledWith( - { configs: { server1: { command: 'test', args: [] } } }, - undefined, - ); - }); - - it('prefers mcpConfigs over mcpServerConfigs for backward compatibility', async () => { - const router = new Router({}); - const directMcpConfigs = { configs: { direct: { command: 'direct', args: [] } } }; - - await router.route({ - route: 'remote-tools', - mcpConfigs: directMcpConfigs, - mcpServerConfigs: { configs: { server: { command: 'server', args: [] } } }, - }); - - expect(MockedMcpClient).toHaveBeenCalledWith(directMcpConfigs, undefined); - }); - - it('throws AIBadRequestError when mcpServerConfigs has invalid shape', async () => { - const router = new Router({}); - - await expect( - router.route({ - route: 'remote-tools', - mcpServerConfigs: 'invalid', - }), - ).rejects.toThrow(AIBadRequestError); - }); - - it('treats mcpServerConfigs null as no configs (falls through to undefined)', async () => { - const router = new Router({}); - - // null is falsy, so it is treated as "no configs provided" — no McpClient is created - await router.route({ - route: 'remote-tools', - mcpServerConfigs: null, - }); - - expect(MockedMcpClient).not.toHaveBeenCalled(); - }); - - it('throws AIBadRequestError when mcpServerConfigs is missing configs property', async () => { - const router = new Router({}); - - await expect( - router.route({ - route: 'remote-tools', - mcpServerConfigs: { notConfigs: {} }, - }), - ).rejects.toThrow(AIBadRequestError); - }); - }); - describe('Model validation', () => { it('throws AIModelNotSupportedError when model does not support tools', () => { expect( From 65490e45dcab2af20c93853a1e181d4dfb32dc19 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sat, 7 Feb 2026 21:46:41 +0100 Subject: [PATCH 13/18] refactor(agent): remove dead AINotConfiguredError handling from ai-proxy route The route is only registered when addAi() is called, so this error path is unreachable. Co-Authored-By: Claude Opus 4.6 --- packages/agent/src/routes/ai/ai-proxy.ts | 26 ++++++------------- .../agent/test/routes/ai/ai-proxy.test.ts | 24 ----------------- 2 files changed, 8 insertions(+), 42 deletions(-) diff --git a/packages/agent/src/routes/ai/ai-proxy.ts b/packages/agent/src/routes/ai/ai-proxy.ts index 344ee93df0..71844c6b9b 100644 --- a/packages/agent/src/routes/ai/ai-proxy.ts +++ b/packages/agent/src/routes/ai/ai-proxy.ts @@ -4,8 +4,6 @@ import type { AiRouter } from '@forestadmin/datasource-toolkit'; import type KoaRouter from '@koa/router'; import type { Context } from 'koa'; -import { UnprocessableError } from '@forestadmin/datasource-toolkit'; - import { HttpCode, RouteType } from '../../types'; import BaseRoute from '../base-route'; @@ -30,21 +28,13 @@ export default class AiProxyRoute extends BaseRoute { const mcpServerConfigs = await this.options.forestAdminClient.mcpServerConfigService.getConfiguration(); - try { - context.response.body = await this.aiRouter.route({ - route: context.params.route, - body: context.request.body, - query: context.query, - mcpServerConfigs, - requestHeaders: context.request.headers, - }); - context.response.status = HttpCode.Ok; - } catch (error) { - if (error instanceof Error && error.name === 'AINotConfiguredError') { - throw new UnprocessableError('AI is not configured. Please call addAi() on your agent.'); - } - - throw error; - } + context.response.body = await this.aiRouter.route({ + route: context.params.route, + body: context.request.body, + query: context.query, + mcpServerConfigs, + requestHeaders: context.request.headers, + }); + context.response.status = HttpCode.Ok; } } diff --git a/packages/agent/test/routes/ai/ai-proxy.test.ts b/packages/agent/test/routes/ai/ai-proxy.test.ts index 5283fc846e..f3c027d69f 100644 --- a/packages/agent/test/routes/ai/ai-proxy.test.ts +++ b/packages/agent/test/routes/ai/ai-proxy.test.ts @@ -1,6 +1,5 @@ import type { AiRouter } from '@forestadmin/datasource-toolkit'; -import { UnprocessableError } from '@forestadmin/datasource-toolkit'; import { createMockContext } from '@shopify/jest-koa-mocks'; import AiProxyRoute from '../../../src/routes/ai/ai-proxy'; @@ -128,28 +127,5 @@ describe('AiProxyRoute', () => { await expect((route as any).handleAiProxy(context)).rejects.toBe(error); }); - test('should enrich AINotConfiguredError with addAi() guidance', async () => { - const route = new AiProxyRoute(services, options, aiRouter); - const error = new Error('AI is not configured'); - error.name = 'AINotConfiguredError'; - mockRoute.mockRejectedValue(error); - - const context = createMockContext({ - customProperties: { - params: { route: 'ai-query' }, - query: {}, - }, - requestBody: {}, - }); - - const promise = (route as any).handleAiProxy(context); - await expect(promise).rejects.toThrow(UnprocessableError); - - mockRoute.mockRejectedValue(error); - const promise2 = (route as any).handleAiProxy(context); - await expect(promise2).rejects.toThrow( - 'AI is not configured. Please call addAi() on your agent.', - ); - }); }); }); From d4fac508a8cedabcdb7770109c15d28b0077a049 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sat, 7 Feb 2026 21:52:16 +0100 Subject: [PATCH 14/18] fix(ai-proxy): fix lint and prettier errors Co-Authored-By: Claude Opus 4.6 --- packages/agent/test/routes/ai/ai-proxy.test.ts | 1 - packages/ai-proxy/src/create-ai-provider.ts | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/agent/test/routes/ai/ai-proxy.test.ts b/packages/agent/test/routes/ai/ai-proxy.test.ts index f3c027d69f..53b9ac8fc6 100644 --- a/packages/agent/test/routes/ai/ai-proxy.test.ts +++ b/packages/agent/test/routes/ai/ai-proxy.test.ts @@ -126,6 +126,5 @@ describe('AiProxyRoute', () => { await expect((route as any).handleAiProxy(context)).rejects.toBe(error); }); - }); }); diff --git a/packages/ai-proxy/src/create-ai-provider.ts b/packages/ai-proxy/src/create-ai-provider.ts index 190f7b317b..7613c7d90e 100644 --- a/packages/ai-proxy/src/create-ai-provider.ts +++ b/packages/ai-proxy/src/create-ai-provider.ts @@ -1,3 +1,4 @@ +import type { McpConfiguration } from './mcp-client'; import type { AiConfiguration } from './provider'; import type { AiProviderDefinition } from '@forestadmin/datasource-toolkit'; @@ -18,7 +19,7 @@ export function createAiProvider(config: AiConfiguration): AiProviderDefinition : undefined; const mcpConfigs = injectOauthTokens({ - mcpConfigs: args.mcpServerConfigs as Parameters[0]['mcpConfigs'], + mcpConfigs: args.mcpServerConfigs as McpConfiguration | undefined, tokensByMcpServerName, }); From dc673b9f7808aae38399aecff513d839264325c5 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sat, 7 Feb 2026 21:59:26 +0100 Subject: [PATCH 15/18] refactor(ai-proxy): remove any cast from createAiProvider Use RouteArgs type assertion instead of any. Co-Authored-By: Claude Opus 4.6 --- packages/ai-proxy/src/create-ai-provider.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/ai-proxy/src/create-ai-provider.ts b/packages/ai-proxy/src/create-ai-provider.ts index 7613c7d90e..7a0c740d99 100644 --- a/packages/ai-proxy/src/create-ai-provider.ts +++ b/packages/ai-proxy/src/create-ai-provider.ts @@ -1,5 +1,6 @@ import type { McpConfiguration } from './mcp-client'; import type { AiConfiguration } from './provider'; +import type { RouteArgs } from './schemas/route'; import type { AiProviderDefinition } from '@forestadmin/datasource-toolkit'; import { extractMcpOauthTokensFromHeaders, injectOauthTokens } from './oauth-token-injector'; @@ -23,12 +24,14 @@ export function createAiProvider(config: AiConfiguration): AiProviderDefinition tokensByMcpServerName, }); - return router.route({ + const routerArgs = { route: args.route, body: args.body, query: args.query, mcpConfigs, - } as any); + } as RouteArgs & { mcpConfigs?: McpConfiguration }; + + return router.route(routerArgs); }, }; }, From d467d5be57e353c696ea7ad910e705b16c2abc45 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sat, 7 Feb 2026 22:01:48 +0100 Subject: [PATCH 16/18] refactor(agent): remove @forestadmin/ai-proxy from peerDependencies The agent no longer imports anything from ai-proxy at runtime. Co-Authored-By: Claude Opus 4.6 --- packages/agent/package.json | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/agent/package.json b/packages/agent/package.json index b9e5f680d3..cac83afb24 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -71,15 +71,11 @@ "@paralleldrive/cuid2": "2.2.2" }, "peerDependencies": { - "@fastify/express": "^1.1.0 || ^2.0.0 || ^3.0.0 || ^4.0.0", - "@forestadmin/ai-proxy": ">=1.5.0" + "@fastify/express": "^1.1.0 || ^2.0.0 || ^3.0.0 || ^4.0.0" }, "peerDependenciesMeta": { "@fastify/express": { "optional": true - }, - "@forestadmin/ai-proxy": { - "optional": true } } } From e484938d4c7a7738dc5ae9edfab57d18f4af4f2b Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sat, 7 Feb 2026 22:07:45 +0100 Subject: [PATCH 17/18] refactor(ai-proxy): extract resolveMcpConfigs to improve readability Co-Authored-By: Claude Opus 4.6 --- packages/ai-proxy/src/create-ai-provider.ts | 33 ++++++++++----------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/packages/ai-proxy/src/create-ai-provider.ts b/packages/ai-proxy/src/create-ai-provider.ts index 7a0c740d99..31527e638f 100644 --- a/packages/ai-proxy/src/create-ai-provider.ts +++ b/packages/ai-proxy/src/create-ai-provider.ts @@ -1,11 +1,22 @@ import type { McpConfiguration } from './mcp-client'; import type { AiConfiguration } from './provider'; import type { RouteArgs } from './schemas/route'; -import type { AiProviderDefinition } from '@forestadmin/datasource-toolkit'; +import type { AiProviderDefinition, AiRouter } from '@forestadmin/datasource-toolkit'; import { extractMcpOauthTokensFromHeaders, injectOauthTokens } from './oauth-token-injector'; import { Router } from './router'; +function resolveMcpConfigs(args: Parameters[0]): McpConfiguration | undefined { + const tokensByMcpServerName = args.requestHeaders + ? extractMcpOauthTokensFromHeaders(args.requestHeaders) + : undefined; + + return injectOauthTokens({ + mcpConfigs: args.mcpServerConfigs as McpConfiguration | undefined, + tokensByMcpServerName, + }); +} + // eslint-disable-next-line import/prefer-default-export export function createAiProvider(config: AiConfiguration): AiProviderDefinition { return { @@ -14,25 +25,13 @@ export function createAiProvider(config: AiConfiguration): AiProviderDefinition const router = new Router({ aiConfigurations: [config], logger }); return { - route(args) { - const tokensByMcpServerName = args.requestHeaders - ? extractMcpOauthTokensFromHeaders(args.requestHeaders) - : undefined; - - const mcpConfigs = injectOauthTokens({ - mcpConfigs: args.mcpServerConfigs as McpConfiguration | undefined, - tokensByMcpServerName, - }); - - const routerArgs = { + route: args => + router.route({ route: args.route, body: args.body, query: args.query, - mcpConfigs, - } as RouteArgs & { mcpConfigs?: McpConfiguration }; - - return router.route(routerArgs); - }, + mcpConfigs: resolveMcpConfigs(args), + } as RouteArgs & { mcpConfigs?: McpConfiguration }), }; }, }; From 93641a9cd5e6396eacdf95b2debff8b20708ddfa Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sat, 7 Feb 2026 22:17:09 +0100 Subject: [PATCH 18/18] refactor(ai-proxy): extract RouterRouteArgs type alias Co-Authored-By: Claude Opus 4.6 --- packages/ai-proxy/src/create-ai-provider.ts | 4 ++-- packages/ai-proxy/src/schemas/route.ts | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/ai-proxy/src/create-ai-provider.ts b/packages/ai-proxy/src/create-ai-provider.ts index 31527e638f..13bf3a5d40 100644 --- a/packages/ai-proxy/src/create-ai-provider.ts +++ b/packages/ai-proxy/src/create-ai-provider.ts @@ -1,6 +1,6 @@ import type { McpConfiguration } from './mcp-client'; import type { AiConfiguration } from './provider'; -import type { RouteArgs } from './schemas/route'; +import type { RouterRouteArgs } from './schemas/route'; import type { AiProviderDefinition, AiRouter } from '@forestadmin/datasource-toolkit'; import { extractMcpOauthTokensFromHeaders, injectOauthTokens } from './oauth-token-injector'; @@ -31,7 +31,7 @@ export function createAiProvider(config: AiConfiguration): AiProviderDefinition body: args.body, query: args.query, mcpConfigs: resolveMcpConfigs(args), - } as RouteArgs & { mcpConfigs?: McpConfiguration }), + } as RouterRouteArgs), }; }, }; diff --git a/packages/ai-proxy/src/schemas/route.ts b/packages/ai-proxy/src/schemas/route.ts index 3e8c75aba4..2baa234fad 100644 --- a/packages/ai-proxy/src/schemas/route.ts +++ b/packages/ai-proxy/src/schemas/route.ts @@ -1,3 +1,5 @@ +import type { McpConfiguration } from '../mcp-client'; + import { z } from 'zod'; // Base query schema with common optional parameters @@ -83,6 +85,7 @@ export type RemoteToolsArgs = z.infer; // Derived types for consumers export type DispatchBody = AiQueryArgs['body']; +export type RouterRouteArgs = RouteArgs & { mcpConfigs?: McpConfiguration }; // Backward compatibility types export type InvokeRemoteToolBody = InvokeRemoteToolArgs['body'];