Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions packages/ai-proxy/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# @forestadmin/ai-proxy

AI Proxy client for Forest Admin.

## Quick Start

```typescript
import { createAiProxyClient } from '@forestadmin/ai-proxy/client';

const client = createAiProxyClient({ baseUrl: 'https://my-agent.com/forest' });

const response = await client.chat('Hello!');
console.log(response.choices[0].message.content);
```

## Installation

**Client only** (frontend, no extra dependencies):

```bash
npm install @forestadmin/ai-proxy
```

**Server side** (Router, ProviderDispatcher):

```bash
npm install @forestadmin/ai-proxy @langchain/core @langchain/openai
```

## Configuration

Choose one mode:

```typescript
// Simple mode (recommended)
const client = createAiProxyClient({
baseUrl: 'https://my-agent.com/forest',
headers: { Authorization: 'Bearer token' }, // optional
timeout: 30000, // optional (default: 30s)
});

// Custom fetch mode
const client = createAiProxyClient({
fetch: myCustomFetch,
timeout: 30000,
});
```

## API

### `chat(input)`

```typescript
// Simple
await client.chat('Hello!');

// With options
await client.chat({
messages: [{ role: 'user', content: 'Hello!' }],
tools: [...], // optional
toolChoice: 'auto', // optional
aiName: 'gpt-4', // optional - server AI config name
});
```

### `getTools()`

```typescript
const tools = await client.getTools();
// [{ name: 'brave_search', description: '...', schema: {...} }]
```

### `callTool(name, inputs)`

```typescript
const result = await client.callTool('brave_search', [
{ role: 'user', content: 'cats' }
]);
```

## Error Handling

```typescript
import { AiProxyClientError } from '@forestadmin/ai-proxy/client';

try {
await client.chat('Hello');
} catch (error) {
if (error instanceof AiProxyClientError) {
console.error(error.status, error.message);

if (error.isNetworkError) { /* status 0 */ }
if (error.isClientError) { /* status 4xx */ }
if (error.isServerError) { /* status 5xx */ }
}
}
```
44 changes: 40 additions & 4 deletions packages/ai-proxy/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -12,19 +23,44 @@
"directory": "packages/ai-proxy"
},
"dependencies": {
"zod": "^4.3.5"
},
"peerDependencies": {
"@langchain/community": "^1.1.4",
"@langchain/core": "^1.1.15",
"@langchain/langgraph": "^1.1.0",
"@langchain/mcp-adapters": "^1.1.1",
"@langchain/openai": "^1.2.2"
},
"peerDependenciesMeta": {
"@langchain/community": {
"optional": true
},
"@langchain/core": {
"optional": true
},
"@langchain/langgraph": {
"optional": true
},
"@langchain/mcp-adapters": {
"optional": true
},
"@langchain/openai": {
"optional": true
}
},
"devDependencies": {
"@forestadmin/datasource-toolkit": "1.50.1",
"@langchain/community": "1.1.4",
"@langchain/core": "1.1.15",
"@langchain/langgraph": "^1.1.0",
"@langchain/mcp-adapters": "1.1.1",
"@langchain/openai": "1.2.2",
"zod": "^4.3.5"
},
"devDependencies": {
"@modelcontextprotocol/sdk": "1.25.3",
"@types/express": "5.0.1",
"express": "5.1.0",
"node-zendesk": "6.0.1"
"node-zendesk": "6.0.1",
"openai": "^6.16.0"
},
"files": [
"dist/**/*.js",
Expand Down
203 changes: 203 additions & 0 deletions packages/ai-proxy/src/client/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import type {
AiProxyClientConfig,
AiQueryResponse,
ChatCompletionMessageParam,
ChatInput,
InvokeToolResponse,
RemoteToolsResponse,
} from './types';

import { AiProxyClientError } from './types';

export * from './types';

const DEFAULT_TIMEOUT = 30_000;

export class AiProxyClient {
private readonly timeout: number;
private readonly mode: 'custom' | 'simple';

// Custom fetch mode
private readonly customFetch?: typeof fetch;

// Simple mode
private readonly baseUrl?: string;
private readonly headers?: Record<string, string>;

constructor(config: AiProxyClientConfig) {
this.timeout = config.timeout ?? DEFAULT_TIMEOUT;

if ('fetch' in config) {
this.mode = 'custom';
this.customFetch = config.fetch;
} else {
this.mode = 'simple';
this.baseUrl = config.baseUrl.replace(/\/$/, '');
this.headers = config.headers;
}
}

/**
* Get the list of available remote tools.
*/
async getTools(): Promise<RemoteToolsResponse> {
return this.request<RemoteToolsResponse>({
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<AiQueryResponse> {
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<AiQueryResponse>({
method: 'POST',
path: '/ai-query',
searchParams,
body: {
messages: normalized.messages,
tools: normalized.tools,
tool_choice: normalized.toolChoice,
},
});
}

/**
* Call a remote tool by name.
*
* @example
* ```typescript
* const result = await client.callTool('brave_search', [
* { role: 'user', content: 'cats' }
* ]);
* ```
*/
async callTool<T = InvokeToolResponse>(
toolName: string,
inputs: ChatCompletionMessageParam[],
): Promise<T> {
const searchParams = new URLSearchParams();
searchParams.set('tool-name', toolName);

return this.request<T>({
method: 'POST',
path: '/invoke-remote-tool',
searchParams,
body: { inputs },
});
}

private async request<T>(params: {
method: 'GET' | 'POST';
path: string;
searchParams?: URLSearchParams;
body?: unknown;
}): Promise<T> {
const { method, path, searchParams, body } = params;

let url = path;

if (searchParams && searchParams.toString()) {
url += `?${searchParams.toString()}`;
}

const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);

try {
const response =
this.mode === 'custom'
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guaranteed by constructor
await this.customFetch!(url, {
method,
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
})
: await fetch(`${this.baseUrl}${url}`, {
method,
headers: { 'Content-Type': 'application/json', ...this.headers },
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
});

if (!response.ok) {
let responseBody: unknown;

try {
responseBody = await response.json();
} catch (jsonError) {
try {
responseBody = await response.text();
} catch {
responseBody = undefined;
}
}

throw new AiProxyClientError(
`${method} ${path} failed with status ${response.status}`,
response.status,
responseBody,
);
}

try {
return (await response.json()) as T;
} catch (parseError) {
throw new AiProxyClientError(
`${method} ${path}: Server returned ${response.status} but response is not valid JSON`,
response.status,
undefined,
parseError instanceof Error ? parseError : undefined,
);
}
} catch (error) {
if (error instanceof AiProxyClientError) {
throw error;
}

if (error instanceof Error && error.name === 'AbortError') {
throw new AiProxyClientError(`${method} ${path} timed out after ${this.timeout}ms`, 408);
}

const cause = error instanceof Error ? error : undefined;
const message = error instanceof Error ? error.message : String(error);
throw new AiProxyClientError(
`${method} ${path} network error: ${message}`,
0,
undefined,
cause,
);
} finally {
clearTimeout(timeoutId);
}
}
}

export function createAiProxyClient(config: AiProxyClientConfig): AiProxyClient {
return new AiProxyClient(config);
}
Loading
Loading