From ba3bcac0cb84a5c8abde7bffa1b92033af749878 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 26 Jan 2026 11:38:59 +0100 Subject: [PATCH 01/14] feat(ai-proxy): add HTTP client for frontend and agent-client usage Add AiProxyClient class with a clean, UX-friendly API: - chat(input): accepts a simple string or ChatInput object - getTools(): list available remote tools - callTool(name, inputs): execute a specific tool API improvements: - Simplified chat() that accepts just a string for common use cases - Consistent camelCase naming (toolChoice, aiName) - Renamed apiKey (more generic than openAiApiKey) - Clear method names: chat(), getTools(), callTool() Co-Authored-By: Claude Opus 4.5 --- packages/ai-proxy/README.md | 201 +++++++++++++++ packages/ai-proxy/package.json | 44 +++- packages/ai-proxy/src/client/index.ts | 173 +++++++++++++ packages/ai-proxy/src/client/types.ts | 45 ++++ packages/ai-proxy/src/index.ts | 3 + packages/ai-proxy/test/client.test.ts | 357 ++++++++++++++++++++++++++ 6 files changed, 819 insertions(+), 4 deletions(-) create mode 100644 packages/ai-proxy/src/client/index.ts create mode 100644 packages/ai-proxy/src/client/types.ts create mode 100644 packages/ai-proxy/test/client.test.ts diff --git a/packages/ai-proxy/README.md b/packages/ai-proxy/README.md index e69de29bb2..40c9c0ea5f 100644 --- a/packages/ai-proxy/README.md +++ b/packages/ai-proxy/README.md @@ -0,0 +1,201 @@ +# @forestadmin/ai-proxy + +AI Proxy package for Forest Admin agents. Provides both server-side routing and a client SDK for frontend/agent-client usage. + +## Installation + +### Client only (frontend) + +```bash +npm install @forestadmin/ai-proxy +``` + +No additional dependencies required. + +### Server-side (with Router, ProviderDispatcher) + +```bash +npm install @forestadmin/ai-proxy @langchain/core @langchain/openai @langchain/community @langchain/mcp-adapters +``` + +Langchain packages are optional peer dependencies - install them only if you use the server-side features. + +## Client Usage + +The `AiProxyClient` provides a simple API to interact with the AI proxy from a frontend or agent-client. + +> **Note:** Import from `@forestadmin/ai-proxy/client` for a lightweight client without langchain dependencies. + +### Setup + +```typescript +// Lightweight import - no langchain dependencies (recommended for frontend) +import { createAiProxyClient } from '@forestadmin/ai-proxy/client'; + +const client = createAiProxyClient({ + baseUrl: 'https://my-agent.com/forest', + apiKey: 'sk-...', // Optional: OpenAI API key for authentication + timeout: 30000, // Optional: request timeout in ms (default: 30000) +}); +``` + +### Chat with AI + +Send messages to the AI and get completions. + +#### Simple usage + +```typescript +// Just send a string - it will be wrapped as a user message +const response = await client.chat('What is the weather today?'); + +console.log(response.choices[0].message.content); +// => "I don't have access to real-time weather data..." +``` + +#### Advanced usage + +```typescript +const response = await client.chat({ + messages: [ + { role: 'system', content: 'You are a helpful assistant.' }, + { role: 'user', content: 'Search for cats' }, + ], + tools: [ + { + type: 'function', + function: { + name: 'brave_search', + description: 'Search the web', + parameters: { type: 'object', properties: { query: { type: 'string' } } }, + }, + }, + ], + toolChoice: 'auto', + aiName: 'gpt-4', // Name of the AI configuration on the server +}); + +// Check if the AI wants to call a tool +if (response.choices[0].message.tool_calls) { + const toolCall = response.choices[0].message.tool_calls[0]; + console.log(`AI wants to call: ${toolCall.function.name}`); +} +``` + +### List Available Tools + +Get the list of remote tools configured on the server. + +```typescript +const tools = await client.getTools(); + +console.log(tools); +// [ +// { +// name: 'brave_search', +// description: 'Search the web using Brave Search', +// schema: { ... }, +// sourceId: 'brave_search', +// sourceType: 'server' +// } +// ] +``` + +### Call a Tool + +Execute a remote tool by name. + +```typescript +const result = await client.callTool('brave_search', [ + { role: 'user', content: 'cats' } +]); + +console.log(result); +// Search results from Brave Search +``` + +### Error Handling + +All methods throw `AiProxyClientError` on failure. + +```typescript +import { AiProxyClientError } from '@forestadmin/ai-proxy/client'; + +try { + await client.chat('Hello'); +} catch (error) { + if (error instanceof AiProxyClientError) { + console.error(`Error ${error.status}: ${error.message}`); + console.error('Response body:', error.body); + } +} +``` + +#### Error status codes + +| Status | Description | +|--------|-------------| +| 0 | Network error | +| 401 | Authentication failed | +| 404 | Resource not found | +| 408 | Request timeout | +| 422 | Validation error | + +## API Reference + +### `createAiProxyClient(config)` + +Creates a new AI Proxy client instance. + +#### Config options + +| Option | Type | Required | Default | Description | +|--------|------|----------|---------|-------------| +| `baseUrl` | `string` | Yes | - | Base URL of the AI proxy server | +| `apiKey` | `string` | No | - | API key for authentication (used in `chat()`) | +| `timeout` | `number` | No | 30000 | Request timeout in milliseconds | +| `fetch` | `typeof fetch` | No | `fetch` | Custom fetch implementation | + +### `client.chat(input)` + +Send a chat message to the AI. + +- **input**: `string | ChatInput` - A simple string or an object with: + - `messages`: Array of chat messages + - `tools?`: Array of tool definitions + - `toolChoice?`: Tool choice option (`'auto'`, `'none'`, `'required'`, or specific tool) + - `aiName?`: Name of the AI configuration to use + +- **Returns**: `Promise` - OpenAI-compatible chat completion response + +### `client.getTools()` + +Get available remote tools. + +- **Returns**: `Promise` + +### `client.callTool(toolName, inputs)` + +Call a remote tool. + +- **toolName**: `string` - Name of the tool to call +- **inputs**: `ChatCompletionMessage[]` - Input messages for the tool + +- **Returns**: `Promise` - Tool execution result + +## TypeScript Support + +Full TypeScript support with exported types: + +```typescript +import type { + AiProxyClientConfig, + ChatInput, + ChatCompletion, + ChatCompletionMessageParam, + ChatCompletionTool, + RemoteToolDefinition, +} from '@forestadmin/ai-proxy/client'; + +import { AiProxyClientError } from '@forestadmin/ai-proxy/client'; +``` diff --git a/packages/ai-proxy/package.json b/packages/ai-proxy/package.json index 6c8abaa01a..63a0754aab 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" @@ -13,18 +24,43 @@ }, "dependencies": { "@forestadmin/datasource-toolkit": "1.50.1", + "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": { "@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 0000000000..c3acc65da1 --- /dev/null +++ b/packages/ai-proxy/src/client/index.ts @@ -0,0 +1,173 @@ +import type { + AiProxyClientConfig, + ChatCompletion, + ChatCompletionMessageParam, + ChatInput, + RemoteToolDefinition, +} from './types'; + +import { AiProxyClientError } from './types'; + +export * from './types'; + +const DEFAULT_TIMEOUT = 30_000; + +export class AiProxyClient { + private readonly baseUrl: string; + private readonly apiKey?: string; + private readonly timeout: number; + private readonly fetchFn: typeof fetch; + + constructor(config: AiProxyClientConfig) { + this.baseUrl = config.baseUrl.replace(/\/$/, ''); + this.apiKey = config.apiKey; + this.timeout = config.timeout ?? DEFAULT_TIMEOUT; + this.fetchFn = config.fetch ?? fetch; + } + + /** + * 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, + }, + auth: true, + }); + } + + /** + * 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; + auth?: boolean; + }): Promise { + const { method, path, searchParams, body, auth } = params; + + let url = `${this.baseUrl}${path}`; + + if (searchParams && searchParams.toString()) { + url += `?${searchParams.toString()}`; + } + + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (auth && this.apiKey) { + headers.Authorization = `Bearer ${this.apiKey}`; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + try { + const response = await this.fetchFn(url, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + signal: controller.signal, + }); + + if (!response.ok) { + let responseBody: unknown; + + try { + responseBody = await response.json(); + } catch { + responseBody = await response.text().catch(() => undefined); + } + + throw new AiProxyClientError( + `Request failed with status ${response.status}`, + response.status, + responseBody, + ); + } + + return (await response.json()) as T; + } catch (error) { + if (error instanceof AiProxyClientError) { + throw error; + } + + if (error instanceof Error && error.name === 'AbortError') { + throw new AiProxyClientError(`Request timeout after ${this.timeout}ms`, 408); + } + + throw new AiProxyClientError( + `Network error: ${error instanceof Error ? error.message : String(error)}`, + 0, + ); + } 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 0000000000..1271acb92e --- /dev/null +++ b/packages/ai-proxy/src/client/types.ts @@ -0,0 +1,45 @@ +/** + * Standalone client types - no runtime dependencies. + * Uses OpenAI types via type-only imports (erased at compile time). + */ +import type OpenAI from 'openai'; + +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; + +export interface AiProxyClientConfig { + baseUrl: string; + apiKey?: string; + timeout?: number; + fetch?: typeof fetch; +} + +export interface ChatInput { + messages: ChatCompletionMessageParam[]; + tools?: ChatCompletionTool[]; + toolChoice?: ChatCompletionToolChoiceOption; + aiName?: string; +} + +export interface RemoteToolDefinition { + name: string; + description: string; + responseFormat: 'content' | 'content_and_artifact'; + schema: Record; + sourceId: string; + sourceType: string; +} + +export class AiProxyClientError extends Error { + readonly status: number; + readonly body?: unknown; + + constructor(message: string, status: number, body?: unknown) { + super(message); + this.name = 'AiProxyClientError'; + this.status = status; + this.body = body; + } +} diff --git a/packages/ai-proxy/src/index.ts b/packages/ai-proxy/src/index.ts index 2cc9c9892c..d477e69cae 100644 --- a/packages/ai-proxy/src/index.ts +++ b/packages/ai-proxy/src/index.ts @@ -9,6 +9,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/test/client.test.ts b/packages/ai-proxy/test/client.test.ts new file mode 100644 index 0000000000..20472afedc --- /dev/null +++ b/packages/ai-proxy/test/client.test.ts @@ -0,0 +1,357 @@ +import type { AiProxyClientConfig, ChatCompletion, RemoteToolDefinition } from '../src/client'; + +import { AiProxyClient, AiProxyClientError, createAiProxyClient } from '../src/client'; + +describe('AiProxyClient', () => { + const baseUrl = 'https://my-agent.com/forest'; + const apiKey = 'sk-test-key'; + + const mockFetch = jest.fn(); + + const createClient = (config: Partial = {}): AiProxyClient => { + return new AiProxyClient({ + baseUrl, + apiKey, + fetch: mockFetch, + ...config, + }); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('createAiProxyClient', () => { + it('creates an AiProxyClient instance', () => { + const client = createAiProxyClient({ + baseUrl, + apiKey, + fetch: mockFetch, + }); + + expect(client).toBeInstanceOf(AiProxyClient); + }); + }); + + describe('getTools', () => { + it('makes a GET request to /remote-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(`${baseUrl}/remote-tools`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + body: undefined, + signal: expect.any(AbortSignal), + }); + expect(result).toEqual(expectedTools); + }); + }); + + describe('chat', () => { + it('accepts a simple string input', async () => { + const expectedResponse = { + 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; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(expectedResponse), + }); + + const client = createClient(); + const result = await client.chat('Hello'); + + expect(mockFetch).toHaveBeenCalledWith(`${baseUrl}/ai-query`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + messages: [{ role: 'user', content: 'Hello' }], + tools: undefined, + tool_choice: undefined, + }), + signal: expect.any(AbortSignal), + }); + expect(result).toEqual(expectedResponse); + }); + + it('accepts an object input with messages', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({}), + }); + + const client = createClient(); + await client.chat({ + messages: [ + { role: 'system', content: 'You are helpful' }, + { role: 'user', content: 'Hello' }, + ], + }); + + expect(mockFetch).toHaveBeenCalledWith( + `${baseUrl}/ai-query`, + expect.objectContaining({ + body: JSON.stringify({ + messages: [ + { role: 'system', content: 'You are helpful' }, + { role: 'user', content: 'Hello' }, + ], + tools: undefined, + tool_choice: undefined, + }), + }), + ); + }); + + it('includes aiName as ai-name query parameter when provided', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({}), + }); + + const client = createClient(); + await client.chat({ + messages: [{ role: 'user', content: 'Hello' }], + aiName: 'gpt-4', + }); + + expect(mockFetch).toHaveBeenCalledWith( + `${baseUrl}/ai-query?ai-name=gpt-4`, + expect.anything(), + ); + }); + + it('includes tools and toolChoice when provided', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({}), + }); + + const client = createClient(); + await client.chat({ + messages: [{ role: 'user', content: 'Search for cats' }], + tools: [ + { + type: 'function', + function: { name: 'search', description: 'Search' }, + }, + ], + toolChoice: 'auto', + }); + + expect(mockFetch).toHaveBeenCalledWith( + `${baseUrl}/ai-query`, + expect.objectContaining({ + body: JSON.stringify({ + messages: [{ role: 'user', content: 'Search for cats' }], + tools: [ + { + type: 'function', + function: { name: 'search', description: 'Search' }, + }, + ], + tool_choice: 'auto', + }), + }), + ); + }); + + it('does not include Authorization header when apiKey is not provided', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({}), + }); + + const client = createClient({ apiKey: undefined }); + await client.chat('Hello'); + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: { 'Content-Type': 'application/json' }, + }), + ); + }); + }); + + describe('callTool', () => { + it('makes a POST request to /invoke-remote-tool with tool-name query param', 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( + `${baseUrl}/invoke-remote-tool?tool-name=brave_search`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ inputs: [{ role: 'user', content: 'cats' }] }), + signal: expect.any(AbortSignal), + }, + ); + expect(result).toEqual(expectedResult); + }); + + it('does not include Authorization header (no auth required)', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({}), + }); + + const client = createClient(); + await client.callTool('test_tool', []); + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: { 'Content-Type': 'application/json' }, + }), + ); + }); + }); + + 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(); + + await expect(client.chat('Hello')).rejects.toMatchObject({ + status: 401, + body: { error: 'Unauthorized' }, + }); + }); + + 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(); + + await expect(client.callTool('non_existent', [])).rejects.toMatchObject({ + status: 404, + body: { error: 'Not found' }, + }); + }); + + 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(); + + await expect(client.chat({ messages: [] })).rejects.toMatchObject({ + status: 422, + body: { error: 'Validation failed' }, + }); + }); + + it('handles non-JSON error responses', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + json: () => Promise.reject(new Error('Not JSON')), + text: () => Promise.resolve('Internal Server Error'), + }); + + const client = createClient(); + + await expect(client.getTools()).rejects.toMatchObject({ + status: 500, + body: 'Internal Server Error', + }); + }); + + it('throws AiProxyClientError with status 408 on timeout', async () => { + mockFetch.mockImplementation( + () => + new Promise((_, reject) => { + const error = new Error('Aborted'); + error.name = 'AbortError'; + setTimeout(() => reject(error), 10); + }), + ); + + const client = createClient({ timeout: 5 }); + + await expect(client.getTools()).rejects.toMatchObject({ + status: 408, + message: 'Request timeout after 5ms', + }); + }); + + it('throws AiProxyClientError with status 0 on network error', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network unreachable')); + + const client = createClient(); + + await expect(client.getTools()).rejects.toMatchObject({ + status: 0, + message: 'Network error: Network unreachable', + }); + }); + }); + + describe('URL handling', () => { + it('removes trailing slash from baseUrl', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve([]), + }); + + const client = createClient({ baseUrl: 'https://example.com/forest/' }); + await client.getTools(); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://example.com/forest/remote-tools', + expect.anything(), + ); + }); + }); +}); From 2732c2b6b1fd78dbdcab18851c594be377b86f72 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 26 Jan 2026 12:27:15 +0100 Subject: [PATCH 02/14] refactor(ai-proxy): unify server/client types in shared routes.ts Create a single source of truth for API types by introducing routes.ts. This file defines types for all routes (ai-query, invoke-remote-tool, remote-tools) and is imported by both server and client code. - Add src/routes.ts with shared OpenAI and route types - Update router.ts to use AiQueryRequest and InvokeToolRequest - Update provider-dispatcher.ts to import from routes.ts - Update remote-tools.ts to use RemoteToolDefinition - Update client/types.ts to re-export from routes.ts - Maintain backwards compatibility with legacy type aliases Co-Authored-By: Claude Opus 4.5 --- packages/ai-proxy/src/client/index.ts | 18 ++++--- packages/ai-proxy/src/client/types.ts | 35 +++++++------ packages/ai-proxy/src/provider-dispatcher.ts | 38 +++++++++----- packages/ai-proxy/src/remote-tools.ts | 11 ++-- packages/ai-proxy/src/router.ts | 17 +++--- packages/ai-proxy/src/routes.ts | 55 ++++++++++++++++++++ packages/ai-proxy/test/router.test.ts | 14 ++--- 7 files changed, 129 insertions(+), 59 deletions(-) create mode 100644 packages/ai-proxy/src/routes.ts diff --git a/packages/ai-proxy/src/client/index.ts b/packages/ai-proxy/src/client/index.ts index c3acc65da1..e3ae844d33 100644 --- a/packages/ai-proxy/src/client/index.ts +++ b/packages/ai-proxy/src/client/index.ts @@ -1,9 +1,10 @@ import type { AiProxyClientConfig, - ChatCompletion, + AiQueryResponse, ChatCompletionMessageParam, ChatInput, - RemoteToolDefinition, + InvokeToolResponse, + RemoteToolsResponse, } from './types'; import { AiProxyClientError } from './types'; @@ -28,8 +29,8 @@ export class AiProxyClient { /** * Get the list of available remote tools. */ - async getTools(): Promise { - return this.request({ + async getTools(): Promise { + return this.request({ method: 'GET', path: '/remote-tools', }); @@ -53,7 +54,7 @@ export class AiProxyClient { * }); * ``` */ - async chat(input: string | ChatInput): Promise { + async chat(input: string | ChatInput): Promise { const normalized: ChatInput = typeof input === 'string' ? { messages: [{ role: 'user', content: input }] } : input; @@ -63,7 +64,7 @@ export class AiProxyClient { searchParams.set('ai-name', normalized.aiName); } - return this.request({ + return this.request({ method: 'POST', path: '/ai-query', searchParams, @@ -86,7 +87,10 @@ export class AiProxyClient { * ]); * ``` */ - async callTool(toolName: string, inputs: ChatCompletionMessageParam[]): Promise { + async callTool( + toolName: string, + inputs: ChatCompletionMessageParam[], + ): Promise { const searchParams = new URLSearchParams(); searchParams.set('tool-name', toolName); diff --git a/packages/ai-proxy/src/client/types.ts b/packages/ai-proxy/src/client/types.ts index 1271acb92e..bbeeb62a56 100644 --- a/packages/ai-proxy/src/client/types.ts +++ b/packages/ai-proxy/src/client/types.ts @@ -1,14 +1,28 @@ /** * Standalone client types - no runtime dependencies. - * Uses OpenAI types via type-only imports (erased at compile time). + * Re-exports shared route types and defines client-specific types. */ -import type OpenAI from 'openai'; +import type { + ChatCompletionMessageParam, + ChatCompletionTool, + ChatCompletionToolChoiceOption, +} from '../routes'; -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; +// Re-export shared route types for client usage +export type { + ChatCompletionMessageParam, + ChatCompletionTool, + ChatCompletionToolChoiceOption, + ChatCompletion, + AiQueryRequest, + AiQueryResponse, + InvokeToolRequest, + InvokeToolResponse, + RemoteToolDefinition, + RemoteToolsResponse, +} from '../routes'; +// Client-specific types export interface AiProxyClientConfig { baseUrl: string; apiKey?: string; @@ -23,15 +37,6 @@ export interface ChatInput { aiName?: string; } -export interface RemoteToolDefinition { - name: string; - description: string; - responseFormat: 'content' | 'content_and_artifact'; - schema: Record; - sourceId: string; - sourceType: string; -} - export class AiProxyClientError extends Error { readonly status: number; readonly body?: unknown; diff --git a/packages/ai-proxy/src/provider-dispatcher.ts b/packages/ai-proxy/src/provider-dispatcher.ts index 5f04f32884..7cd7e8e505 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 './routes'; 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,16 +73,21 @@ 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; -}; +// Re-export types from routes.ts for backwards compatibility +export type { + AiQueryRequest, + AiQueryResponse, + ChatCompletion, + ChatCompletionMessageParam, + ChatCompletionTool, + ChatCompletionToolChoiceOption, +} from './routes'; + +// Legacy type aliases for backwards compatibility +export type DispatchBody = AiQueryRequest; +export type ChatCompletionResponse = AiQueryResponse; +export type { ChatCompletionMessageParam as ChatCompletionMessage } from './routes'; +export type { ChatCompletionToolChoiceOption as ChatCompletionToolChoice } from './routes'; export class ProviderDispatcher { private readonly chatModel: ChatOpenAI | null = null; @@ -96,7 +106,7 @@ export class ProviderDispatcher { } } - async dispatch(body: DispatchBody): Promise { + async dispatch(body: AiQueryRequest): Promise { if (!this.chatModel) { throw new AINotConfiguredError(); } @@ -110,7 +120,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 +149,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 5883d5344d..2acb537f57 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 './routes'; 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 049d760a2b..efef16ebee 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 './routes'; 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/routes.ts b/packages/ai-proxy/src/routes.ts new file mode 100644 index 0000000000..f5ce67a78e --- /dev/null +++ b/packages/ai-proxy/src/routes.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/router.test.ts b/packages/ai-proxy/test/router.test.ts index a5800e3a78..624f9c90cc 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/routes'; 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'); }); From 9eb9b00d0aa11e159096355b55293d67a6b5fd56 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 26 Jan 2026 12:48:44 +0100 Subject: [PATCH 03/14] chore(ai-proxy): move datasource-toolkit to devDependencies The dependency is only used for type imports (Logger type), so it doesn't need to be installed by consumers at runtime. Co-Authored-By: Claude Opus 4.5 --- packages/ai-proxy/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ai-proxy/package.json b/packages/ai-proxy/package.json index 63a0754aab..912bb27a79 100644 --- a/packages/ai-proxy/package.json +++ b/packages/ai-proxy/package.json @@ -23,7 +23,6 @@ "directory": "packages/ai-proxy" }, "dependencies": { - "@forestadmin/datasource-toolkit": "1.50.1", "zod": "^4.3.5" }, "peerDependencies": { @@ -51,6 +50,7 @@ } }, "devDependencies": { + "@forestadmin/datasource-toolkit": "1.50.1", "@langchain/community": "1.1.4", "@langchain/core": "1.1.15", "@langchain/langgraph": "^1.1.0", From b1d6f20f36726fc125d4fbfb1c26e5a0e2cbcc8d Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 26 Jan 2026 13:01:31 +0100 Subject: [PATCH 04/14] refactor(ai-proxy): remove backwards compatibility type aliases Remove legacy type exports (DispatchBody, ChatCompletionResponse, etc.) since there are no external consumers yet. Co-Authored-By: Claude Opus 4.5 --- packages/ai-proxy/src/index.ts | 1 + packages/ai-proxy/src/provider-dispatcher.ts | 16 -------------- .../ai-proxy/test/provider-dispatcher.test.ts | 22 +++++++++---------- 3 files changed, 12 insertions(+), 27 deletions(-) diff --git a/packages/ai-proxy/src/index.ts b/packages/ai-proxy/src/index.ts index d477e69cae..accd774a4c 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 './routes'; export * from './provider-dispatcher'; export * from './remote-tools'; export * from './router'; diff --git a/packages/ai-proxy/src/provider-dispatcher.ts b/packages/ai-proxy/src/provider-dispatcher.ts index 7cd7e8e505..7a92906569 100644 --- a/packages/ai-proxy/src/provider-dispatcher.ts +++ b/packages/ai-proxy/src/provider-dispatcher.ts @@ -73,22 +73,6 @@ export type OpenAiConfiguration = BaseAiConfiguration & export type AiProvider = 'openai'; export type AiConfiguration = OpenAiConfiguration; -// Re-export types from routes.ts for backwards compatibility -export type { - AiQueryRequest, - AiQueryResponse, - ChatCompletion, - ChatCompletionMessageParam, - ChatCompletionTool, - ChatCompletionToolChoiceOption, -} from './routes'; - -// Legacy type aliases for backwards compatibility -export type DispatchBody = AiQueryRequest; -export type ChatCompletionResponse = AiQueryResponse; -export type { ChatCompletionMessageParam as ChatCompletionMessage } from './routes'; -export type { ChatCompletionToolChoiceOption as ChatCompletionToolChoice } from './routes'; - export class ProviderDispatcher { private readonly chatModel: ChatOpenAI | null = null; diff --git a/packages/ai-proxy/test/provider-dispatcher.test.ts b/packages/ai-proxy/test/provider-dispatcher.test.ts index c819fb1bc2..9617941431 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( From 84d8f3cf0b5c6a7d14887b921b198f71d5a120ec Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 26 Jan 2026 13:46:44 +0100 Subject: [PATCH 05/14] fix(ai-proxy): improve client error handling and add edge case tests - Add Object.setPrototypeOf for proper Error subclass behavior - Add cause property to preserve original error stack traces - Add error categorization helpers (isNetworkError, isClientError, isServerError) - Fail fast when auth required but no apiKey configured - Handle JSON parse errors on successful responses properly - Add request context (method, path) to all error messages - Add tests for edge cases: both json/text fail, non-Error exceptions, invalid JSON on success, error cause preservation Co-Authored-By: Claude Opus 4.5 --- packages/ai-proxy/src/client/index.ts | 37 +++++-- packages/ai-proxy/src/client/types.ts | 17 +++- packages/ai-proxy/test/client.test.ts | 136 +++++++++++++++++++++++--- 3 files changed, 168 insertions(+), 22 deletions(-) diff --git a/packages/ai-proxy/src/client/index.ts b/packages/ai-proxy/src/client/index.ts index e3ae844d33..928a37217c 100644 --- a/packages/ai-proxy/src/client/index.ts +++ b/packages/ai-proxy/src/client/index.ts @@ -111,6 +111,14 @@ export class AiProxyClient { }): Promise { const { method, path, searchParams, body, auth } = params; + // Fail fast if auth required but no API key configured + if (auth && !this.apiKey) { + throw new AiProxyClientError( + `${method} ${path}: Authentication required but no API key configured`, + 0, + ); + } + let url = `${this.baseUrl}${path}`; if (searchParams && searchParams.toString()) { @@ -141,30 +149,47 @@ export class AiProxyClient { try { responseBody = await response.json(); - } catch { - responseBody = await response.text().catch(() => undefined); + } catch (jsonError) { + try { + responseBody = await response.text(); + } catch { + responseBody = undefined; + } } throw new AiProxyClientError( - `Request failed with status ${response.status}`, + `${method} ${path} failed with status ${response.status}`, response.status, responseBody, ); } - return (await response.json()) as T; + 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(`Request timeout after ${this.timeout}ms`, 408); + 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( - `Network error: ${error instanceof Error ? error.message : String(error)}`, + `${method} ${path} network error: ${message}`, 0, + undefined, + cause, ); } finally { clearTimeout(timeoutId); diff --git a/packages/ai-proxy/src/client/types.ts b/packages/ai-proxy/src/client/types.ts index bbeeb62a56..695cb83e68 100644 --- a/packages/ai-proxy/src/client/types.ts +++ b/packages/ai-proxy/src/client/types.ts @@ -40,11 +40,26 @@ export interface ChatInput { export class AiProxyClientError extends Error { readonly status: number; readonly body?: unknown; + readonly cause?: Error; - constructor(message: string, status: number, body?: unknown) { + constructor(message: string, status: number, body?: unknown, cause?: Error) { super(message); + Object.setPrototypeOf(this, AiProxyClientError.prototype); 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/test/client.test.ts b/packages/ai-proxy/test/client.test.ts index 20472afedc..7922761fab 100644 --- a/packages/ai-proxy/test/client.test.ts +++ b/packages/ai-proxy/test/client.test.ts @@ -2,6 +2,60 @@ import type { AiProxyClientConfig, ChatCompletion, RemoteToolDefinition } from ' 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', () => { const baseUrl = 'https://my-agent.com/forest'; const apiKey = 'sk-test-key'; @@ -186,21 +240,15 @@ describe('AiProxyClient', () => { ); }); - it('does not include Authorization header when apiKey is not provided', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({}), - }); - + it('throws error when auth required but no apiKey configured', async () => { const client = createClient({ apiKey: undefined }); - await client.chat('Hello'); - expect(mockFetch).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - headers: { 'Content-Type': 'application/json' }, - }), - ); + await expect(client.chat('Hello')).rejects.toMatchObject({ + status: 0, + message: 'POST /ai-query: Authentication required but no API key configured', + }); + + expect(mockFetch).not.toHaveBeenCalled(); }); }); @@ -322,7 +370,7 @@ describe('AiProxyClient', () => { await expect(client.getTools()).rejects.toMatchObject({ status: 408, - message: 'Request timeout after 5ms', + message: 'GET /remote-tools timed out after 5ms', }); }); @@ -333,9 +381,67 @@ describe('AiProxyClient', () => { await expect(client.getTools()).rejects.toMatchObject({ status: 0, - message: 'Network error: Network unreachable', + message: 'GET /remote-tools network error: Network unreachable', }); }); + + it('handles non-Error exceptions in network failures', async () => { + mockFetch.mockRejectedValueOnce('string error'); + + const client = createClient(); + + await expect(client.getTools()).rejects.toMatchObject({ + status: 0, + message: '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(); + + await expect(client.getTools()).rejects.toMatchObject({ + status: 503, + message: 'GET /remote-tools failed with status 503', + body: undefined, + }); + }); + + 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(); + + await expect(client.getTools()).rejects.toMatchObject({ + status: 200, + message: "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(); + + try { + await client.getTools(); + fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(AiProxyClientError); + expect((error as AiProxyClientError).cause).toBe(originalError); + } + }); }); describe('URL handling', () => { From 9da7b462950beb0ccb91afd5efc297c0b08be9af Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 26 Jan 2026 13:49:43 +0100 Subject: [PATCH 06/14] chore(ai-proxy): remove unnecessary Object.setPrototypeOf Not needed since Node 18+ is the minimum supported version. Co-Authored-By: Claude Opus 4.5 --- packages/ai-proxy/src/client/types.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/ai-proxy/src/client/types.ts b/packages/ai-proxy/src/client/types.ts index 695cb83e68..38494744c1 100644 --- a/packages/ai-proxy/src/client/types.ts +++ b/packages/ai-proxy/src/client/types.ts @@ -44,7 +44,6 @@ export class AiProxyClientError extends Error { constructor(message: string, status: number, body?: unknown, cause?: Error) { super(message); - Object.setPrototypeOf(this, AiProxyClientError.prototype); this.name = 'AiProxyClientError'; this.status = status; this.body = body; From e307764628b68e68e4a5670c33c83a319d727e33 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 26 Jan 2026 13:54:39 +0100 Subject: [PATCH 07/14] refactor(ai-proxy): rename routes.ts to types.ts Better reflects that this file contains type definitions, not routing logic. Co-Authored-By: Claude Opus 4.5 --- packages/ai-proxy/src/client/types.ts | 4 ++-- packages/ai-proxy/src/index.ts | 2 +- packages/ai-proxy/src/provider-dispatcher.ts | 2 +- packages/ai-proxy/src/remote-tools.ts | 2 +- packages/ai-proxy/src/router.ts | 2 +- packages/ai-proxy/src/{routes.ts => types.ts} | 0 packages/ai-proxy/test/router.test.ts | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) rename packages/ai-proxy/src/{routes.ts => types.ts} (100%) diff --git a/packages/ai-proxy/src/client/types.ts b/packages/ai-proxy/src/client/types.ts index 38494744c1..6aa8ce0fee 100644 --- a/packages/ai-proxy/src/client/types.ts +++ b/packages/ai-proxy/src/client/types.ts @@ -6,7 +6,7 @@ import type { ChatCompletionMessageParam, ChatCompletionTool, ChatCompletionToolChoiceOption, -} from '../routes'; +} from '../types'; // Re-export shared route types for client usage export type { @@ -20,7 +20,7 @@ export type { InvokeToolResponse, RemoteToolDefinition, RemoteToolsResponse, -} from '../routes'; +} from '../types'; // Client-specific types export interface AiProxyClientConfig { diff --git a/packages/ai-proxy/src/index.ts b/packages/ai-proxy/src/index.ts index accd774a4c..f4ebbe7269 100644 --- a/packages/ai-proxy/src/index.ts +++ b/packages/ai-proxy/src/index.ts @@ -2,7 +2,7 @@ import type { McpConfiguration } from './mcp-client'; import McpConfigChecker from './types/mcp-config-checker'; -export * from './routes'; +export * from './types'; export * from './provider-dispatcher'; export * from './remote-tools'; export * from './router'; diff --git a/packages/ai-proxy/src/provider-dispatcher.ts b/packages/ai-proxy/src/provider-dispatcher.ts index 7a92906569..8629fe9dbf 100644 --- a/packages/ai-proxy/src/provider-dispatcher.ts +++ b/packages/ai-proxy/src/provider-dispatcher.ts @@ -4,7 +4,7 @@ import type { AiQueryResponse, ChatCompletionTool, ChatCompletionToolChoiceOption, -} from './routes'; +} from './types'; import type { BaseMessageLike } from '@langchain/core/messages'; import type { ChatOpenAIFields, OpenAIChatModelId } from '@langchain/openai'; diff --git a/packages/ai-proxy/src/remote-tools.ts b/packages/ai-proxy/src/remote-tools.ts index 2acb537f57..fdfc78aaf3 100644 --- a/packages/ai-proxy/src/remote-tools.ts +++ b/packages/ai-proxy/src/remote-tools.ts @@ -1,4 +1,4 @@ -import type { ChatCompletionMessageParam, RemoteToolDefinition } from './routes'; +import type { ChatCompletionMessageParam, RemoteToolDefinition } from './types'; import type RemoteTool from './types/remote-tool'; import { BraveSearch } from '@langchain/community/tools/brave_search'; diff --git a/packages/ai-proxy/src/router.ts b/packages/ai-proxy/src/router.ts index efef16ebee..c48094c1c0 100644 --- a/packages/ai-proxy/src/router.ts +++ b/packages/ai-proxy/src/router.ts @@ -1,7 +1,7 @@ import type { McpConfiguration } from './mcp-client'; import type { AiConfiguration } from './provider-dispatcher'; import type { RemoteToolsApiKeys } from './remote-tools'; -import type { AiQueryQuery, AiQueryRequest, InvokeToolQuery, InvokeToolRequest } from './routes'; +import type { AiQueryQuery, AiQueryRequest, InvokeToolQuery, InvokeToolRequest } from './types'; import type { Logger } from '@forestadmin/datasource-toolkit'; import { AIBadRequestError, AIUnprocessableError, ProviderDispatcher } from './index'; diff --git a/packages/ai-proxy/src/routes.ts b/packages/ai-proxy/src/types.ts similarity index 100% rename from packages/ai-proxy/src/routes.ts rename to packages/ai-proxy/src/types.ts diff --git a/packages/ai-proxy/test/router.test.ts b/packages/ai-proxy/test/router.test.ts index 624f9c90cc..f10559713d 100644 --- a/packages/ai-proxy/test/router.test.ts +++ b/packages/ai-proxy/test/router.test.ts @@ -1,5 +1,5 @@ import type { AiQueryRequest, Route } from '../src'; -import type { InvokeToolRequest } from '../src/routes'; +import type { InvokeToolRequest } from '../src/types'; import type { Logger } from '@forestadmin/datasource-toolkit'; import { AIUnprocessableError, Router } from '../src'; From b23eb2f6163700b89a379f7141c88a423fb870c2 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 26 Jan 2026 14:00:41 +0100 Subject: [PATCH 08/14] refactor(ai-proxy): remove unused apiKey from client Authentication is handled at a different layer. Co-Authored-By: Claude Opus 4.5 --- packages/ai-proxy/src/client/index.ts | 18 +------------- packages/ai-proxy/src/client/types.ts | 1 - packages/ai-proxy/test/client.test.ts | 35 +-------------------------- 3 files changed, 2 insertions(+), 52 deletions(-) diff --git a/packages/ai-proxy/src/client/index.ts b/packages/ai-proxy/src/client/index.ts index 928a37217c..14a4b95e06 100644 --- a/packages/ai-proxy/src/client/index.ts +++ b/packages/ai-proxy/src/client/index.ts @@ -15,13 +15,11 @@ const DEFAULT_TIMEOUT = 30_000; export class AiProxyClient { private readonly baseUrl: string; - private readonly apiKey?: string; private readonly timeout: number; private readonly fetchFn: typeof fetch; constructor(config: AiProxyClientConfig) { this.baseUrl = config.baseUrl.replace(/\/$/, ''); - this.apiKey = config.apiKey; this.timeout = config.timeout ?? DEFAULT_TIMEOUT; this.fetchFn = config.fetch ?? fetch; } @@ -73,7 +71,6 @@ export class AiProxyClient { tools: normalized.tools, tool_choice: normalized.toolChoice, }, - auth: true, }); } @@ -107,17 +104,8 @@ export class AiProxyClient { path: string; searchParams?: URLSearchParams; body?: unknown; - auth?: boolean; }): Promise { - const { method, path, searchParams, body, auth } = params; - - // Fail fast if auth required but no API key configured - if (auth && !this.apiKey) { - throw new AiProxyClientError( - `${method} ${path}: Authentication required but no API key configured`, - 0, - ); - } + const { method, path, searchParams, body } = params; let url = `${this.baseUrl}${path}`; @@ -129,10 +117,6 @@ export class AiProxyClient { 'Content-Type': 'application/json', }; - if (auth && this.apiKey) { - headers.Authorization = `Bearer ${this.apiKey}`; - } - const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.timeout); diff --git a/packages/ai-proxy/src/client/types.ts b/packages/ai-proxy/src/client/types.ts index 6aa8ce0fee..54c1ebf2fc 100644 --- a/packages/ai-proxy/src/client/types.ts +++ b/packages/ai-proxy/src/client/types.ts @@ -25,7 +25,6 @@ export type { // Client-specific types export interface AiProxyClientConfig { baseUrl: string; - apiKey?: string; timeout?: number; fetch?: typeof fetch; } diff --git a/packages/ai-proxy/test/client.test.ts b/packages/ai-proxy/test/client.test.ts index 7922761fab..43b5e838c2 100644 --- a/packages/ai-proxy/test/client.test.ts +++ b/packages/ai-proxy/test/client.test.ts @@ -58,14 +58,12 @@ describe('AiProxyClientError', () => { describe('AiProxyClient', () => { const baseUrl = 'https://my-agent.com/forest'; - const apiKey = 'sk-test-key'; const mockFetch = jest.fn(); const createClient = (config: Partial = {}): AiProxyClient => { return new AiProxyClient({ baseUrl, - apiKey, fetch: mockFetch, ...config, }); @@ -79,7 +77,6 @@ describe('AiProxyClient', () => { it('creates an AiProxyClient instance', () => { const client = createAiProxyClient({ baseUrl, - apiKey, fetch: mockFetch, }); @@ -144,10 +141,7 @@ describe('AiProxyClient', () => { expect(mockFetch).toHaveBeenCalledWith(`${baseUrl}/ai-query`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${apiKey}`, - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages: [{ role: 'user', content: 'Hello' }], tools: undefined, @@ -240,16 +234,6 @@ describe('AiProxyClient', () => { ); }); - it('throws error when auth required but no apiKey configured', async () => { - const client = createClient({ apiKey: undefined }); - - await expect(client.chat('Hello')).rejects.toMatchObject({ - status: 0, - message: 'POST /ai-query: Authentication required but no API key configured', - }); - - expect(mockFetch).not.toHaveBeenCalled(); - }); }); describe('callTool', () => { @@ -275,23 +259,6 @@ describe('AiProxyClient', () => { ); expect(result).toEqual(expectedResult); }); - - it('does not include Authorization header (no auth required)', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({}), - }); - - const client = createClient(); - await client.callTool('test_tool', []); - - expect(mockFetch).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - headers: { 'Content-Type': 'application/json' }, - }), - ); - }); }); describe('error handling', () => { From 367c372b9c2ff5b8c89d11751c86da45faedb070 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 26 Jan 2026 14:23:25 +0100 Subject: [PATCH 09/14] refactor(ai-proxy): use union type for mutually exclusive client config AiProxyClientConfig now uses a union type where fetch and baseUrl are mutually exclusive: - Custom fetch mode: { fetch, timeout? } - Simple mode: { baseUrl, headers?, timeout? } Co-Authored-By: Claude Opus 4.5 --- packages/ai-proxy/src/client/index.ts | 47 +- packages/ai-proxy/src/client/types.ts | 8 +- packages/ai-proxy/test/client.test.ts | 590 ++++++++++++++------------ 3 files changed, 351 insertions(+), 294 deletions(-) diff --git a/packages/ai-proxy/src/client/index.ts b/packages/ai-proxy/src/client/index.ts index 14a4b95e06..0b567b14f9 100644 --- a/packages/ai-proxy/src/client/index.ts +++ b/packages/ai-proxy/src/client/index.ts @@ -14,14 +14,27 @@ export * from './types'; const DEFAULT_TIMEOUT = 30_000; export class AiProxyClient { - private readonly baseUrl: string; private readonly timeout: number; - private readonly fetchFn: typeof fetch; + 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.baseUrl = config.baseUrl.replace(/\/$/, ''); this.timeout = config.timeout ?? DEFAULT_TIMEOUT; - this.fetchFn = config.fetch ?? fetch; + + if ('fetch' in config) { + this.mode = 'custom'; + this.customFetch = config.fetch; + } else { + this.mode = 'simple'; + this.baseUrl = config.baseUrl.replace(/\/$/, ''); + this.headers = config.headers; + } } /** @@ -107,26 +120,30 @@ export class AiProxyClient { }): Promise { const { method, path, searchParams, body } = params; - let url = `${this.baseUrl}${path}`; + let url = path; if (searchParams && searchParams.toString()) { url += `?${searchParams.toString()}`; } - const headers: Record = { - 'Content-Type': 'application/json', - }; - const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.timeout); try { - const response = await this.fetchFn(url, { - method, - headers, - body: body ? JSON.stringify(body) : undefined, - signal: controller.signal, - }); + 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; diff --git a/packages/ai-proxy/src/client/types.ts b/packages/ai-proxy/src/client/types.ts index 54c1ebf2fc..fe9af0b018 100644 --- a/packages/ai-proxy/src/client/types.ts +++ b/packages/ai-proxy/src/client/types.ts @@ -23,11 +23,9 @@ export type { } from '../types'; // Client-specific types -export interface AiProxyClientConfig { - baseUrl: string; - timeout?: number; - fetch?: typeof fetch; -} +export type AiProxyClientConfig = + | { fetch: typeof fetch; timeout?: number } + | { baseUrl: string; headers?: Record; timeout?: number }; export interface ChatInput { messages: ChatCompletionMessageParam[]; diff --git a/packages/ai-proxy/test/client.test.ts b/packages/ai-proxy/test/client.test.ts index 43b5e838c2..8386ff5cf5 100644 --- a/packages/ai-proxy/test/client.test.ts +++ b/packages/ai-proxy/test/client.test.ts @@ -1,4 +1,4 @@ -import type { AiProxyClientConfig, ChatCompletion, RemoteToolDefinition } from '../src/client'; +import type { ChatCompletion, RemoteToolDefinition } from '../src/client'; import { AiProxyClient, AiProxyClientError, createAiProxyClient } from '../src/client'; @@ -57,373 +57,415 @@ describe('AiProxyClientError', () => { }); describe('AiProxyClient', () => { - const baseUrl = 'https://my-agent.com/forest'; + describe('custom fetch mode', () => { + const mockFetch = jest.fn(); - const mockFetch = jest.fn(); + const createClient = (timeout?: number): AiProxyClient => { + return new AiProxyClient({ fetch: mockFetch, timeout }); + }; - const createClient = (config: Partial = {}): AiProxyClient => { - return new AiProxyClient({ - baseUrl, - fetch: mockFetch, - ...config, + beforeEach(() => { + jest.clearAllMocks(); }); - }; - beforeEach(() => { - jest.clearAllMocks(); - }); + describe('createAiProxyClient', () => { + it('creates an AiProxyClient instance', () => { + const client = createAiProxyClient({ fetch: mockFetch }); - describe('createAiProxyClient', () => { - it('creates an AiProxyClient instance', () => { - const client = createAiProxyClient({ - baseUrl, - fetch: mockFetch, + expect(client).toBeInstanceOf(AiProxyClient); }); - - expect(client).toBeInstanceOf(AiProxyClient); }); - }); - - describe('getTools', () => { - it('makes a GET request to /remote-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(`${baseUrl}/remote-tools`, { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - body: undefined, - signal: expect.any(AbortSignal), - }); - expect(result).toEqual(expectedTools); - }); - }); - - describe('chat', () => { - it('accepts a simple string input', async () => { - const expectedResponse = { - id: 'chatcmpl-123', - object: 'chat.completion', - created: 1234567890, - model: 'gpt-4o', - choices: [ + describe('getTools', () => { + it('makes a GET request to /remote-tools', async () => { + const expectedTools: RemoteToolDefinition[] = [ { - index: 0, - message: { role: 'assistant', content: 'Hello!', refusal: null }, - finish_reason: 'stop', + name: 'brave_search', + description: 'Search the web', + responseFormat: 'content', + schema: { type: 'object' }, + sourceId: 'brave', + sourceType: 'server', }, - ], - } as ChatCompletion; + ]; - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(expectedResponse), - }); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(expectedTools), + }); - const client = createClient(); - const result = await client.chat('Hello'); + const client = createClient(); + const result = await client.getTools(); - expect(mockFetch).toHaveBeenCalledWith(`${baseUrl}/ai-query`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - messages: [{ role: 'user', content: 'Hello' }], - tools: undefined, - tool_choice: undefined, - }), - signal: expect.any(AbortSignal), + expect(mockFetch).toHaveBeenCalledWith('/remote-tools', { + method: 'GET', + body: undefined, + signal: expect.any(AbortSignal), + }); + expect(result).toEqual(expectedTools); }); - expect(result).toEqual(expectedResponse); }); - it('accepts an object input with messages', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({}), - }); - - const client = createClient(); - await client.chat({ - messages: [ - { role: 'system', content: 'You are helpful' }, - { role: 'user', content: 'Hello' }, - ], - }); - - expect(mockFetch).toHaveBeenCalledWith( - `${baseUrl}/ai-query`, - expect.objectContaining({ + describe('chat', () => { + it('accepts a simple string input', async () => { + const expectedResponse = { + 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; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(expectedResponse), + }); + + const client = createClient(); + const result = await client.chat('Hello'); + + expect(mockFetch).toHaveBeenCalledWith('/ai-query', { + method: 'POST', body: JSON.stringify({ - messages: [ - { role: 'system', content: 'You are helpful' }, - { role: 'user', content: 'Hello' }, - ], + messages: [{ role: 'user', content: 'Hello' }], tools: undefined, tool_choice: undefined, }), - }), - ); - }); - - it('includes aiName as ai-name query parameter when provided', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({}), + signal: expect.any(AbortSignal), + }); + expect(result).toEqual(expectedResponse); }); - const client = createClient(); - await client.chat({ - messages: [{ role: 'user', content: 'Hello' }], - aiName: 'gpt-4', + it('accepts an object input with messages', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({}), + }); + + const client = createClient(); + await client.chat({ + messages: [ + { role: 'system', content: 'You are helpful' }, + { role: 'user', content: 'Hello' }, + ], + }); + + expect(mockFetch).toHaveBeenCalledWith( + '/ai-query', + expect.objectContaining({ + body: JSON.stringify({ + messages: [ + { role: 'system', content: 'You are helpful' }, + { role: 'user', content: 'Hello' }, + ], + tools: undefined, + tool_choice: undefined, + }), + }), + ); }); - expect(mockFetch).toHaveBeenCalledWith( - `${baseUrl}/ai-query?ai-name=gpt-4`, - expect.anything(), - ); - }); + it('includes aiName as ai-name query parameter when provided', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({}), + }); - it('includes tools and toolChoice when provided', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({}), - }); + const client = createClient(); + await client.chat({ + messages: [{ role: 'user', content: 'Hello' }], + aiName: 'gpt-4', + }); - const client = createClient(); - 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?ai-name=gpt-4', expect.anything()); }); - expect(mockFetch).toHaveBeenCalledWith( - `${baseUrl}/ai-query`, - expect.objectContaining({ - body: JSON.stringify({ - messages: [{ role: 'user', content: 'Search for cats' }], - tools: [ - { - type: 'function', - function: { name: 'search', description: 'Search' }, - }, - ], - tool_choice: 'auto', + it('includes tools and toolChoice when provided', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({}), + }); + + const client = createClient(); + 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', + expect.objectContaining({ + body: JSON.stringify({ + messages: [{ role: 'user', content: 'Search for cats' }], + tools: [ + { + type: 'function', + function: { name: 'search', description: 'Search' }, + }, + ], + tool_choice: 'auto', + }), }), - }), - ); + ); + }); }); - }); - - describe('callTool', () => { - it('makes a POST request to /invoke-remote-tool with tool-name query param', async () => { - const expectedResult = { result: 'search results' }; + describe('callTool', () => { + it('makes a POST request to /invoke-remote-tool with tool-name query param', async () => { + const expectedResult = { result: 'search results' }; - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(expectedResult), - }); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(expectedResult), + }); - const client = createClient(); - const result = await client.callTool('brave_search', [{ role: 'user', content: 'cats' }]); + const client = createClient(); + const result = await client.callTool('brave_search', [{ role: 'user', content: 'cats' }]); - expect(mockFetch).toHaveBeenCalledWith( - `${baseUrl}/invoke-remote-tool?tool-name=brave_search`, - { + expect(mockFetch).toHaveBeenCalledWith('/invoke-remote-tool?tool-name=brave_search', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ inputs: [{ role: 'user', content: 'cats' }] }), signal: expect.any(AbortSignal), - }, - ); - expect(result).toEqual(expectedResult); + }); + expect(result).toEqual(expectedResult); + }); }); - }); - describe('error handling', () => { - it('throws AiProxyClientError with status 401 on authentication failure', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 401, - json: () => Promise.resolve({ error: 'Unauthorized' }), - }); + 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 client = createClient(); - await expect(client.chat('Hello')).rejects.toMatchObject({ - status: 401, - body: { error: 'Unauthorized' }, + await expect(client.chat('Hello')).rejects.toMatchObject({ + status: 401, + body: { error: 'Unauthorized' }, + }); }); - }); - it('throws AiProxyClientError with status 404 when resource not found', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 404, - json: () => Promise.resolve({ error: 'Not found' }), - }); + 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 client = createClient(); - await expect(client.callTool('non_existent', [])).rejects.toMatchObject({ - status: 404, - body: { error: 'Not found' }, + await expect(client.callTool('non_existent', [])).rejects.toMatchObject({ + status: 404, + body: { error: 'Not found' }, + }); }); - }); - it('throws AiProxyClientError with status 422 on validation error', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 422, - json: () => Promise.resolve({ error: 'Validation failed' }), + 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(); + + await expect(client.chat({ messages: [] })).rejects.toMatchObject({ + status: 422, + body: { error: 'Validation failed' }, + }); }); - const client = createClient(); + it('handles non-JSON error responses', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + json: () => Promise.reject(new Error('Not JSON')), + text: () => Promise.resolve('Internal Server Error'), + }); - await expect(client.chat({ messages: [] })).rejects.toMatchObject({ - status: 422, - body: { error: 'Validation failed' }, + const client = createClient(); + + await expect(client.getTools()).rejects.toMatchObject({ + status: 500, + body: 'Internal Server Error', + }); }); - }); - it('handles non-JSON error responses', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 500, - json: () => Promise.reject(new Error('Not JSON')), - text: () => Promise.resolve('Internal Server Error'), + it('throws AiProxyClientError with status 408 on timeout', async () => { + mockFetch.mockImplementation( + () => + new Promise((_, reject) => { + const error = new Error('Aborted'); + error.name = 'AbortError'; + setTimeout(() => reject(error), 10); + }), + ); + + const client = createClient(5); + + await expect(client.getTools()).rejects.toMatchObject({ + status: 408, + message: 'GET /remote-tools timed out after 5ms', + }); }); - const client = createClient(); + it('throws AiProxyClientError with status 0 on network error', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network unreachable')); + + const client = createClient(); - await expect(client.getTools()).rejects.toMatchObject({ - status: 500, - body: 'Internal Server Error', + await expect(client.getTools()).rejects.toMatchObject({ + status: 0, + message: 'GET /remote-tools network error: Network unreachable', + }); }); - }); - it('throws AiProxyClientError with status 408 on timeout', async () => { - mockFetch.mockImplementation( - () => - new Promise((_, reject) => { - const error = new Error('Aborted'); - error.name = 'AbortError'; - setTimeout(() => reject(error), 10); - }), - ); + it('handles non-Error exceptions in network failures', async () => { + mockFetch.mockRejectedValueOnce('string error'); - const client = createClient({ timeout: 5 }); + const client = createClient(); - await expect(client.getTools()).rejects.toMatchObject({ - status: 408, - message: 'GET /remote-tools timed out after 5ms', + await expect(client.getTools()).rejects.toMatchObject({ + status: 0, + message: 'GET /remote-tools network error: string error', + }); }); - }); - it('throws AiProxyClientError with status 0 on network error', async () => { - mockFetch.mockRejectedValueOnce(new Error('Network unreachable')); + 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(); + + await expect(client.getTools()).rejects.toMatchObject({ + status: 503, + message: 'GET /remote-tools failed with status 503', + body: undefined, + }); + }); + + 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 client = createClient(); - await expect(client.getTools()).rejects.toMatchObject({ - status: 0, - message: 'GET /remote-tools network error: Network unreachable', + await expect(client.getTools()).rejects.toMatchObject({ + status: 200, + message: 'GET /remote-tools: Server returned 200 but response is not valid JSON', + }); }); - }); - it('handles non-Error exceptions in network failures', async () => { - mockFetch.mockRejectedValueOnce('string error'); + it('preserves error cause for network errors', async () => { + const originalError = new Error('Connection refused'); + mockFetch.mockRejectedValueOnce(originalError); - const client = createClient(); + const client = createClient(); - await expect(client.getTools()).rejects.toMatchObject({ - status: 0, - message: 'GET /remote-tools network error: string error', + try { + await client.getTools(); + fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(AiProxyClientError); + expect((error as AiProxyClientError).cause).toBe(originalError); + } }); }); + }); - 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')), - }); + describe('simple mode', () => { + const baseUrl = 'https://my-agent.com/forest'; - const client = createClient(); + // Mock global fetch for simple mode tests + const mockFetch = jest.fn(); + const originalFetch = global.fetch; - await expect(client.getTools()).rejects.toMatchObject({ - status: 503, - message: 'GET /remote-tools failed with status 503', - body: undefined, - }); + beforeAll(() => { + global.fetch = mockFetch; + }); + + afterAll(() => { + global.fetch = originalFetch; + }); + + beforeEach(() => { + jest.clearAllMocks(); }); - it('throws error when successful response is not valid JSON', async () => { + it('makes requests with baseUrl and Content-Type header', async () => { mockFetch.mockResolvedValueOnce({ ok: true, - status: 200, - json: () => Promise.reject(new SyntaxError("Unexpected token '<'")), + json: () => Promise.resolve([]), }); - const client = createClient(); + const client = new AiProxyClient({ baseUrl }); + await client.getTools(); - await expect(client.getTools()).rejects.toMatchObject({ - status: 200, - message: "GET /remote-tools: Server returned 200 but response is not valid JSON", + expect(mockFetch).toHaveBeenCalledWith(`${baseUrl}/remote-tools`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + body: undefined, + signal: expect.any(AbortSignal), }); }); - it('preserves error cause for network errors', async () => { - const originalError = new Error('Connection refused'); - mockFetch.mockRejectedValueOnce(originalError); + it('removes trailing slash from baseUrl', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve([]), + }); - const client = createClient(); + const client = new AiProxyClient({ baseUrl: 'https://example.com/forest/' }); + await client.getTools(); - try { - await client.getTools(); - fail('Should have thrown'); - } catch (error) { - expect(error).toBeInstanceOf(AiProxyClientError); - expect((error as AiProxyClientError).cause).toBe(originalError); - } + expect(mockFetch).toHaveBeenCalledWith( + 'https://example.com/forest/remote-tools', + expect.anything(), + ); }); - }); - describe('URL handling', () => { - it('removes trailing slash from baseUrl', async () => { + it('includes custom headers when provided', async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]), }); - const client = createClient({ baseUrl: 'https://example.com/forest/' }); + const client = new AiProxyClient({ + baseUrl, + headers: { Authorization: 'Bearer token', 'X-Custom': 'value' }, + }); await client.getTools(); expect(mockFetch).toHaveBeenCalledWith( - 'https://example.com/forest/remote-tools', - expect.anything(), + expect.any(String), + expect.objectContaining({ + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer token', + 'X-Custom': 'value', + }, + }), ); }); }); From feb83180a0c8ca5e05ed478d637b410f60931c24 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 26 Jan 2026 14:36:02 +0100 Subject: [PATCH 10/14] docs(ai-proxy): update README with union config and complex tool example - Document mutually exclusive config modes (baseUrl vs fetch) - Remove outdated apiKey references - Add complex tool example with nested objects, arrays, and enums - Document error categorization helpers Co-Authored-By: Claude Opus 4.5 --- packages/ai-proxy/README.md | 185 ++++++++++++++++++++++++++++++++---- 1 file changed, 166 insertions(+), 19 deletions(-) diff --git a/packages/ai-proxy/README.md b/packages/ai-proxy/README.md index 40c9c0ea5f..4cd801d0e4 100644 --- a/packages/ai-proxy/README.md +++ b/packages/ai-proxy/README.md @@ -28,14 +28,30 @@ The `AiProxyClient` provides a simple API to interact with the AI proxy from a f ### Setup +Two configuration modes are available (mutually exclusive): + +#### Simple mode (recommended for frontend) + ```typescript -// Lightweight import - no langchain dependencies (recommended for frontend) import { createAiProxyClient } from '@forestadmin/ai-proxy/client'; const client = createAiProxyClient({ baseUrl: 'https://my-agent.com/forest', - apiKey: 'sk-...', // Optional: OpenAI API key for authentication - timeout: 30000, // Optional: request timeout in ms (default: 30000) + headers: { // Optional: custom headers + Authorization: 'Bearer my-token', + }, + timeout: 30000, // Optional: request timeout in ms (default: 30000) +}); +``` + +#### Custom fetch mode (for agent-client or custom routing) + +```typescript +import { createAiProxyClient } from '@forestadmin/ai-proxy/client'; + +const client = createAiProxyClient({ + fetch: myCustomFetch, // Your custom fetch implementation + timeout: 30000, }); ``` @@ -53,7 +69,7 @@ console.log(response.choices[0].message.content); // => "I don't have access to real-time weather data..." ``` -#### Advanced usage +#### With tools ```typescript const response = await client.chat({ @@ -65,9 +81,15 @@ const response = await client.chat({ { type: 'function', function: { - name: 'brave_search', + name: 'search', description: 'Search the web', - parameters: { type: 'object', properties: { query: { type: 'string' } } }, + parameters: { + type: 'object', + properties: { + query: { type: 'string', description: 'Search query' }, + }, + required: ['query'], + }, }, }, ], @@ -79,6 +101,110 @@ const response = await client.chat({ if (response.choices[0].message.tool_calls) { const toolCall = response.choices[0].message.tool_calls[0]; console.log(`AI wants to call: ${toolCall.function.name}`); + console.log(`Arguments: ${toolCall.function.arguments}`); +} +``` + +#### Complex tool with multiple parameters + +Tools can have complex schemas with multiple parameters, nested objects, arrays, and enums: + +```typescript +const response = await client.chat({ + messages: [ + { role: 'system', content: 'You are a data analyst assistant.' }, + { role: 'user', content: 'Create a sales report for Q4 2024, grouped by region, in PDF format' }, + ], + tools: [ + { + type: 'function', + function: { + name: 'generate_report', + description: 'Generate a business report with customizable parameters', + parameters: { + type: 'object', + properties: { + reportType: { + type: 'string', + enum: ['sales', 'inventory', 'customers', 'financial'], + description: 'Type of report to generate', + }, + dateRange: { + type: 'object', + description: 'Date range for the report', + properties: { + start: { type: 'string', format: 'date', description: 'Start date (YYYY-MM-DD)' }, + end: { type: 'string', format: 'date', description: 'End date (YYYY-MM-DD)' }, + }, + required: ['start', 'end'], + }, + groupBy: { + type: 'array', + items: { + type: 'string', + enum: ['region', 'product', 'salesperson', 'month'], + }, + description: 'Fields to group the data by', + }, + filters: { + type: 'object', + description: 'Optional filters to apply', + properties: { + regions: { + type: 'array', + items: { type: 'string' }, + description: 'Filter by specific regions', + }, + minAmount: { + type: 'number', + description: 'Minimum transaction amount', + }, + status: { + type: 'string', + enum: ['completed', 'pending', 'cancelled'], + }, + }, + }, + output: { + type: 'object', + properties: { + format: { + type: 'string', + enum: ['pdf', 'excel', 'csv', 'json'], + description: 'Output file format', + }, + includeCharts: { + type: 'boolean', + description: 'Whether to include visual charts', + }, + language: { + type: 'string', + enum: ['en', 'fr', 'es', 'de'], + default: 'en', + }, + }, + required: ['format'], + }, + }, + required: ['reportType', 'dateRange', 'output'], + }, + }, + }, + ], + toolChoice: 'auto', +}); + +// The AI will respond with structured arguments +const toolCall = response.choices[0].message.tool_calls?.[0]; +if (toolCall) { + const args = JSON.parse(toolCall.function.arguments); + console.log(args); + // { + // reportType: 'sales', + // dateRange: { start: '2024-10-01', end: '2024-12-31' }, + // groupBy: ['region'], + // output: { format: 'pdf', includeCharts: true, language: 'en' } + // } } ``` @@ -116,7 +242,7 @@ console.log(result); ### Error Handling -All methods throw `AiProxyClientError` on failure. +All methods throw `AiProxyClientError` on failure with helpful categorization: ```typescript import { AiProxyClientError } from '@forestadmin/ai-proxy/client'; @@ -127,19 +253,34 @@ try { if (error instanceof AiProxyClientError) { console.error(`Error ${error.status}: ${error.message}`); console.error('Response body:', error.body); + + // Error categorization helpers + if (error.isNetworkError) { + console.error('Network issue - check your connection'); + } else if (error.isClientError) { + console.error('Client error (4xx) - check your request'); + } else if (error.isServerError) { + console.error('Server error (5xx) - try again later'); + } + + // Access the original error if needed + if (error.cause) { + console.error('Caused by:', error.cause); + } } } ``` #### Error status codes -| Status | Description | -|--------|-------------| -| 0 | Network error | -| 401 | Authentication failed | -| 404 | Resource not found | -| 408 | Request timeout | -| 422 | Validation error | +| Status | Description | `isNetworkError` | `isClientError` | `isServerError` | +|--------|-------------|------------------|-----------------|-----------------| +| 0 | Network error | `true` | `false` | `false` | +| 401 | Authentication failed | `false` | `true` | `false` | +| 404 | Resource not found | `false` | `true` | `false` | +| 408 | Request timeout | `false` | `true` | `false` | +| 422 | Validation error | `false` | `true` | `false` | +| 500+ | Server error | `false` | `false` | `true` | ## API Reference @@ -147,14 +288,20 @@ try { Creates a new AI Proxy client instance. -#### Config options +#### Config options (Simple mode) | Option | Type | Required | Default | Description | |--------|------|----------|---------|-------------| | `baseUrl` | `string` | Yes | - | Base URL of the AI proxy server | -| `apiKey` | `string` | No | - | API key for authentication (used in `chat()`) | +| `headers` | `Record` | No | - | Custom headers to include in requests | +| `timeout` | `number` | No | 30000 | Request timeout in milliseconds | + +#### Config options (Custom fetch mode) + +| Option | Type | Required | Default | Description | +|--------|------|----------|---------|-------------| +| `fetch` | `typeof fetch` | Yes | - | Custom fetch implementation | | `timeout` | `number` | No | 30000 | Request timeout in milliseconds | -| `fetch` | `typeof fetch` | No | `fetch` | Custom fetch implementation | ### `client.chat(input)` @@ -162,7 +309,7 @@ Send a chat message to the AI. - **input**: `string | ChatInput` - A simple string or an object with: - `messages`: Array of chat messages - - `tools?`: Array of tool definitions + - `tools?`: Array of tool definitions (supports complex schemas) - `toolChoice?`: Tool choice option (`'auto'`, `'none'`, `'required'`, or specific tool) - `aiName?`: Name of the AI configuration to use @@ -197,5 +344,5 @@ import type { RemoteToolDefinition, } from '@forestadmin/ai-proxy/client'; -import { AiProxyClientError } from '@forestadmin/ai-proxy/client'; +import { AiProxyClient, AiProxyClientError } from '@forestadmin/ai-proxy/client'; ``` From d3af3afb795eda7dae285af2b5c0b26e54000937 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 26 Jan 2026 14:38:09 +0100 Subject: [PATCH 11/14] docs(ai-proxy): remove complex tool example Co-Authored-By: Claude Opus 4.5 --- packages/ai-proxy/README.md | 103 ------------------------------------ 1 file changed, 103 deletions(-) diff --git a/packages/ai-proxy/README.md b/packages/ai-proxy/README.md index 4cd801d0e4..684f23bc85 100644 --- a/packages/ai-proxy/README.md +++ b/packages/ai-proxy/README.md @@ -105,109 +105,6 @@ if (response.choices[0].message.tool_calls) { } ``` -#### Complex tool with multiple parameters - -Tools can have complex schemas with multiple parameters, nested objects, arrays, and enums: - -```typescript -const response = await client.chat({ - messages: [ - { role: 'system', content: 'You are a data analyst assistant.' }, - { role: 'user', content: 'Create a sales report for Q4 2024, grouped by region, in PDF format' }, - ], - tools: [ - { - type: 'function', - function: { - name: 'generate_report', - description: 'Generate a business report with customizable parameters', - parameters: { - type: 'object', - properties: { - reportType: { - type: 'string', - enum: ['sales', 'inventory', 'customers', 'financial'], - description: 'Type of report to generate', - }, - dateRange: { - type: 'object', - description: 'Date range for the report', - properties: { - start: { type: 'string', format: 'date', description: 'Start date (YYYY-MM-DD)' }, - end: { type: 'string', format: 'date', description: 'End date (YYYY-MM-DD)' }, - }, - required: ['start', 'end'], - }, - groupBy: { - type: 'array', - items: { - type: 'string', - enum: ['region', 'product', 'salesperson', 'month'], - }, - description: 'Fields to group the data by', - }, - filters: { - type: 'object', - description: 'Optional filters to apply', - properties: { - regions: { - type: 'array', - items: { type: 'string' }, - description: 'Filter by specific regions', - }, - minAmount: { - type: 'number', - description: 'Minimum transaction amount', - }, - status: { - type: 'string', - enum: ['completed', 'pending', 'cancelled'], - }, - }, - }, - output: { - type: 'object', - properties: { - format: { - type: 'string', - enum: ['pdf', 'excel', 'csv', 'json'], - description: 'Output file format', - }, - includeCharts: { - type: 'boolean', - description: 'Whether to include visual charts', - }, - language: { - type: 'string', - enum: ['en', 'fr', 'es', 'de'], - default: 'en', - }, - }, - required: ['format'], - }, - }, - required: ['reportType', 'dateRange', 'output'], - }, - }, - }, - ], - toolChoice: 'auto', -}); - -// The AI will respond with structured arguments -const toolCall = response.choices[0].message.tool_calls?.[0]; -if (toolCall) { - const args = JSON.parse(toolCall.function.arguments); - console.log(args); - // { - // reportType: 'sales', - // dateRange: { start: '2024-10-01', end: '2024-12-31' }, - // groupBy: ['region'], - // output: { format: 'pdf', includeCharts: true, language: 'en' } - // } -} -``` - ### List Available Tools Get the list of remote tools configured on the server. From a86e7fb2de385e8812217814baa3f5af744a1eb3 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 26 Jan 2026 14:41:36 +0100 Subject: [PATCH 12/14] docs(ai-proxy): simplify README for better developer UX - Add Quick Start section at the top - Remove redundant API Reference and TypeScript sections - Simplify error handling documentation - Focus on time-to-first-success Co-Authored-By: Claude Opus 4.5 --- packages/ai-proxy/README.md | 230 ++++++------------------------------ 1 file changed, 37 insertions(+), 193 deletions(-) diff --git a/packages/ai-proxy/README.md b/packages/ai-proxy/README.md index 684f23bc85..551cf0ab09 100644 --- a/packages/ai-proxy/README.md +++ b/packages/ai-proxy/README.md @@ -1,145 +1,76 @@ # @forestadmin/ai-proxy -AI Proxy package for Forest Admin agents. Provides both server-side routing and a client SDK for frontend/agent-client usage. +AI Proxy client for Forest Admin. -## Installation +## Quick Start -### Client only (frontend) +```typescript +import { createAiProxyClient } from '@forestadmin/ai-proxy/client'; -```bash -npm install @forestadmin/ai-proxy -``` +const client = createAiProxyClient({ baseUrl: 'https://my-agent.com/forest' }); -No additional dependencies required. +const response = await client.chat('Hello!'); +console.log(response.choices[0].message.content); +``` -### Server-side (with Router, ProviderDispatcher) +## Installation ```bash -npm install @forestadmin/ai-proxy @langchain/core @langchain/openai @langchain/community @langchain/mcp-adapters +npm install @forestadmin/ai-proxy ``` -Langchain packages are optional peer dependencies - install them only if you use the server-side features. - -## Client Usage - -The `AiProxyClient` provides a simple API to interact with the AI proxy from a frontend or agent-client. +## Configuration -> **Note:** Import from `@forestadmin/ai-proxy/client` for a lightweight client without langchain dependencies. - -### Setup - -Two configuration modes are available (mutually exclusive): - -#### Simple mode (recommended for frontend) +Choose one mode: ```typescript -import { createAiProxyClient } from '@forestadmin/ai-proxy/client'; - +// Simple mode (recommended) const client = createAiProxyClient({ baseUrl: 'https://my-agent.com/forest', - headers: { // Optional: custom headers - Authorization: 'Bearer my-token', - }, - timeout: 30000, // Optional: request timeout in ms (default: 30000) + headers: { Authorization: 'Bearer token' }, // optional + timeout: 30000, // optional (default: 30s) }); -``` - -#### Custom fetch mode (for agent-client or custom routing) - -```typescript -import { createAiProxyClient } from '@forestadmin/ai-proxy/client'; +// Custom fetch mode const client = createAiProxyClient({ - fetch: myCustomFetch, // Your custom fetch implementation + fetch: myCustomFetch, timeout: 30000, }); ``` -### Chat with AI +## API -Send messages to the AI and get completions. - -#### Simple usage +### `chat(input)` ```typescript -// Just send a string - it will be wrapped as a user message -const response = await client.chat('What is the weather today?'); - -console.log(response.choices[0].message.content); -// => "I don't have access to real-time weather data..." -``` - -#### With tools - -```typescript -const response = await client.chat({ - messages: [ - { role: 'system', content: 'You are a helpful assistant.' }, - { role: 'user', content: 'Search for cats' }, - ], - tools: [ - { - type: 'function', - function: { - name: 'search', - description: 'Search the web', - parameters: { - type: 'object', - properties: { - query: { type: 'string', description: 'Search query' }, - }, - required: ['query'], - }, - }, - }, - ], - toolChoice: 'auto', - aiName: 'gpt-4', // Name of the AI configuration on the server +// 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 }); - -// Check if the AI wants to call a tool -if (response.choices[0].message.tool_calls) { - const toolCall = response.choices[0].message.tool_calls[0]; - console.log(`AI wants to call: ${toolCall.function.name}`); - console.log(`Arguments: ${toolCall.function.arguments}`); -} ``` -### List Available Tools - -Get the list of remote tools configured on the server. +### `getTools()` ```typescript const tools = await client.getTools(); - -console.log(tools); -// [ -// { -// name: 'brave_search', -// description: 'Search the web using Brave Search', -// schema: { ... }, -// sourceId: 'brave_search', -// sourceType: 'server' -// } -// ] +// [{ name: 'brave_search', description: '...', schema: {...} }] ``` -### Call a Tool - -Execute a remote tool by name. +### `callTool(name, inputs)` ```typescript const result = await client.callTool('brave_search', [ { role: 'user', content: 'cats' } ]); - -console.log(result); -// Search results from Brave Search ``` -### Error Handling - -All methods throw `AiProxyClientError` on failure with helpful categorization: +## Error Handling ```typescript import { AiProxyClientError } from '@forestadmin/ai-proxy/client'; @@ -148,98 +79,11 @@ try { await client.chat('Hello'); } catch (error) { if (error instanceof AiProxyClientError) { - console.error(`Error ${error.status}: ${error.message}`); - console.error('Response body:', error.body); - - // Error categorization helpers - if (error.isNetworkError) { - console.error('Network issue - check your connection'); - } else if (error.isClientError) { - console.error('Client error (4xx) - check your request'); - } else if (error.isServerError) { - console.error('Server error (5xx) - try again later'); - } - - // Access the original error if needed - if (error.cause) { - console.error('Caused by:', error.cause); - } + console.error(error.status, error.message); + + if (error.isNetworkError) { /* status 0 */ } + if (error.isClientError) { /* status 4xx */ } + if (error.isServerError) { /* status 5xx */ } } } ``` - -#### Error status codes - -| Status | Description | `isNetworkError` | `isClientError` | `isServerError` | -|--------|-------------|------------------|-----------------|-----------------| -| 0 | Network error | `true` | `false` | `false` | -| 401 | Authentication failed | `false` | `true` | `false` | -| 404 | Resource not found | `false` | `true` | `false` | -| 408 | Request timeout | `false` | `true` | `false` | -| 422 | Validation error | `false` | `true` | `false` | -| 500+ | Server error | `false` | `false` | `true` | - -## API Reference - -### `createAiProxyClient(config)` - -Creates a new AI Proxy client instance. - -#### Config options (Simple mode) - -| Option | Type | Required | Default | Description | -|--------|------|----------|---------|-------------| -| `baseUrl` | `string` | Yes | - | Base URL of the AI proxy server | -| `headers` | `Record` | No | - | Custom headers to include in requests | -| `timeout` | `number` | No | 30000 | Request timeout in milliseconds | - -#### Config options (Custom fetch mode) - -| Option | Type | Required | Default | Description | -|--------|------|----------|---------|-------------| -| `fetch` | `typeof fetch` | Yes | - | Custom fetch implementation | -| `timeout` | `number` | No | 30000 | Request timeout in milliseconds | - -### `client.chat(input)` - -Send a chat message to the AI. - -- **input**: `string | ChatInput` - A simple string or an object with: - - `messages`: Array of chat messages - - `tools?`: Array of tool definitions (supports complex schemas) - - `toolChoice?`: Tool choice option (`'auto'`, `'none'`, `'required'`, or specific tool) - - `aiName?`: Name of the AI configuration to use - -- **Returns**: `Promise` - OpenAI-compatible chat completion response - -### `client.getTools()` - -Get available remote tools. - -- **Returns**: `Promise` - -### `client.callTool(toolName, inputs)` - -Call a remote tool. - -- **toolName**: `string` - Name of the tool to call -- **inputs**: `ChatCompletionMessage[]` - Input messages for the tool - -- **Returns**: `Promise` - Tool execution result - -## TypeScript Support - -Full TypeScript support with exported types: - -```typescript -import type { - AiProxyClientConfig, - ChatInput, - ChatCompletion, - ChatCompletionMessageParam, - ChatCompletionTool, - RemoteToolDefinition, -} from '@forestadmin/ai-proxy/client'; - -import { AiProxyClient, AiProxyClientError } from '@forestadmin/ai-proxy/client'; -``` From 69e2ed2545cd62eb092567b6db6ce42ca057aebf Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 26 Jan 2026 14:44:01 +0100 Subject: [PATCH 13/14] docs(ai-proxy): add client vs server installation instructions Co-Authored-By: Claude Opus 4.5 --- packages/ai-proxy/README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/ai-proxy/README.md b/packages/ai-proxy/README.md index 551cf0ab09..b48e8613dd 100644 --- a/packages/ai-proxy/README.md +++ b/packages/ai-proxy/README.md @@ -15,10 +15,18 @@ 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: From 7ecac0688b72fe145ee6bcb3909becb8e7dac627 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 26 Jan 2026 15:47:41 +0100 Subject: [PATCH 14/14] test(ai-proxy): improve test quality with stronger assertions - Use toBeInstanceOf(AiProxyClientError) instead of toMatchObject - Add return value assertions to all tests - Add tests for default timeout (30s) - Add tests for empty aiName - Add tests for URL encoding special characters in aiName/toolName - Add tests for custom headers overriding Content-Type - Add tests for concurrent requests with independent abort signals - Replace expect.anything() with specific assertions Co-Authored-By: Claude Opus 4.5 --- packages/ai-proxy/test/client.test.ts | 399 ++++++++++++++++++-------- 1 file changed, 278 insertions(+), 121 deletions(-) diff --git a/packages/ai-proxy/test/client.test.ts b/packages/ai-proxy/test/client.test.ts index 8386ff5cf5..db53b72050 100644 --- a/packages/ai-proxy/test/client.test.ts +++ b/packages/ai-proxy/test/client.test.ts @@ -77,7 +77,7 @@ describe('AiProxyClient', () => { }); describe('getTools', () => { - it('makes a GET request to /remote-tools', async () => { + it('makes a GET request to /remote-tools and returns tools', async () => { const expectedTools: RemoteToolDefinition[] = [ { name: 'brave_search', @@ -107,24 +107,24 @@ describe('AiProxyClient', () => { }); describe('chat', () => { - it('accepts a simple string input', async () => { - const expectedResponse = { - 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; + 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(expectedResponse), + json: () => Promise.resolve(validResponse), }); const client = createClient(); @@ -139,61 +139,111 @@ describe('AiProxyClient', () => { }), signal: expect.any(AbortSignal), }); - expect(result).toEqual(expectedResponse); + expect(result).toEqual(validResponse); }); - it('accepts an object input with messages', async () => { + it('accepts an object input with messages and returns response', async () => { mockFetch.mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve({}), + json: () => Promise.resolve(validResponse), }); const client = createClient(); - await client.chat({ + const result = await client.chat({ messages: [ { role: 'system', content: 'You are helpful' }, { role: 'user', content: 'Hello' }, ], }); - expect(mockFetch).toHaveBeenCalledWith( - '/ai-query', - expect.objectContaining({ - body: JSON.stringify({ - messages: [ - { role: 'system', content: 'You are helpful' }, - { role: 'user', content: 'Hello' }, - ], - tools: undefined, - tool_choice: undefined, - }), + 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({}), + json: () => Promise.resolve(validResponse), }); const client = createClient(); - await client.chat({ + const result = await client.chat({ messages: [{ role: 'user', content: 'Hello' }], aiName: 'gpt-4', }); - expect(mockFetch).toHaveBeenCalledWith('/ai-query?ai-name=gpt-4', expect.anything()); + 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('includes tools and toolChoice when provided', async () => { + it('does not include ai-name query parameter when aiName is empty string', async () => { mockFetch.mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve({}), + 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: [ { @@ -204,26 +254,26 @@ describe('AiProxyClient', () => { toolChoice: 'auto', }); - expect(mockFetch).toHaveBeenCalledWith( - '/ai-query', - expect.objectContaining({ - body: JSON.stringify({ - messages: [{ role: 'user', content: 'Search for cats' }], - tools: [ - { - type: 'function', - function: { name: 'search', description: 'Search' }, - }, - ], - tool_choice: '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 with tool-name query param', async () => { + it('makes a POST request to /invoke-remote-tool and returns result', async () => { const expectedResult = { result: 'search results' }; mockFetch.mockResolvedValueOnce({ @@ -241,6 +291,98 @@ describe('AiProxyClient', () => { }); 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', () => { @@ -253,10 +395,13 @@ describe('AiProxyClient', () => { const client = createClient(); - await expect(client.chat('Hello')).rejects.toMatchObject({ - status: 401, - body: { error: 'Unauthorized' }, - }); + 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 () => { @@ -268,10 +413,12 @@ describe('AiProxyClient', () => { const client = createClient(); - await expect(client.callTool('non_existent', [])).rejects.toMatchObject({ - status: 404, - body: { error: 'Not found' }, - }); + 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 () => { @@ -283,13 +430,15 @@ describe('AiProxyClient', () => { const client = createClient(); - await expect(client.chat({ messages: [] })).rejects.toMatchObject({ - status: 422, - body: { error: 'Validation failed' }, - }); + 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', async () => { + it('handles non-JSON error responses by falling back to text', async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 500, @@ -299,28 +448,12 @@ describe('AiProxyClient', () => { const client = createClient(); - await expect(client.getTools()).rejects.toMatchObject({ - status: 500, - body: 'Internal Server Error', - }); - }); + const error = (await client.getTools().catch(e => e)) as AiProxyClientError; - it('throws AiProxyClientError with status 408 on timeout', async () => { - mockFetch.mockImplementation( - () => - new Promise((_, reject) => { - const error = new Error('Aborted'); - error.name = 'AbortError'; - setTimeout(() => reject(error), 10); - }), - ); - - const client = createClient(5); - - await expect(client.getTools()).rejects.toMatchObject({ - status: 408, - message: 'GET /remote-tools timed out after 5ms', - }); + 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 () => { @@ -328,10 +461,12 @@ describe('AiProxyClient', () => { const client = createClient(); - await expect(client.getTools()).rejects.toMatchObject({ - status: 0, - message: 'GET /remote-tools network error: Network unreachable', - }); + 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 () => { @@ -339,10 +474,11 @@ describe('AiProxyClient', () => { const client = createClient(); - await expect(client.getTools()).rejects.toMatchObject({ - status: 0, - message: 'GET /remote-tools network error: string error', - }); + 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 () => { @@ -355,11 +491,12 @@ describe('AiProxyClient', () => { const client = createClient(); - await expect(client.getTools()).rejects.toMatchObject({ - status: 503, - message: 'GET /remote-tools failed with status 503', - body: undefined, - }); + 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 () => { @@ -371,10 +508,11 @@ describe('AiProxyClient', () => { const client = createClient(); - await expect(client.getTools()).rejects.toMatchObject({ - status: 200, - message: 'GET /remote-tools: Server returned 200 but response is not valid JSON', - }); + 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 () => { @@ -383,13 +521,10 @@ describe('AiProxyClient', () => { const client = createClient(); - try { - await client.getTools(); - fail('Should have thrown'); - } catch (error) { - expect(error).toBeInstanceOf(AiProxyClientError); - expect((error as AiProxyClientError).cause).toBe(originalError); - } + const error = (await client.getTools().catch(e => e)) as AiProxyClientError; + + expect(error).toBeInstanceOf(AiProxyClientError); + expect(error.cause).toBe(originalError); }); }); }); @@ -397,7 +532,6 @@ describe('AiProxyClient', () => { describe('simple mode', () => { const baseUrl = 'https://my-agent.com/forest'; - // Mock global fetch for simple mode tests const mockFetch = jest.fn(); const originalFetch = global.fetch; @@ -414,13 +548,15 @@ describe('AiProxyClient', () => { }); it('makes requests with baseUrl and Content-Type header', async () => { + const expectedTools: RemoteToolDefinition[] = [{ name: 'test' }] as RemoteToolDefinition[]; + mockFetch.mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve([]), + json: () => Promise.resolve(expectedTools), }); const client = new AiProxyClient({ baseUrl }); - await client.getTools(); + const result = await client.getTools(); expect(mockFetch).toHaveBeenCalledWith(`${baseUrl}/remote-tools`, { method: 'GET', @@ -428,6 +564,7 @@ describe('AiProxyClient', () => { body: undefined, signal: expect.any(AbortSignal), }); + expect(result).toEqual(expectedTools); }); it('removes trailing slash from baseUrl', async () => { @@ -441,11 +578,11 @@ describe('AiProxyClient', () => { expect(mockFetch).toHaveBeenCalledWith( 'https://example.com/forest/remote-tools', - expect.anything(), + expect.objectContaining({ method: 'GET' }), ); }); - it('includes custom headers when provided', async () => { + it('includes custom headers merged with Content-Type', async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]), @@ -457,14 +594,34 @@ describe('AiProxyClient', () => { }); 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( - expect.any(String), + `${baseUrl}/remote-tools`, expect.objectContaining({ - headers: { - 'Content-Type': 'application/json', - Authorization: 'Bearer token', - 'X-Custom': 'value', - }, + headers: { 'Content-Type': 'text/plain' }, }), ); });