Skip to content

Commit 87fbd66

Browse files
alban bertoliniclaude
andcommitted
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, model instead of tool_choice, ai-name) - Renamed apiKey (more generic than openAiApiKey) - Clear method names: chat(), getTools(), callTool() Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent ec02b01 commit 87fbd66

4 files changed

Lines changed: 575 additions & 0 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type {
2+
ChatCompletionMessage,
3+
ChatCompletionResponse,
4+
ChatCompletionTool,
5+
ChatCompletionToolChoice,
6+
} from './provider-dispatcher';
7+
import type { ResponseFormat } from '@langchain/core/tools';
8+
9+
export interface AiProxyClientConfig {
10+
baseUrl: string;
11+
apiKey?: string;
12+
timeout?: number;
13+
fetch?: typeof fetch;
14+
}
15+
16+
export interface ChatInput {
17+
messages: ChatCompletionMessage[];
18+
tools?: ChatCompletionTool[];
19+
toolChoice?: ChatCompletionToolChoice;
20+
model?: string;
21+
}
22+
23+
export interface RemoteToolDefinition {
24+
name: string;
25+
description: string;
26+
responseFormat: ResponseFormat;
27+
schema: Record<string, unknown>;
28+
sourceId: string;
29+
sourceType: string;
30+
}
31+
32+
export type { ChatCompletionResponse as ChatCompletion };
33+
34+
export class AiProxyClientError extends Error {
35+
readonly status: number;
36+
readonly body?: unknown;
37+
38+
constructor(message: string, status: number, body?: unknown) {
39+
super(message);
40+
this.name = 'AiProxyClientError';
41+
this.status = status;
42+
this.body = body;
43+
}
44+
}

packages/ai-proxy/src/client.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import type {
2+
AiProxyClientConfig,
3+
ChatCompletion,
4+
ChatInput,
5+
RemoteToolDefinition,
6+
} from './client-types';
7+
import type { ChatCompletionMessage } from './provider-dispatcher';
8+
9+
import { AiProxyClientError } from './client-types';
10+
11+
const DEFAULT_TIMEOUT = 30_000;
12+
13+
export class AiProxyClient {
14+
private readonly baseUrl: string;
15+
private readonly apiKey?: string;
16+
private readonly timeout: number;
17+
private readonly fetchFn: typeof fetch;
18+
19+
constructor(config: AiProxyClientConfig) {
20+
this.baseUrl = config.baseUrl.replace(/\/$/, '');
21+
this.apiKey = config.apiKey;
22+
this.timeout = config.timeout ?? DEFAULT_TIMEOUT;
23+
this.fetchFn = config.fetch ?? fetch;
24+
}
25+
26+
/**
27+
* Get the list of available remote tools.
28+
*/
29+
async getTools(): Promise<RemoteToolDefinition[]> {
30+
return this.request<RemoteToolDefinition[]>({
31+
method: 'GET',
32+
path: '/remote-tools',
33+
});
34+
}
35+
36+
/**
37+
* Send a chat message to the AI.
38+
*
39+
* @example Simple usage with a string
40+
* ```typescript
41+
* const response = await client.chat('Hello, how are you?');
42+
* ```
43+
*
44+
* @example Advanced usage with options
45+
* ```typescript
46+
* const response = await client.chat({
47+
* messages: [{ role: 'user', content: 'Search for cats' }],
48+
* tools: [...],
49+
* toolChoice: 'auto',
50+
* model: 'gpt-4',
51+
* });
52+
* ```
53+
*/
54+
async chat(input: string | ChatInput): Promise<ChatCompletion> {
55+
const normalized: ChatInput =
56+
typeof input === 'string' ? { messages: [{ role: 'user', content: input }] } : input;
57+
58+
const searchParams = new URLSearchParams();
59+
60+
if (normalized.model) {
61+
searchParams.set('ai-name', normalized.model);
62+
}
63+
64+
return this.request<ChatCompletion>({
65+
method: 'POST',
66+
path: '/ai-query',
67+
searchParams,
68+
body: {
69+
messages: normalized.messages,
70+
tools: normalized.tools,
71+
tool_choice: normalized.toolChoice,
72+
},
73+
auth: true,
74+
});
75+
}
76+
77+
/**
78+
* Call a remote tool by name.
79+
*
80+
* @example
81+
* ```typescript
82+
* const result = await client.callTool('brave_search', [
83+
* { role: 'user', content: 'cats' }
84+
* ]);
85+
* ```
86+
*/
87+
async callTool<T = unknown>(toolName: string, inputs: ChatCompletionMessage[]): Promise<T> {
88+
const searchParams = new URLSearchParams();
89+
searchParams.set('tool-name', toolName);
90+
91+
return this.request<T>({
92+
method: 'POST',
93+
path: '/invoke-remote-tool',
94+
searchParams,
95+
body: { inputs },
96+
});
97+
}
98+
99+
private async request<T>(params: {
100+
method: 'GET' | 'POST';
101+
path: string;
102+
searchParams?: URLSearchParams;
103+
body?: unknown;
104+
auth?: boolean;
105+
}): Promise<T> {
106+
const { method, path, searchParams, body, auth } = params;
107+
108+
let url = `${this.baseUrl}${path}`;
109+
110+
if (searchParams && searchParams.toString()) {
111+
url += `?${searchParams.toString()}`;
112+
}
113+
114+
const headers: Record<string, string> = {
115+
'Content-Type': 'application/json',
116+
};
117+
118+
if (auth && this.apiKey) {
119+
headers.Authorization = `Bearer ${this.apiKey}`;
120+
}
121+
122+
const controller = new AbortController();
123+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
124+
125+
try {
126+
const response = await this.fetchFn(url, {
127+
method,
128+
headers,
129+
body: body ? JSON.stringify(body) : undefined,
130+
signal: controller.signal,
131+
});
132+
133+
if (!response.ok) {
134+
let responseBody: unknown;
135+
136+
try {
137+
responseBody = await response.json();
138+
} catch {
139+
responseBody = await response.text().catch(() => undefined);
140+
}
141+
142+
throw new AiProxyClientError(
143+
`Request failed with status ${response.status}`,
144+
response.status,
145+
responseBody,
146+
);
147+
}
148+
149+
return (await response.json()) as T;
150+
} catch (error) {
151+
if (error instanceof AiProxyClientError) {
152+
throw error;
153+
}
154+
155+
if (error instanceof Error && error.name === 'AbortError') {
156+
throw new AiProxyClientError(`Request timeout after ${this.timeout}ms`, 408);
157+
}
158+
159+
throw new AiProxyClientError(
160+
`Network error: ${error instanceof Error ? error.message : String(error)}`,
161+
0,
162+
);
163+
} finally {
164+
clearTimeout(timeoutId);
165+
}
166+
}
167+
}
168+
169+
export function createAiProxyClient(config: AiProxyClientConfig): AiProxyClient {
170+
return new AiProxyClient(config);
171+
}

packages/ai-proxy/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ export * from './mcp-client';
99

1010
export * from './types/errors';
1111

12+
export * from './client';
13+
export * from './client-types';
14+
1215
export function validMcpConfigurationOrThrow(mcpConfig: McpConfiguration) {
1316
return McpConfigChecker.check(mcpConfig);
1417
}

0 commit comments

Comments
 (0)