@inferencesh/sdk 0.6.7 → 0.6.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,10 @@
1
1
  import { HttpClient, createHttpClient } from './client';
2
2
  import { InferenceError, RequirementsNotMetException } from './errors';
3
+ import { EventSource } from 'eventsource';
4
+ jest.mock('eventsource');
3
5
  const mockFetch = jest.fn();
4
6
  global.fetch = mockFetch;
7
+ const MockEventSource = EventSource;
5
8
  function mockJsonResponse(body, status = 200, ok = true) {
6
9
  mockFetch.mockResolvedValueOnce({
7
10
  ok,
@@ -38,17 +41,12 @@ describe('HttpClient', () => {
38
41
  describe('request', () => {
39
42
  const client = () => new HttpClient({ apiKey: 'test-key' });
40
43
  it('should return parsed data on success', async () => {
41
- mockJsonResponse({ success: true, data: { id: 'task-1' } });
44
+ mockJsonResponse({ id: 'task-1' });
42
45
  const result = await client().request('get', '/tasks/task-1');
43
46
  expect(result).toEqual({ id: 'task-1' });
44
47
  });
45
- it('should return null when success is true but data field is omitted', async () => {
46
- mockJsonResponse({ success: true });
47
- const result = await client().request('post', '/tasks/task-1/cancel');
48
- expect(result).toBeNull();
49
- });
50
- it('should return null when success is true with explicit data: null', async () => {
51
- mockJsonResponse({ success: true, data: null });
48
+ it('should return null for null response body', async () => {
49
+ mockJsonResponse(null);
52
50
  const result = await client().request('post', '/tasks/task-1/cancel');
53
51
  expect(result).toBeNull();
54
52
  });
@@ -61,8 +59,8 @@ describe('HttpClient', () => {
61
59
  const result = await client().request('delete', '/tasks/task-1');
62
60
  expect(result).toBeUndefined();
63
61
  });
64
- it('should throw InferenceError when success is false', async () => {
65
- mockJsonResponse({ success: false, error: { message: 'Invalid request' } });
62
+ it('should throw InferenceError on non-ok response', async () => {
63
+ mockJsonResponse({ message: 'Invalid request' }, 400, false);
66
64
  const err = await client().request('get', '/tasks/1').catch((e) => e);
67
65
  expect(err).toBeInstanceOf(InferenceError);
68
66
  expect(err.message).toContain('Invalid request');
@@ -91,7 +89,7 @@ describe('HttpClient', () => {
91
89
  .mockResolvedValueOnce({
92
90
  ok: true,
93
91
  status: 200,
94
- text: () => Promise.resolve(JSON.stringify({ success: true, data: { ok: true } })),
92
+ text: () => Promise.resolve(JSON.stringify({ ok: true })),
95
93
  });
96
94
  const result = await httpClient.request('get', '/tasks/1');
97
95
  expect(result).toEqual({ ok: true });
@@ -99,7 +97,7 @@ describe('HttpClient', () => {
99
97
  });
100
98
  it('should route through proxy with x-inf-target-url header', async () => {
101
99
  const proxyClient = new HttpClient({ proxyUrl: 'https://proxy.example.com' });
102
- mockJsonResponse({ success: true, data: { id: '1' } });
100
+ mockJsonResponse({ id: '1' });
103
101
  await proxyClient.request('get', '/tasks/1');
104
102
  expect(mockFetch).toHaveBeenCalledWith('https://proxy.example.com', expect.objectContaining({
105
103
  headers: expect.objectContaining({
@@ -108,7 +106,7 @@ describe('HttpClient', () => {
108
106
  }));
109
107
  });
110
108
  it('should serialize array query params as JSON', async () => {
111
- mockJsonResponse({ success: true, data: [] });
109
+ mockJsonResponse([]);
112
110
  await client().request('get', '/tasks', {
113
111
  params: { ids: ['a', 'b'] },
114
112
  });
@@ -149,4 +147,44 @@ describe('HttpClient', () => {
149
147
  expect(config.headers.Authorization).toBe('Bearer dynamic-token');
150
148
  });
151
149
  });
150
+ describe('createEventSource', () => {
151
+ beforeEach(() => {
152
+ MockEventSource.mockReset();
153
+ });
154
+ it('should attach Bearer token in direct mode via custom fetch', async () => {
155
+ let capturedFetch;
156
+ MockEventSource.mockImplementation((_url, options) => {
157
+ capturedFetch = options?.fetch;
158
+ return { close: jest.fn(), onmessage: null, onerror: null };
159
+ });
160
+ mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
161
+ const client = new HttpClient({ apiKey: 'sse-key' });
162
+ await client.createEventSource('/tasks/task-1/stream');
163
+ expect(capturedFetch).toBeDefined();
164
+ await capturedFetch('https://api.inference.sh/tasks/task-1/stream', { headers: {} });
165
+ expect(mockFetch).toHaveBeenCalledWith('https://api.inference.sh/tasks/task-1/stream', expect.objectContaining({
166
+ headers: expect.objectContaining({
167
+ Authorization: 'Bearer sse-key',
168
+ }),
169
+ }));
170
+ });
171
+ it('should route through proxy with target URL header on custom fetch', async () => {
172
+ let capturedFetch;
173
+ MockEventSource.mockImplementation((url, options) => {
174
+ capturedFetch = options?.fetch;
175
+ expect(url).toContain('https://proxy.example.com');
176
+ expect(url).toContain('__inf_target=');
177
+ return { close: jest.fn(), onmessage: null, onerror: null };
178
+ });
179
+ mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
180
+ const client = new HttpClient({ proxyUrl: 'https://proxy.example.com' });
181
+ await client.createEventSource('/tasks/task-1/stream');
182
+ await capturedFetch('https://proxy.example.com', { headers: {} });
183
+ expect(mockFetch).toHaveBeenCalledWith('https://proxy.example.com', expect.objectContaining({
184
+ headers: expect.objectContaining({
185
+ 'x-inf-target-url': 'https://api.inference.sh/tasks/task-1/stream',
186
+ }),
187
+ }));
188
+ });
189
+ });
152
190
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,106 @@
1
+ import { INF_TARGET_HEADER } from './index';
2
+ import { createHandler } from './express';
3
+ function createMockResponse() {
4
+ const res = {
5
+ statusCode: 200,
6
+ headers: {},
7
+ body: undefined,
8
+ chunks: [],
9
+ status(code) {
10
+ this.statusCode = code;
11
+ return this;
12
+ },
13
+ setHeader(name, value) {
14
+ this.headers[name] = value;
15
+ },
16
+ json(data) {
17
+ this.body = data;
18
+ return this;
19
+ },
20
+ send(data) {
21
+ this.body = data;
22
+ return this;
23
+ },
24
+ write(chunk) {
25
+ this.chunks.push(chunk);
26
+ },
27
+ end() {
28
+ return;
29
+ },
30
+ };
31
+ return res;
32
+ }
33
+ describe('express createHandler', () => {
34
+ const originalFetch = global.fetch;
35
+ beforeEach(() => {
36
+ jest.clearAllMocks();
37
+ process.env.INFERENCE_API_KEY = 'express-test-key';
38
+ });
39
+ afterEach(() => {
40
+ global.fetch = originalFetch;
41
+ });
42
+ it('should return 400 when target URL is missing', async () => {
43
+ const handler = createHandler();
44
+ const req = {
45
+ method: 'POST',
46
+ body: {},
47
+ headers: {},
48
+ query: {},
49
+ };
50
+ const res = createMockResponse();
51
+ await handler(req, res, jest.fn());
52
+ expect(res.statusCode).toBe(400);
53
+ expect(res.body).toEqual({
54
+ error: `Missing ${INF_TARGET_HEADER} header or __inf_target query param`,
55
+ });
56
+ });
57
+ it('should proxy JSON responses through res.json()', async () => {
58
+ global.fetch = jest.fn().mockResolvedValue(new Response(JSON.stringify({ ok: true }), {
59
+ status: 200,
60
+ headers: { 'content-type': 'application/json' },
61
+ }));
62
+ const handler = createHandler();
63
+ const target = 'https://api.inference.sh/v1/run';
64
+ const req = {
65
+ method: 'POST',
66
+ body: { prompt: 'hi' },
67
+ headers: { [INF_TARGET_HEADER]: target },
68
+ query: {},
69
+ };
70
+ const res = createMockResponse();
71
+ await handler(req, res, jest.fn());
72
+ expect(res.statusCode).toBe(200);
73
+ expect(res.body).toEqual({ ok: true });
74
+ expect(global.fetch).toHaveBeenCalledWith(target, expect.objectContaining({
75
+ headers: expect.objectContaining({
76
+ authorization: 'Bearer express-test-key',
77
+ }),
78
+ }));
79
+ });
80
+ it('should stream SSE responses with res.write()', async () => {
81
+ const encoder = new TextEncoder();
82
+ const stream = new ReadableStream({
83
+ start(controller) {
84
+ controller.enqueue(encoder.encode('data: {"x":1}\n\n'));
85
+ controller.close();
86
+ },
87
+ });
88
+ global.fetch = jest.fn().mockResolvedValue(new Response(stream, {
89
+ status: 200,
90
+ headers: { 'content-type': 'text/event-stream' },
91
+ }));
92
+ const handler = createHandler();
93
+ const target = 'https://api.inference.sh/v1/stream';
94
+ const req = {
95
+ method: 'POST',
96
+ body: {},
97
+ headers: { [INF_TARGET_HEADER]: target },
98
+ query: {},
99
+ };
100
+ const res = createMockResponse();
101
+ await handler(req, res, jest.fn());
102
+ expect(res.statusCode).toBe(200);
103
+ expect(res.chunks.length).toBeGreaterThan(0);
104
+ expect(res.body).toBeUndefined();
105
+ });
106
+ });
@@ -1,4 +1,4 @@
1
- import { INF_TARGET_HEADER, INF_TARGET_PARAM, processProxyRequest, } from './index';
1
+ import { INF_TARGET_HEADER, INF_TARGET_PARAM, headersToRecord, processProxyRequest, } from './index';
2
2
  function createTestAdapter(overrides = {}) {
3
3
  const state = { status: 0 };
4
4
  const adapter = {
@@ -26,6 +26,15 @@ function createTestAdapter(overrides = {}) {
26
26
  };
27
27
  return adapter;
28
28
  }
29
+ describe('headersToRecord', () => {
30
+ it('should convert Headers to a plain record', () => {
31
+ const headers = new Headers({ 'x-test': 'value', 'content-type': 'application/json' });
32
+ expect(headersToRecord(headers)).toEqual({
33
+ 'x-test': 'value',
34
+ 'content-type': 'application/json',
35
+ });
36
+ });
37
+ });
29
38
  describe('processProxyRequest', () => {
30
39
  const originalFetch = global.fetch;
31
40
  const originalApiKey = process.env.INFERENCE_API_KEY;
@@ -211,5 +211,144 @@ describe('StreamManager', () => {
211
211
  await new Promise((r) => setTimeout(r, 50));
212
212
  expect(createEventSource).toHaveBeenCalledTimes(1);
213
213
  });
214
+ it('should stop reconnecting after maxReconnects initial failures', async () => {
215
+ jest.useFakeTimers();
216
+ const onError = jest.fn();
217
+ const createEventSource = jest
218
+ .fn()
219
+ .mockResolvedValue(mockEventSource);
220
+ const manager = new StreamManager({
221
+ createEventSource,
222
+ autoReconnect: true,
223
+ maxReconnects: 2,
224
+ reconnectDelayMs: 100,
225
+ onError,
226
+ });
227
+ await manager.connect();
228
+ mockEventSource.onerror?.({});
229
+ jest.advanceTimersByTime(100);
230
+ await Promise.resolve();
231
+ expect(createEventSource).toHaveBeenCalledTimes(2);
232
+ mockEventSource.onerror?.({});
233
+ jest.advanceTimersByTime(100);
234
+ await Promise.resolve();
235
+ expect(createEventSource).toHaveBeenCalledTimes(3);
236
+ mockEventSource.onerror?.({});
237
+ jest.advanceTimersByTime(100);
238
+ await Promise.resolve();
239
+ expect(createEventSource).toHaveBeenCalledTimes(3);
240
+ jest.useRealTimers();
241
+ });
242
+ it('should call onError and stop when createEventSource throws', async () => {
243
+ jest.useFakeTimers();
244
+ const onError = jest.fn();
245
+ const createEventSource = jest
246
+ .fn()
247
+ .mockRejectedValue(new Error('connection refused'));
248
+ const manager = new StreamManager({
249
+ createEventSource,
250
+ autoReconnect: false,
251
+ onError,
252
+ });
253
+ await manager.connect();
254
+ expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'connection refused' }));
255
+ jest.useRealTimers();
256
+ });
257
+ });
258
+ describe('stopAfter and clearStopTimeout', () => {
259
+ it('should stop after the configured delay', async () => {
260
+ jest.useFakeTimers();
261
+ const onStop = jest.fn();
262
+ const manager = new StreamManager({
263
+ createEventSource: async () => mockEventSource,
264
+ onStop,
265
+ });
266
+ await manager.connect();
267
+ manager.stopAfter(5000);
268
+ jest.advanceTimersByTime(5000);
269
+ expect(mockEventSource.close).toHaveBeenCalled();
270
+ expect(onStop).toHaveBeenCalled();
271
+ jest.useRealTimers();
272
+ });
273
+ it('should cancel a pending stop when clearStopTimeout is called', async () => {
274
+ jest.useFakeTimers();
275
+ const manager = new StreamManager({
276
+ createEventSource: async () => mockEventSource,
277
+ });
278
+ await manager.connect();
279
+ manager.stopAfter(5000);
280
+ manager.clearStopTimeout();
281
+ jest.advanceTimersByTime(5000);
282
+ expect(mockEventSource.close).not.toHaveBeenCalled();
283
+ jest.useRealTimers();
284
+ });
285
+ });
286
+ describe('addEventListener lifecycle', () => {
287
+ it('should register listeners on an existing connection when added after connect', async () => {
288
+ const typedListeners = {};
289
+ const eventSource = {
290
+ onmessage: null,
291
+ onerror: null,
292
+ close: jest.fn(),
293
+ addEventListener: (eventName, handler) => {
294
+ const listeners = typedListeners[eventName] || new Set();
295
+ listeners.add(handler);
296
+ typedListeners[eventName] = listeners;
297
+ },
298
+ };
299
+ const manager = new StreamManager({
300
+ createEventSource: async () => eventSource,
301
+ });
302
+ await manager.connect();
303
+ const onChat = jest.fn();
304
+ manager.addEventListener('chats', onChat);
305
+ const payload = { id: 'chat-2' };
306
+ typedListeners.chats?.forEach((handler) => handler({ data: JSON.stringify(payload) }));
307
+ expect(onChat).toHaveBeenCalledWith(payload);
308
+ });
309
+ it('should remove a listener when the cleanup function is called', async () => {
310
+ const typedListeners = {};
311
+ const eventSource = {
312
+ onmessage: null,
313
+ onerror: null,
314
+ close: jest.fn(),
315
+ addEventListener: (eventName, handler) => {
316
+ const listeners = typedListeners[eventName] || new Set();
317
+ listeners.add(handler);
318
+ typedListeners[eventName] = listeners;
319
+ },
320
+ };
321
+ const manager = new StreamManager({
322
+ createEventSource: async () => eventSource,
323
+ });
324
+ const onChat = jest.fn();
325
+ const unsubscribe = manager.addEventListener('chats', onChat);
326
+ await manager.connect();
327
+ unsubscribe();
328
+ typedListeners.chats?.forEach((handler) => handler({ data: '{"id":"chat-3"}' }));
329
+ expect(onChat).not.toHaveBeenCalled();
330
+ });
331
+ it('should call onError when typed event payload is invalid JSON', async () => {
332
+ const typedListeners = {};
333
+ const eventSource = {
334
+ onmessage: null,
335
+ onerror: null,
336
+ close: jest.fn(),
337
+ addEventListener: (eventName, handler) => {
338
+ const listeners = typedListeners[eventName] || new Set();
339
+ listeners.add(handler);
340
+ typedListeners[eventName] = listeners;
341
+ },
342
+ };
343
+ const onError = jest.fn();
344
+ const manager = new StreamManager({
345
+ createEventSource: async () => eventSource,
346
+ onError,
347
+ });
348
+ manager.addEventListener('chats', jest.fn());
349
+ await manager.connect();
350
+ typedListeners.chats?.forEach((handler) => handler({ data: 'not-json' }));
351
+ expect(onError).toHaveBeenCalled();
352
+ });
214
353
  });
215
354
  });
@@ -1,5 +1,5 @@
1
- import { tool, appTool, agentTool, webhookTool, internalTools, string, number, integer, boolean, enumOf, object, array, optional, } from './tool-builder';
2
- import { ToolTypeClient, ToolTypeApp, ToolTypeAgent, ToolTypeHook } from './types';
1
+ import { tool, appTool, agentTool, webhookTool, httpTool, callTool, mcpTool, internalTools, string, number, integer, boolean, enumOf, object, array, optional, } from './tool-builder';
2
+ import { ToolTypeClient, ToolTypeApp, ToolTypeAgent, ToolTypeHook, ToolTypeHTTP, ToolTypeMCP, IntegrationProviderGoogle, } from './types';
3
3
  describe('Schema Helpers', () => {
4
4
  describe('string', () => {
5
5
  it('creates string schema without description', () => {
@@ -109,6 +109,18 @@ describe('ClientToolBuilder (tool)', () => {
109
109
  const t = tool('get_data').display('Get Data').build();
110
110
  expect(t.display_name).toBe('Get Data');
111
111
  });
112
+ it('creates tool with displayName alias', () => {
113
+ const t = tool('get_data').displayName('Get Data').build();
114
+ expect(t.display_name).toBe('Get Data');
115
+ });
116
+ it('returns schema and handler from handler()', async () => {
117
+ const clientTool = tool('greet')
118
+ .describe('Say hi')
119
+ .param('name', string('Name'))
120
+ .handler(async (args) => `Hello, ${args.name}`);
121
+ expect(clientTool.schema.name).toBe('greet');
122
+ expect(await clientTool.handler({ name: 'Ada' })).toBe('Hello, Ada');
123
+ });
112
124
  it('creates tool with parameters', () => {
113
125
  const t = tool('add')
114
126
  .param('a', number('First number'))
@@ -240,6 +252,61 @@ describe('AgentToolBuilder (agentTool)', () => {
240
252
  expect(t.require_approval).toBe(true);
241
253
  });
242
254
  });
255
+ describe('HTTPToolBuilder (httpTool)', () => {
256
+ it('includes custom method and headers in the built tool', () => {
257
+ const t = httpTool('fetch', 'https://api.example.com/data')
258
+ .method('GET')
259
+ .header('X-Custom', '1')
260
+ .build();
261
+ expect(t.http?.method).toBe('GET');
262
+ expect(t.http?.headers).toEqual({ 'X-Custom': '1' });
263
+ });
264
+ it('omits method when POST is the default', () => {
265
+ const t = httpTool('post', 'https://api.example.com').build();
266
+ expect(t.http?.method).toBeUndefined();
267
+ });
268
+ it('should attach integration auth with provider and integration id', () => {
269
+ const t = httpTool('gmail_send', 'https://api.example.com/send')
270
+ .auth({ integration: IntegrationProviderGoogle, integrationId: 'int-123' })
271
+ .build();
272
+ expect(t.type).toBe(ToolTypeHTTP);
273
+ expect(t.http?.auth).toEqual({
274
+ type: 'integration',
275
+ provider: IntegrationProviderGoogle,
276
+ integration_id: 'int-123',
277
+ });
278
+ });
279
+ it('should attach api key auth with default header', () => {
280
+ const t = httpTool('fetch', 'https://api.example.com').auth({ apiKey: 'KEY' }).build();
281
+ expect(t.http?.auth).toEqual({
282
+ type: 'api_key',
283
+ secret: 'KEY',
284
+ header: 'X-API-Key',
285
+ });
286
+ });
287
+ it('should attach bearer auth', () => {
288
+ const t = httpTool('fetch', 'https://api.example.com')
289
+ .auth({ bearer: 'token-abc' })
290
+ .build();
291
+ expect(t.http?.auth).toEqual({
292
+ type: 'bearer',
293
+ secret: 'token-abc',
294
+ });
295
+ });
296
+ it('callTool should be an alias for httpTool', () => {
297
+ const t = callTool('ping', 'https://api.example.com/ping').method('GET').build();
298
+ expect(t.type).toBe(ToolTypeHTTP);
299
+ expect(t.http?.method).toBe('GET');
300
+ });
301
+ });
302
+ describe('MCPToolBuilder (mcpTool)', () => {
303
+ it('creates MCP tool with integration and tool name', () => {
304
+ const t = mcpTool('search_docs', 'int-mcp-1', 'search').describe('Search docs').build();
305
+ expect(t.type).toBe(ToolTypeMCP);
306
+ expect(t.mcp).toEqual({ integration_id: 'int-mcp-1', tool_name: 'search' });
307
+ expect(t.description).toBe('Search docs');
308
+ });
309
+ });
243
310
  describe('WebhookToolBuilder (webhookTool)', () => {
244
311
  it('creates webhook tool with URL', () => {
245
312
  const t = webhookTool('notify', 'https://api.example.com/webhook')