diff --git a/packages/ai-proxy/README.md b/packages/ai-proxy/README.md index e69de29bb..b48e8613d 100644 --- a/packages/ai-proxy/README.md +++ b/packages/ai-proxy/README.md @@ -0,0 +1,97 @@ +# @forestadmin/ai-proxy + +AI Proxy client for Forest Admin. + +## Quick Start + +```typescript +import { createAiProxyClient } from '@forestadmin/ai-proxy/client'; + +const client = createAiProxyClient({ baseUrl: 'https://my-agent.com/forest' }); + +const response = await client.chat('Hello!'); +console.log(response.choices[0].message.content); +``` + +## Installation + +**Client only** (frontend, no extra dependencies): + +```bash +npm install @forestadmin/ai-proxy +``` + +**Server side** (Router, ProviderDispatcher): + +```bash +npm install @forestadmin/ai-proxy @langchain/core @langchain/openai +``` + +## Configuration + +Choose one mode: + +```typescript +// Simple mode (recommended) +const client = createAiProxyClient({ + baseUrl: 'https://my-agent.com/forest', + headers: { Authorization: 'Bearer token' }, // optional + timeout: 30000, // optional (default: 30s) +}); + +// Custom fetch mode +const client = createAiProxyClient({ + fetch: myCustomFetch, + timeout: 30000, +}); +``` + +## API + +### `chat(input)` + +```typescript +// Simple +await client.chat('Hello!'); + +// With options +await client.chat({ + messages: [{ role: 'user', content: 'Hello!' }], + tools: [...], // optional + toolChoice: 'auto', // optional + aiName: 'gpt-4', // optional - server AI config name +}); +``` + +### `getTools()` + +```typescript +const tools = await client.getTools(); +// [{ name: 'brave_search', description: '...', schema: {...} }] +``` + +### `callTool(name, inputs)` + +```typescript +const result = await client.callTool('brave_search', [ + { role: 'user', content: 'cats' } +]); +``` + +## Error Handling + +```typescript +import { AiProxyClientError } from '@forestadmin/ai-proxy/client'; + +try { + await client.chat('Hello'); +} catch (error) { + if (error instanceof AiProxyClientError) { + console.error(error.status, error.message); + + if (error.isNetworkError) { /* status 0 */ } + if (error.isClientError) { /* status 4xx */ } + if (error.isServerError) { /* status 5xx */ } + } +} +``` diff --git a/packages/ai-proxy/package.json b/packages/ai-proxy/package.json index 6c8abaa01..912bb27a7 100644 --- a/packages/ai-proxy/package.json +++ b/packages/ai-proxy/package.json @@ -2,6 +2,17 @@ "name": "@forestadmin/ai-proxy", "version": "1.1.0", "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./client": { + "types": "./dist/client/index.d.ts", + "default": "./dist/client/index.js" + } + }, "license": "GPL-3.0", "publishConfig": { "access": "public" @@ -12,19 +23,44 @@ "directory": "packages/ai-proxy" }, "dependencies": { + "zod": "^4.3.5" + }, + "peerDependencies": { + "@langchain/community": "^1.1.4", + "@langchain/core": "^1.1.15", + "@langchain/langgraph": "^1.1.0", + "@langchain/mcp-adapters": "^1.1.1", + "@langchain/openai": "^1.2.2" + }, + "peerDependenciesMeta": { + "@langchain/community": { + "optional": true + }, + "@langchain/core": { + "optional": true + }, + "@langchain/langgraph": { + "optional": true + }, + "@langchain/mcp-adapters": { + "optional": true + }, + "@langchain/openai": { + "optional": true + } + }, + "devDependencies": { "@forestadmin/datasource-toolkit": "1.50.1", "@langchain/community": "1.1.4", "@langchain/core": "1.1.15", "@langchain/langgraph": "^1.1.0", "@langchain/mcp-adapters": "1.1.1", "@langchain/openai": "1.2.2", - "zod": "^4.3.5" - }, - "devDependencies": { "@modelcontextprotocol/sdk": "1.25.3", "@types/express": "5.0.1", "express": "5.1.0", - "node-zendesk": "6.0.1" + "node-zendesk": "6.0.1", + "openai": "^6.16.0" }, "files": [ "dist/**/*.js", diff --git a/packages/ai-proxy/src/client/index.ts b/packages/ai-proxy/src/client/index.ts new file mode 100644 index 000000000..0b567b14f --- /dev/null +++ b/packages/ai-proxy/src/client/index.ts @@ -0,0 +1,203 @@ +import type { + AiProxyClientConfig, + AiQueryResponse, + ChatCompletionMessageParam, + ChatInput, + InvokeToolResponse, + RemoteToolsResponse, +} from './types'; + +import { AiProxyClientError } from './types'; + +export * from './types'; + +const DEFAULT_TIMEOUT = 30_000; + +export class AiProxyClient { + private readonly timeout: number; + private readonly mode: 'custom' | 'simple'; + + // Custom fetch mode + private readonly customFetch?: typeof fetch; + + // Simple mode + private readonly baseUrl?: string; + private readonly headers?: Record; + + constructor(config: AiProxyClientConfig) { + this.timeout = config.timeout ?? DEFAULT_TIMEOUT; + + if ('fetch' in config) { + this.mode = 'custom'; + this.customFetch = config.fetch; + } else { + this.mode = 'simple'; + this.baseUrl = config.baseUrl.replace(/\/$/, ''); + this.headers = config.headers; + } + } + + /** + * Get the list of available remote tools. + */ + async getTools(): Promise { + return this.request({ + method: 'GET', + path: '/remote-tools', + }); + } + + /** + * Send a chat message to the AI. + * + * @example Simple usage with a string + * ```typescript + * const response = await client.chat('Hello, how are you?'); + * ``` + * + * @example Advanced usage with options + * ```typescript + * const response = await client.chat({ + * messages: [{ role: 'user', content: 'Search for cats' }], + * tools: [...], + * toolChoice: 'auto', + * aiName: 'gpt-4', + * }); + * ``` + */ + async chat(input: string | ChatInput): Promise { + const normalized: ChatInput = + typeof input === 'string' ? { messages: [{ role: 'user', content: input }] } : input; + + const searchParams = new URLSearchParams(); + + if (normalized.aiName) { + searchParams.set('ai-name', normalized.aiName); + } + + return this.request({ + method: 'POST', + path: '/ai-query', + searchParams, + body: { + messages: normalized.messages, + tools: normalized.tools, + tool_choice: normalized.toolChoice, + }, + }); + } + + /** + * Call a remote tool by name. + * + * @example + * ```typescript + * const result = await client.callTool('brave_search', [ + * { role: 'user', content: 'cats' } + * ]); + * ``` + */ + async callTool( + toolName: string, + inputs: ChatCompletionMessageParam[], + ): Promise { + const searchParams = new URLSearchParams(); + searchParams.set('tool-name', toolName); + + return this.request({ + method: 'POST', + path: '/invoke-remote-tool', + searchParams, + body: { inputs }, + }); + } + + private async request(params: { + method: 'GET' | 'POST'; + path: string; + searchParams?: URLSearchParams; + body?: unknown; + }): Promise { + const { method, path, searchParams, body } = params; + + let url = path; + + if (searchParams && searchParams.toString()) { + url += `?${searchParams.toString()}`; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + try { + const response = + this.mode === 'custom' + ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guaranteed by constructor + await this.customFetch!(url, { + method, + body: body ? JSON.stringify(body) : undefined, + signal: controller.signal, + }) + : await fetch(`${this.baseUrl}${url}`, { + method, + headers: { 'Content-Type': 'application/json', ...this.headers }, + body: body ? JSON.stringify(body) : undefined, + signal: controller.signal, + }); + + if (!response.ok) { + let responseBody: unknown; + + try { + responseBody = await response.json(); + } catch (jsonError) { + try { + responseBody = await response.text(); + } catch { + responseBody = undefined; + } + } + + throw new AiProxyClientError( + `${method} ${path} failed with status ${response.status}`, + response.status, + responseBody, + ); + } + + try { + return (await response.json()) as T; + } catch (parseError) { + throw new AiProxyClientError( + `${method} ${path}: Server returned ${response.status} but response is not valid JSON`, + response.status, + undefined, + parseError instanceof Error ? parseError : undefined, + ); + } + } catch (error) { + if (error instanceof AiProxyClientError) { + throw error; + } + + if (error instanceof Error && error.name === 'AbortError') { + throw new AiProxyClientError(`${method} ${path} timed out after ${this.timeout}ms`, 408); + } + + const cause = error instanceof Error ? error : undefined; + const message = error instanceof Error ? error.message : String(error); + throw new AiProxyClientError( + `${method} ${path} network error: ${message}`, + 0, + undefined, + cause, + ); + } finally { + clearTimeout(timeoutId); + } + } +} + +export function createAiProxyClient(config: AiProxyClientConfig): AiProxyClient { + return new AiProxyClient(config); +} diff --git a/packages/ai-proxy/src/client/types.ts b/packages/ai-proxy/src/client/types.ts new file mode 100644 index 000000000..fe9af0b01 --- /dev/null +++ b/packages/ai-proxy/src/client/types.ts @@ -0,0 +1,61 @@ +/** + * Standalone client types - no runtime dependencies. + * Re-exports shared route types and defines client-specific types. + */ +import type { + ChatCompletionMessageParam, + ChatCompletionTool, + ChatCompletionToolChoiceOption, +} from '../types'; + +// Re-export shared route types for client usage +export type { + ChatCompletionMessageParam, + ChatCompletionTool, + ChatCompletionToolChoiceOption, + ChatCompletion, + AiQueryRequest, + AiQueryResponse, + InvokeToolRequest, + InvokeToolResponse, + RemoteToolDefinition, + RemoteToolsResponse, +} from '../types'; + +// Client-specific types +export type AiProxyClientConfig = + | { fetch: typeof fetch; timeout?: number } + | { baseUrl: string; headers?: Record; timeout?: number }; + +export interface ChatInput { + messages: ChatCompletionMessageParam[]; + tools?: ChatCompletionTool[]; + toolChoice?: ChatCompletionToolChoiceOption; + aiName?: string; +} + +export class AiProxyClientError extends Error { + readonly status: number; + readonly body?: unknown; + readonly cause?: Error; + + constructor(message: string, status: number, body?: unknown, cause?: Error) { + super(message); + this.name = 'AiProxyClientError'; + this.status = status; + this.body = body; + this.cause = cause; + } + + get isNetworkError(): boolean { + return this.status === 0; + } + + get isClientError(): boolean { + return this.status >= 400 && this.status < 500; + } + + get isServerError(): boolean { + return this.status >= 500 && this.status < 600; + } +} diff --git a/packages/ai-proxy/src/index.ts b/packages/ai-proxy/src/index.ts index 2cc9c9892..f4ebbe726 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 './types/mcp-config-checker'; +export * from './types'; export * from './provider-dispatcher'; export * from './remote-tools'; export * from './router'; @@ -9,6 +10,9 @@ export * from './mcp-client'; export * from './types/errors'; +// Client is exported from a separate entry point: @forestadmin/ai-proxy/client +// This avoids pulling langchain dependencies in frontend builds + export function validMcpConfigurationOrThrow(mcpConfig: McpConfiguration) { return McpConfigChecker.check(mcpConfig); } diff --git a/packages/ai-proxy/src/provider-dispatcher.ts b/packages/ai-proxy/src/provider-dispatcher.ts index 5f04f3288..8629fe9db 100644 --- a/packages/ai-proxy/src/provider-dispatcher.ts +++ b/packages/ai-proxy/src/provider-dispatcher.ts @@ -1,7 +1,12 @@ import type { RemoteTools } from './remote-tools'; +import type { + AiQueryRequest, + AiQueryResponse, + ChatCompletionTool, + ChatCompletionToolChoiceOption, +} from './types'; import type { BaseMessageLike } from '@langchain/core/messages'; import type { ChatOpenAIFields, OpenAIChatModelId } from '@langchain/openai'; -import type OpenAI from 'openai'; import { convertToOpenAIFunction } from '@langchain/core/utils/function_calling'; import { ChatOpenAI } from '@langchain/openai'; @@ -68,17 +73,6 @@ export type OpenAiConfiguration = BaseAiConfiguration & export type AiProvider = 'openai'; export type AiConfiguration = OpenAiConfiguration; -export type ChatCompletionResponse = OpenAI.Chat.Completions.ChatCompletion; -export type ChatCompletionMessage = OpenAI.Chat.Completions.ChatCompletionMessageParam; -export type ChatCompletionTool = OpenAI.Chat.Completions.ChatCompletionTool; -export type ChatCompletionToolChoice = OpenAI.Chat.Completions.ChatCompletionToolChoiceOption; - -export type DispatchBody = { - messages: ChatCompletionMessage[]; - tools?: ChatCompletionTool[]; - tool_choice?: ChatCompletionToolChoice; -}; - export class ProviderDispatcher { private readonly chatModel: ChatOpenAI | null = null; @@ -96,7 +90,7 @@ export class ProviderDispatcher { } } - async dispatch(body: DispatchBody): Promise { + async dispatch(body: AiQueryRequest): Promise { if (!this.chatModel) { throw new AINotConfiguredError(); } @@ -110,7 +104,7 @@ export class ProviderDispatcher { const response = await model.invoke(messages as BaseMessageLike[]); // eslint-disable-next-line no-underscore-dangle - const rawResponse = response.additional_kwargs.__raw_response as ChatCompletionResponse; + const rawResponse = response.additional_kwargs.__raw_response as AiQueryResponse; if (!rawResponse) { throw new OpenAIUnprocessableError( @@ -139,7 +133,7 @@ export class ProviderDispatcher { private bindToolsIfNeeded( chatModel: ChatOpenAI, tools: ChatCompletionTool[] | undefined, - toolChoice?: ChatCompletionToolChoice, + toolChoice?: ChatCompletionToolChoiceOption, ) { if (!tools || tools.length === 0) { return chatModel; diff --git a/packages/ai-proxy/src/remote-tools.ts b/packages/ai-proxy/src/remote-tools.ts index 5883d5344..fdfc78aaf 100644 --- a/packages/ai-proxy/src/remote-tools.ts +++ b/packages/ai-proxy/src/remote-tools.ts @@ -1,6 +1,5 @@ +import type { ChatCompletionMessageParam, RemoteToolDefinition } from './types'; import type RemoteTool from './types/remote-tool'; -import type { ResponseFormat } from '@langchain/core/tools'; -import type { ChatCompletionCreateParamsNonStreaming } from 'openai/resources/chat/completions'; import { BraveSearch } from '@langchain/community/tools/brave_search'; import { toJsonSchema } from '@langchain/core/utils/json_schema'; @@ -8,7 +7,7 @@ import { toJsonSchema } from '@langchain/core/utils/json_schema'; import { AIToolNotFoundError, AIToolUnprocessableError } from './types/errors'; import ServerRemoteTool from './types/server-remote-tool'; -export type Messages = ChatCompletionCreateParamsNonStreaming['messages']; +export type Messages = ChatCompletionMessageParam[]; export type RemoteToolsApiKeys = | { ['AI_REMOTE_TOOL_BRAVE_SEARCH_API_KEY']: string } @@ -32,12 +31,12 @@ export class RemoteTools { } } - get toolDefinitionsForFrontend() { + get toolDefinitionsForFrontend(): RemoteToolDefinition[] { return this.tools.map(extendedTool => { return { name: extendedTool.sanitizedName, description: extendedTool.base.description, - responseFormat: 'content' as ResponseFormat, + responseFormat: 'content' as const, schema: toJsonSchema(extendedTool.base.schema), sourceId: extendedTool.sourceId, sourceType: extendedTool.sourceType, @@ -45,7 +44,7 @@ export class RemoteTools { }); } - async invokeTool(toolName: string, messages: ChatCompletionCreateParamsNonStreaming['messages']) { + async invokeTool(toolName: string, messages: ChatCompletionMessageParam[]) { const extendedTool = this.tools.find(exTool => exTool.sanitizedName === toolName); if (!extendedTool) throw new AIToolNotFoundError(`Tool ${toolName} not found`); diff --git a/packages/ai-proxy/src/router.ts b/packages/ai-proxy/src/router.ts index 049d760a2..c48094c1c 100644 --- a/packages/ai-proxy/src/router.ts +++ b/packages/ai-proxy/src/router.ts @@ -1,19 +1,16 @@ import type { McpConfiguration } from './mcp-client'; -import type { AiConfiguration, DispatchBody } from './provider-dispatcher'; -import type { Messages, RemoteToolsApiKeys } from './remote-tools'; +import type { AiConfiguration } from './provider-dispatcher'; +import type { RemoteToolsApiKeys } from './remote-tools'; +import type { AiQueryQuery, AiQueryRequest, InvokeToolQuery, InvokeToolRequest } from './types'; import type { Logger } from '@forestadmin/datasource-toolkit'; import { AIBadRequestError, AIUnprocessableError, ProviderDispatcher } from './index'; import McpClient from './mcp-client'; import { RemoteTools } from './remote-tools'; -export type InvokeRemoteToolBody = { inputs: Messages }; -export type Body = DispatchBody | InvokeRemoteToolBody | undefined; export type Route = 'ai-query' | 'remote-tools' | 'invoke-remote-tool'; -export type Query = { - 'tool-name'?: string; - 'ai-name'?: string; -}; +export type Body = AiQueryRequest | InvokeToolRequest | undefined; +export type Query = Partial; export type ApiKeys = RemoteToolsApiKeys; export class Router { @@ -78,7 +75,7 @@ export class Router { const aiConfiguration = this.getAiConfiguration(args.query?.['ai-name']); return await new ProviderDispatcher(aiConfiguration, remoteTools).dispatch( - args.body as DispatchBody, + args.body as AiQueryRequest, ); } @@ -89,7 +86,7 @@ export class Router { throw new AIBadRequestError('Missing required query parameter: tool-name'); } - const body = args.body as InvokeRemoteToolBody | undefined; + const body = args.body as InvokeToolRequest | undefined; if (!body?.inputs) { throw new AIBadRequestError('Missing required body parameter: inputs'); diff --git a/packages/ai-proxy/src/types.ts b/packages/ai-proxy/src/types.ts new file mode 100644 index 000000000..f5ce67a78 --- /dev/null +++ b/packages/ai-proxy/src/types.ts @@ -0,0 +1,55 @@ +/** + * Shared route types for server and client. + * This file is the single source of truth for API types. + */ +import type OpenAI from 'openai'; + +// ============================================ +// OpenAI types (re-exported for convenience) +// ============================================ +export type ChatCompletionMessageParam = OpenAI.Chat.Completions.ChatCompletionMessageParam; +export type ChatCompletionTool = OpenAI.Chat.Completions.ChatCompletionTool; +export type ChatCompletionToolChoiceOption = OpenAI.Chat.Completions.ChatCompletionToolChoiceOption; +export type ChatCompletion = OpenAI.Chat.Completions.ChatCompletion; + +// ============================================ +// Route: ai-query +// ============================================ +export interface AiQueryRequest { + messages: ChatCompletionMessageParam[]; + tools?: ChatCompletionTool[]; + tool_choice?: ChatCompletionToolChoiceOption; +} + +export interface AiQueryQuery { + 'ai-name'?: string; +} + +export type AiQueryResponse = ChatCompletion; + +// ============================================ +// Route: invoke-remote-tool +// ============================================ +export interface InvokeToolRequest { + inputs: ChatCompletionMessageParam[]; +} + +export interface InvokeToolQuery { + 'tool-name': string; +} + +export type InvokeToolResponse = unknown; + +// ============================================ +// Route: remote-tools +// ============================================ +export interface RemoteToolDefinition { + name: string; + description: string; + responseFormat: 'content' | 'content_and_artifact'; + schema: Record; + sourceId: string; + sourceType: string; +} + +export type RemoteToolsResponse = RemoteToolDefinition[]; diff --git a/packages/ai-proxy/test/client.test.ts b/packages/ai-proxy/test/client.test.ts new file mode 100644 index 000000000..db53b7205 --- /dev/null +++ b/packages/ai-proxy/test/client.test.ts @@ -0,0 +1,629 @@ +import type { ChatCompletion, RemoteToolDefinition } from '../src/client'; + +import { AiProxyClient, AiProxyClientError, createAiProxyClient } from '../src/client'; + +describe('AiProxyClientError', () => { + it('creates an error with correct properties', () => { + const error = new AiProxyClientError('Test error', 404, { detail: 'Not found' }); + + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(AiProxyClientError); + expect(error.name).toBe('AiProxyClientError'); + expect(error.message).toBe('Test error'); + expect(error.status).toBe(404); + expect(error.body).toEqual({ detail: 'Not found' }); + }); + + it('preserves cause when provided', () => { + const cause = new Error('Original error'); + const error = new AiProxyClientError('Wrapped error', 500, undefined, cause); + + expect(error.cause).toBe(cause); + }); + + describe('error categorization helpers', () => { + it('identifies network errors (status 0)', () => { + const error = new AiProxyClientError('Network error', 0); + + expect(error.isNetworkError).toBe(true); + expect(error.isClientError).toBe(false); + expect(error.isServerError).toBe(false); + }); + + it('identifies client errors (4xx)', () => { + const error = new AiProxyClientError('Bad request', 400); + + expect(error.isNetworkError).toBe(false); + expect(error.isClientError).toBe(true); + expect(error.isServerError).toBe(false); + }); + + it('identifies server errors (5xx)', () => { + const error = new AiProxyClientError('Server error', 500); + + expect(error.isNetworkError).toBe(false); + expect(error.isClientError).toBe(false); + expect(error.isServerError).toBe(true); + }); + + it('does not categorize 3xx as client or server error', () => { + const error = new AiProxyClientError('Redirect', 302); + + expect(error.isNetworkError).toBe(false); + expect(error.isClientError).toBe(false); + expect(error.isServerError).toBe(false); + }); + }); +}); + +describe('AiProxyClient', () => { + describe('custom fetch mode', () => { + const mockFetch = jest.fn(); + + const createClient = (timeout?: number): AiProxyClient => { + return new AiProxyClient({ fetch: mockFetch, timeout }); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('createAiProxyClient', () => { + it('creates an AiProxyClient instance', () => { + const client = createAiProxyClient({ fetch: mockFetch }); + + expect(client).toBeInstanceOf(AiProxyClient); + }); + }); + + describe('getTools', () => { + it('makes a GET request to /remote-tools and returns tools', async () => { + const expectedTools: RemoteToolDefinition[] = [ + { + name: 'brave_search', + description: 'Search the web', + responseFormat: 'content', + schema: { type: 'object' }, + sourceId: 'brave', + sourceType: 'server', + }, + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(expectedTools), + }); + + const client = createClient(); + const result = await client.getTools(); + + expect(mockFetch).toHaveBeenCalledWith('/remote-tools', { + method: 'GET', + body: undefined, + signal: expect.any(AbortSignal), + }); + expect(result).toEqual(expectedTools); + }); + }); + + describe('chat', () => { + const validResponse: ChatCompletion = { + id: 'chatcmpl-123', + object: 'chat.completion', + created: 1234567890, + model: 'gpt-4o', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Hello!', refusal: null }, + finish_reason: 'stop', + }, + ], + } as ChatCompletion; + + it('accepts a simple string input and returns response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(validResponse), + }); + + const client = createClient(); + const result = await client.chat('Hello'); + + expect(mockFetch).toHaveBeenCalledWith('/ai-query', { + method: 'POST', + body: JSON.stringify({ + messages: [{ role: 'user', content: 'Hello' }], + tools: undefined, + tool_choice: undefined, + }), + signal: expect.any(AbortSignal), + }); + expect(result).toEqual(validResponse); + }); + + it('accepts an object input with messages and returns response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(validResponse), + }); + + const client = createClient(); + const result = await client.chat({ + messages: [ + { role: 'system', content: 'You are helpful' }, + { role: 'user', content: 'Hello' }, + ], + }); + + expect(mockFetch).toHaveBeenCalledWith('/ai-query', { + method: 'POST', + body: JSON.stringify({ + messages: [ + { role: 'system', content: 'You are helpful' }, + { role: 'user', content: 'Hello' }, + ], + tools: undefined, + tool_choice: undefined, + }), + signal: expect.any(AbortSignal), + }); + expect(result).toEqual(validResponse); + }); + + it('includes aiName as ai-name query parameter when provided', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(validResponse), + }); + + const client = createClient(); + const result = await client.chat({ + messages: [{ role: 'user', content: 'Hello' }], + aiName: 'gpt-4', + }); + + expect(mockFetch).toHaveBeenCalledWith('/ai-query?ai-name=gpt-4', { + method: 'POST', + body: JSON.stringify({ + messages: [{ role: 'user', content: 'Hello' }], + tools: undefined, + tool_choice: undefined, + }), + signal: expect.any(AbortSignal), + }); + expect(result).toEqual(validResponse); + }); + + it('does not include ai-name query parameter when aiName is empty string', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(validResponse), + }); + + const client = createClient(); + await client.chat({ + messages: [{ role: 'user', content: 'Hello' }], + aiName: '', + }); + + expect(mockFetch).toHaveBeenCalledWith('/ai-query', { + method: 'POST', + body: JSON.stringify({ + messages: [{ role: 'user', content: 'Hello' }], + tools: undefined, + tool_choice: undefined, + }), + signal: expect.any(AbortSignal), + }); + }); + + it('URL-encodes special characters in aiName', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(validResponse), + }); + + const client = createClient(); + await client.chat({ + messages: [{ role: 'user', content: 'Hello' }], + aiName: 'gpt-4o & claude', + }); + + expect(mockFetch).toHaveBeenCalledWith( + '/ai-query?ai-name=gpt-4o+%26+claude', + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('includes tools and toolChoice when provided', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(validResponse), + }); + + const client = createClient(); + const result = await client.chat({ + messages: [{ role: 'user', content: 'Search for cats' }], + tools: [ + { + type: 'function', + function: { name: 'search', description: 'Search' }, + }, + ], + toolChoice: 'auto', + }); + + expect(mockFetch).toHaveBeenCalledWith('/ai-query', { + method: 'POST', + body: JSON.stringify({ + messages: [{ role: 'user', content: 'Search for cats' }], + tools: [ + { + type: 'function', + function: { name: 'search', description: 'Search' }, + }, + ], + tool_choice: 'auto', + }), + signal: expect.any(AbortSignal), + }); + expect(result).toEqual(validResponse); + }); + }); + + describe('callTool', () => { + it('makes a POST request to /invoke-remote-tool and returns result', async () => { + const expectedResult = { result: 'search results' }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(expectedResult), + }); + + const client = createClient(); + const result = await client.callTool('brave_search', [{ role: 'user', content: 'cats' }]); + + expect(mockFetch).toHaveBeenCalledWith('/invoke-remote-tool?tool-name=brave_search', { + method: 'POST', + body: JSON.stringify({ inputs: [{ role: 'user', content: 'cats' }] }), + signal: expect.any(AbortSignal), + }); + expect(result).toEqual(expectedResult); + }); + + it('URL-encodes special characters in tool name', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({}), + }); + + const client = createClient(); + await client.callTool('tool/with&special=chars', []); + + expect(mockFetch).toHaveBeenCalledWith( + '/invoke-remote-tool?tool-name=tool%2Fwith%26special%3Dchars', + expect.objectContaining({ method: 'POST' }), + ); + }); + }); + + describe('timeout', () => { + it('uses default timeout of 30 seconds when not specified', async () => { + mockFetch.mockImplementation( + () => + new Promise((_, reject) => { + const error = new Error('Aborted'); + error.name = 'AbortError'; + setTimeout(() => reject(error), 50); + }), + ); + + const client = new AiProxyClient({ fetch: mockFetch }); // No timeout specified + + const error = (await client.getTools().catch(e => e)) as AiProxyClientError; + + expect(error).toBeInstanceOf(AiProxyClientError); + expect(error.message).toBe('GET /remote-tools timed out after 30000ms'); + expect(error.status).toBe(408); + }); + + it('uses custom timeout when specified', async () => { + mockFetch.mockImplementation( + () => + new Promise((_, reject) => { + const error = new Error('Aborted'); + error.name = 'AbortError'; + setTimeout(() => reject(error), 10); + }), + ); + + const client = createClient(5); + + const error = (await client.getTools().catch(e => e)) as AiProxyClientError; + + expect(error).toBeInstanceOf(AiProxyClientError); + expect(error.message).toBe('GET /remote-tools timed out after 5ms'); + expect(error.status).toBe(408); + }); + }); + + describe('concurrent requests', () => { + it('handles concurrent requests with independent abort signals', async () => { + let resolveFirst: (value: unknown) => void; + let resolveSecond: (value: unknown) => void; + + mockFetch + .mockImplementationOnce( + () => + new Promise(resolve => { + resolveFirst = resolve; + }), + ) + .mockImplementationOnce( + () => + new Promise(resolve => { + resolveSecond = resolve; + }), + ); + + const client = createClient(); + + const promise1 = client.getTools(); + const promise2 = client.chat('Hello'); + + expect(mockFetch).toHaveBeenCalledTimes(2); + + // Resolve in reverse order to verify independence + resolveSecond!({ ok: true, json: () => Promise.resolve({ id: 'chat-123' }) }); + resolveFirst!({ ok: true, json: () => Promise.resolve([{ name: 'tool1' }]) }); + + const [tools, chat] = await Promise.all([promise1, promise2]); + + expect(tools).toEqual([{ name: 'tool1' }]); + expect(chat).toEqual({ id: 'chat-123' }); + }); + }); + + describe('error handling', () => { + it('throws AiProxyClientError with status 401 on authentication failure', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + json: () => Promise.resolve({ error: 'Unauthorized' }), + }); + + const client = createClient(); + + const error = (await client.chat('Hello').catch(e => e)) as AiProxyClientError; + + expect(error).toBeInstanceOf(AiProxyClientError); + expect(error.status).toBe(401); + expect(error.body).toEqual({ error: 'Unauthorized' }); + expect(error.message).toBe('POST /ai-query failed with status 401'); + expect(error.isClientError).toBe(true); + }); + + it('throws AiProxyClientError with status 404 when resource not found', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + json: () => Promise.resolve({ error: 'Not found' }), + }); + + const client = createClient(); + + const error = (await client.callTool('non_existent', []).catch(e => e)) as AiProxyClientError; + + expect(error).toBeInstanceOf(AiProxyClientError); + expect(error.status).toBe(404); + expect(error.body).toEqual({ error: 'Not found' }); + expect(error.isClientError).toBe(true); + }); + + it('throws AiProxyClientError with status 422 on validation error', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 422, + json: () => Promise.resolve({ error: 'Validation failed' }), + }); + + const client = createClient(); + + const error = (await client.chat({ messages: [] }).catch(e => e)) as AiProxyClientError; + + expect(error).toBeInstanceOf(AiProxyClientError); + expect(error.status).toBe(422); + expect(error.body).toEqual({ error: 'Validation failed' }); + expect(error.isClientError).toBe(true); + }); + + it('handles non-JSON error responses by falling back to text', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + json: () => Promise.reject(new Error('Not JSON')), + text: () => Promise.resolve('Internal Server Error'), + }); + + const client = createClient(); + + const error = (await client.getTools().catch(e => e)) as AiProxyClientError; + + expect(error).toBeInstanceOf(AiProxyClientError); + expect(error.status).toBe(500); + expect(error.body).toBe('Internal Server Error'); + expect(error.isServerError).toBe(true); + }); + + it('throws AiProxyClientError with status 0 on network error', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network unreachable')); + + const client = createClient(); + + const error = (await client.getTools().catch(e => e)) as AiProxyClientError; + + expect(error).toBeInstanceOf(AiProxyClientError); + expect(error.status).toBe(0); + expect(error.message).toBe('GET /remote-tools network error: Network unreachable'); + expect(error.isNetworkError).toBe(true); + }); + + it('handles non-Error exceptions in network failures', async () => { + mockFetch.mockRejectedValueOnce('string error'); + + const client = createClient(); + + const error = (await client.getTools().catch(e => e)) as AiProxyClientError; + + expect(error).toBeInstanceOf(AiProxyClientError); + expect(error.status).toBe(0); + expect(error.message).toBe('GET /remote-tools network error: string error'); + }); + + it('handles error responses when both json() and text() fail', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 503, + json: () => Promise.reject(new Error('Parse error')), + text: () => Promise.reject(new Error('Read error')), + }); + + const client = createClient(); + + const error = (await client.getTools().catch(e => e)) as AiProxyClientError; + + expect(error).toBeInstanceOf(AiProxyClientError); + expect(error.status).toBe(503); + expect(error.message).toBe('GET /remote-tools failed with status 503'); + expect(error.body).toBeUndefined(); + }); + + it('throws error when successful response is not valid JSON', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.reject(new SyntaxError("Unexpected token '<'")), + }); + + const client = createClient(); + + const error = (await client.getTools().catch(e => e)) as AiProxyClientError; + + expect(error).toBeInstanceOf(AiProxyClientError); + expect(error.status).toBe(200); + expect(error.message).toBe('GET /remote-tools: Server returned 200 but response is not valid JSON'); + }); + + it('preserves error cause for network errors', async () => { + const originalError = new Error('Connection refused'); + mockFetch.mockRejectedValueOnce(originalError); + + const client = createClient(); + + const error = (await client.getTools().catch(e => e)) as AiProxyClientError; + + expect(error).toBeInstanceOf(AiProxyClientError); + expect(error.cause).toBe(originalError); + }); + }); + }); + + describe('simple mode', () => { + const baseUrl = 'https://my-agent.com/forest'; + + const mockFetch = jest.fn(); + const originalFetch = global.fetch; + + beforeAll(() => { + global.fetch = mockFetch; + }); + + afterAll(() => { + global.fetch = originalFetch; + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('makes requests with baseUrl and Content-Type header', async () => { + const expectedTools: RemoteToolDefinition[] = [{ name: 'test' }] as RemoteToolDefinition[]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(expectedTools), + }); + + const client = new AiProxyClient({ baseUrl }); + const result = await client.getTools(); + + expect(mockFetch).toHaveBeenCalledWith(`${baseUrl}/remote-tools`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + body: undefined, + signal: expect.any(AbortSignal), + }); + expect(result).toEqual(expectedTools); + }); + + it('removes trailing slash from baseUrl', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve([]), + }); + + const client = new AiProxyClient({ baseUrl: 'https://example.com/forest/' }); + await client.getTools(); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://example.com/forest/remote-tools', + expect.objectContaining({ method: 'GET' }), + ); + }); + + it('includes custom headers merged with Content-Type', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve([]), + }); + + const client = new AiProxyClient({ + baseUrl, + headers: { Authorization: 'Bearer token', 'X-Custom': 'value' }, + }); + await client.getTools(); + + expect(mockFetch).toHaveBeenCalledWith(`${baseUrl}/remote-tools`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer token', + 'X-Custom': 'value', + }, + body: undefined, + signal: expect.any(AbortSignal), + }); + }); + + it('allows custom headers to override Content-Type', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve([]), + }); + + const client = new AiProxyClient({ + baseUrl, + headers: { 'Content-Type': 'text/plain' }, + }); + await client.getTools(); + + expect(mockFetch).toHaveBeenCalledWith( + `${baseUrl}/remote-tools`, + expect.objectContaining({ + headers: { 'Content-Type': 'text/plain' }, + }), + ); + }); + }); +}); diff --git a/packages/ai-proxy/test/provider-dispatcher.test.ts b/packages/ai-proxy/test/provider-dispatcher.test.ts index c819fb1bc..961794143 100644 --- a/packages/ai-proxy/test/provider-dispatcher.test.ts +++ b/packages/ai-proxy/test/provider-dispatcher.test.ts @@ -1,4 +1,4 @@ -import type { DispatchBody } from '../src'; +import type { AiQueryRequest } from '../src'; import { convertToOpenAIFunction } from '@langchain/core/utils/function_calling'; @@ -56,8 +56,8 @@ describe('ProviderDispatcher', () => { describe('when AI is not configured', () => { it('should throw AINotConfiguredError', async () => { const dispatcher = new ProviderDispatcher(null, new RemoteTools(apiKeys)); - await expect(dispatcher.dispatch({} as DispatchBody)).rejects.toThrow(AINotConfiguredError); - await expect(dispatcher.dispatch({} as DispatchBody)).rejects.toThrow( + await expect(dispatcher.dispatch({} as AiQueryRequest)).rejects.toThrow(AINotConfiguredError); + await expect(dispatcher.dispatch({} as AiQueryRequest)).rejects.toThrow( 'AI is not configured. Please call addAI() on your agent.', ); }); @@ -79,7 +79,7 @@ describe('ProviderDispatcher', () => { const response = await dispatcher.dispatch({ tools: [], messages: [], - } as unknown as DispatchBody); + } as unknown as AiQueryRequest); // Response is the raw OpenAI response (via __includeRawResponse) expect(response).toEqual(mockOpenAIResponse); @@ -103,7 +103,7 @@ describe('ProviderDispatcher', () => { tools: [], messages, tool_choice: 'auto', - } as unknown as DispatchBody); + } as unknown as AiQueryRequest); // When no tools, invoke is called directly with messages expect(invokeMock).toHaveBeenCalledWith(messages); @@ -124,7 +124,7 @@ describe('ProviderDispatcher', () => { invokeMock.mockRejectedValueOnce(new Error('OpenAI error')); await expect( - dispatcher.dispatch({ tools: [], messages: [] } as unknown as DispatchBody), + dispatcher.dispatch({ tools: [], messages: [] } as unknown as AiQueryRequest), ).rejects.toThrow('Error while calling OpenAI: OpenAI error'); }); @@ -138,7 +138,7 @@ describe('ProviderDispatcher', () => { invokeMock.mockRejectedValueOnce(rateLimitError); await expect( - dispatcher.dispatch({ tools: [], messages: [] } as unknown as DispatchBody), + dispatcher.dispatch({ tools: [], messages: [] } as unknown as AiQueryRequest), ).rejects.toThrow('Rate limit exceeded: Too many requests'); }); @@ -152,7 +152,7 @@ describe('ProviderDispatcher', () => { invokeMock.mockRejectedValueOnce(authError); await expect( - dispatcher.dispatch({ tools: [], messages: [] } as unknown as DispatchBody), + dispatcher.dispatch({ tools: [], messages: [] } as unknown as AiQueryRequest), ).rejects.toThrow('Authentication failed: Invalid API key'); }); }); @@ -169,7 +169,7 @@ describe('ProviderDispatcher', () => { }); await expect( - dispatcher.dispatch({ tools: [], messages: [] } as unknown as DispatchBody), + dispatcher.dispatch({ tools: [], messages: [] } as unknown as AiQueryRequest), ).rejects.toThrow( 'OpenAI response missing raw response data. This may indicate an API change.', ); @@ -202,7 +202,7 @@ describe('ProviderDispatcher', () => { }, ], messages, - } as unknown as DispatchBody); + } as unknown as AiQueryRequest); // When tools are provided, bindTools is called first expect(bindToolsMock).toHaveBeenCalledWith( @@ -241,7 +241,7 @@ describe('ProviderDispatcher', () => { }, ], messages, - } as unknown as DispatchBody); + } as unknown as AiQueryRequest); // When tools are provided, bindTools is called expect(bindToolsMock).toHaveBeenCalledWith( diff --git a/packages/ai-proxy/test/router.test.ts b/packages/ai-proxy/test/router.test.ts index a5800e3a7..f10559713 100644 --- a/packages/ai-proxy/test/router.test.ts +++ b/packages/ai-proxy/test/router.test.ts @@ -1,5 +1,5 @@ -import type { DispatchBody, Route } from '../src'; -import type { InvokeRemoteToolBody } from '../src/router'; +import type { AiQueryRequest, Route } from '../src'; +import type { InvokeToolRequest } from '../src/types'; import type { Logger } from '@forestadmin/datasource-toolkit'; import { AIUnprocessableError, Router } from '../src'; @@ -61,7 +61,7 @@ describe('route', () => { await router.route({ route: 'ai-query', - body: { tools: [], tool_choice: 'required', messages: [] } as unknown as DispatchBody, + body: { tools: [], tool_choice: 'required', messages: [] } as unknown as AiQueryRequest, }); expect(dispatchMock).toHaveBeenCalledWith({ @@ -91,7 +91,7 @@ describe('route', () => { await router.route({ route: 'ai-query', query: { 'ai-name': 'gpt3' }, - body: { tools: [], tool_choice: 'required', messages: [] } as unknown as DispatchBody, + body: { tools: [], tool_choice: 'required', messages: [] } as unknown as AiQueryRequest, }); expect(ProviderDispatcherMock).toHaveBeenCalledWith(gpt3Config, expect.anything()); @@ -116,7 +116,7 @@ describe('route', () => { await router.route({ route: 'ai-query', - body: { tools: [], tool_choice: 'required', messages: [] } as unknown as DispatchBody, + body: { tools: [], tool_choice: 'required', messages: [] } as unknown as AiQueryRequest, }); expect(ProviderDispatcherMock).toHaveBeenCalledWith(gpt4Config, expect.anything()); @@ -139,7 +139,7 @@ describe('route', () => { await router.route({ route: 'ai-query', query: { 'ai-name': 'non-existent' }, - body: { tools: [], tool_choice: 'required', messages: [] } as unknown as DispatchBody, + body: { tools: [], tool_choice: 'required', messages: [] } as unknown as AiQueryRequest, }); expect(mockLogger).toHaveBeenCalledWith( @@ -182,7 +182,7 @@ describe('route', () => { router.route({ route: 'invoke-remote-tool', query: { 'tool-name': 'tool-name' }, - body: {} as InvokeRemoteToolBody, + body: {} as InvokeToolRequest, }), ).rejects.toThrow('Missing required body parameter: inputs'); });