Skip to content

Commit 657874e

Browse files
alban bertoliniclaude
andcommitted
feat(ai-proxy): add HTTP client for frontend and agent-client usage
Add AiProxyClient class with methods to interact with the ai-proxy API: - remoteTools(): GET /remote-tools to list available tools - askAi(): POST /ai-query with Authorization header for chat completions - invokeTool(): POST /invoke-remote-tool to execute a specific tool Includes timeout handling, error handling, and comprehensive tests. Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent ec02b01 commit 657874e

4 files changed

Lines changed: 533 additions & 0 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
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+
openAiApiKey?: string;
12+
timeout?: number;
13+
fetch?: typeof fetch;
14+
}
15+
16+
export interface AskAiInput {
17+
messages: ChatCompletionMessage[];
18+
tools?: ChatCompletionTool[];
19+
tool_choice?: ChatCompletionToolChoice;
20+
aiName?: string;
21+
}
22+
23+
export interface InvokeToolInput {
24+
toolName: string;
25+
inputs: ChatCompletionMessage[];
26+
}
27+
28+
export interface RemoteToolDefinition {
29+
name: string;
30+
description: string;
31+
responseFormat: ResponseFormat;
32+
schema: Record<string, unknown>;
33+
sourceId: string;
34+
sourceType: string;
35+
}
36+
37+
export type { ChatCompletionResponse as ChatCompletion };
38+
39+
export class AiProxyClientError extends Error {
40+
readonly status: number;
41+
readonly body?: unknown;
42+
43+
constructor(message: string, status: number, body?: unknown) {
44+
super(message);
45+
this.name = 'AiProxyClientError';
46+
this.status = status;
47+
this.body = body;
48+
}
49+
}

packages/ai-proxy/src/client.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import type {
2+
AiProxyClientConfig,
3+
AskAiInput,
4+
ChatCompletion,
5+
InvokeToolInput,
6+
RemoteToolDefinition,
7+
} from './client-types';
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 openAiApiKey?: 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.openAiApiKey = config.openAiApiKey;
22+
this.timeout = config.timeout ?? DEFAULT_TIMEOUT;
23+
this.fetchFn = config.fetch ?? fetch;
24+
}
25+
26+
async remoteTools(): Promise<RemoteToolDefinition[]> {
27+
return this.request<RemoteToolDefinition[]>({
28+
method: 'GET',
29+
path: '/remote-tools',
30+
});
31+
}
32+
33+
async askAi(input: AskAiInput): Promise<ChatCompletion> {
34+
const searchParams = new URLSearchParams();
35+
36+
if (input.aiName) {
37+
searchParams.set('ai-name', input.aiName);
38+
}
39+
40+
return this.request<ChatCompletion>({
41+
method: 'POST',
42+
path: '/ai-query',
43+
searchParams,
44+
body: {
45+
messages: input.messages,
46+
tools: input.tools,
47+
tool_choice: input.tool_choice,
48+
},
49+
auth: true,
50+
});
51+
}
52+
53+
async invokeTool<T = unknown>(input: InvokeToolInput): Promise<T> {
54+
const searchParams = new URLSearchParams();
55+
searchParams.set('tool-name', input.toolName);
56+
57+
return this.request<T>({
58+
method: 'POST',
59+
path: '/invoke-remote-tool',
60+
searchParams,
61+
body: {
62+
inputs: input.inputs,
63+
},
64+
});
65+
}
66+
67+
private async request<T>(params: {
68+
method: 'GET' | 'POST';
69+
path: string;
70+
searchParams?: URLSearchParams;
71+
body?: unknown;
72+
auth?: boolean;
73+
}): Promise<T> {
74+
const { method, path, searchParams, body, auth } = params;
75+
76+
let url = `${this.baseUrl}${path}`;
77+
78+
if (searchParams && searchParams.toString()) {
79+
url += `?${searchParams.toString()}`;
80+
}
81+
82+
const headers: Record<string, string> = {
83+
'Content-Type': 'application/json',
84+
};
85+
86+
if (auth && this.openAiApiKey) {
87+
headers.Authorization = `Bearer ${this.openAiApiKey}`;
88+
}
89+
90+
const controller = new AbortController();
91+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
92+
93+
try {
94+
const response = await this.fetchFn(url, {
95+
method,
96+
headers,
97+
body: body ? JSON.stringify(body) : undefined,
98+
signal: controller.signal,
99+
});
100+
101+
if (!response.ok) {
102+
let responseBody: unknown;
103+
104+
try {
105+
responseBody = await response.json();
106+
} catch {
107+
responseBody = await response.text().catch(() => undefined);
108+
}
109+
110+
throw new AiProxyClientError(
111+
`Request failed with status ${response.status}`,
112+
response.status,
113+
responseBody,
114+
);
115+
}
116+
117+
return (await response.json()) as T;
118+
} catch (error) {
119+
if (error instanceof AiProxyClientError) {
120+
throw error;
121+
}
122+
123+
if (error instanceof Error && error.name === 'AbortError') {
124+
throw new AiProxyClientError(`Request timeout after ${this.timeout}ms`, 408);
125+
}
126+
127+
throw new AiProxyClientError(
128+
`Network error: ${error instanceof Error ? error.message : String(error)}`,
129+
0,
130+
);
131+
} finally {
132+
clearTimeout(timeoutId);
133+
}
134+
}
135+
}
136+
137+
export function createAiProxyClient(config: AiProxyClientConfig): AiProxyClient {
138+
return new AiProxyClient(config);
139+
}

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)