@inferencesh/sdk 0.6.4 → 0.6.6

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.
@@ -0,0 +1,152 @@
1
+ import { HttpClient, createHttpClient } from './client';
2
+ import { InferenceError, RequirementsNotMetException } from './errors';
3
+ const mockFetch = jest.fn();
4
+ global.fetch = mockFetch;
5
+ function mockJsonResponse(body, status = 200, ok = true) {
6
+ mockFetch.mockResolvedValueOnce({
7
+ ok,
8
+ status,
9
+ text: () => Promise.resolve(JSON.stringify(body)),
10
+ });
11
+ }
12
+ describe('HttpClient', () => {
13
+ beforeEach(() => {
14
+ jest.clearAllMocks();
15
+ });
16
+ describe('constructor', () => {
17
+ it('should throw when no apiKey, getToken, or proxyUrl', () => {
18
+ expect(() => new HttpClient({})).toThrow('Either apiKey, getToken, or proxyUrl is required');
19
+ });
20
+ it('should allow proxyUrl without apiKey', () => {
21
+ const client = new HttpClient({ proxyUrl: 'https://proxy.example.com' });
22
+ expect(client.isProxyMode()).toBe(true);
23
+ });
24
+ it('should expose stream and poll interval config', () => {
25
+ const client = new HttpClient({
26
+ apiKey: 'key',
27
+ stream: false,
28
+ pollIntervalMs: 5000,
29
+ });
30
+ expect(client.getStreamDefault()).toBe(false);
31
+ expect(client.getPollIntervalMs()).toBe(5000);
32
+ });
33
+ it('createHttpClient should return an HttpClient instance', () => {
34
+ const client = createHttpClient({ apiKey: 'key' });
35
+ expect(client).toBeInstanceOf(HttpClient);
36
+ });
37
+ });
38
+ describe('request', () => {
39
+ const client = () => new HttpClient({ apiKey: 'test-key' });
40
+ it('should return parsed data on success', async () => {
41
+ mockJsonResponse({ success: true, data: { id: 'task-1' } });
42
+ const result = await client().request('get', '/tasks/task-1');
43
+ expect(result).toEqual({ id: 'task-1' });
44
+ });
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 });
52
+ const result = await client().request('post', '/tasks/task-1/cancel');
53
+ expect(result).toBeNull();
54
+ });
55
+ it('should return undefined for 204 No Content', async () => {
56
+ mockFetch.mockResolvedValueOnce({
57
+ ok: true,
58
+ status: 204,
59
+ text: () => Promise.resolve(''),
60
+ });
61
+ const result = await client().request('delete', '/tasks/task-1');
62
+ expect(result).toBeUndefined();
63
+ });
64
+ it('should throw InferenceError when success is false', async () => {
65
+ mockJsonResponse({ success: false, error: { message: 'Invalid request' } });
66
+ const err = await client().request('get', '/tasks/1').catch((e) => e);
67
+ expect(err).toBeInstanceOf(InferenceError);
68
+ expect(err.message).toContain('Invalid request');
69
+ });
70
+ it('should throw RequirementsNotMetException on HTTP 412', async () => {
71
+ mockFetch.mockResolvedValueOnce({
72
+ ok: false,
73
+ status: 412,
74
+ text: () => Promise.resolve(JSON.stringify({
75
+ errors: [{ type: 'secret', key: 'API_KEY', message: 'Missing secret' }],
76
+ })),
77
+ });
78
+ await expect(client().request('post', '/apps/run')).rejects.toBeInstanceOf(RequirementsNotMetException);
79
+ });
80
+ it('should retry when onError handler calls retry', async () => {
81
+ const httpClient = new HttpClient({
82
+ apiKey: 'key',
83
+ onError: async (_error, retry) => retry(),
84
+ });
85
+ mockFetch
86
+ .mockResolvedValueOnce({
87
+ ok: false,
88
+ status: 500,
89
+ text: () => Promise.resolve('server error'),
90
+ })
91
+ .mockResolvedValueOnce({
92
+ ok: true,
93
+ status: 200,
94
+ text: () => Promise.resolve(JSON.stringify({ success: true, data: { ok: true } })),
95
+ });
96
+ const result = await httpClient.request('get', '/tasks/1');
97
+ expect(result).toEqual({ ok: true });
98
+ expect(mockFetch).toHaveBeenCalledTimes(2);
99
+ });
100
+ it('should route through proxy with x-inf-target-url header', async () => {
101
+ const proxyClient = new HttpClient({ proxyUrl: 'https://proxy.example.com' });
102
+ mockJsonResponse({ success: true, data: { id: '1' } });
103
+ await proxyClient.request('get', '/tasks/1');
104
+ expect(mockFetch).toHaveBeenCalledWith('https://proxy.example.com', expect.objectContaining({
105
+ headers: expect.objectContaining({
106
+ 'x-inf-target-url': 'https://api.inference.sh/tasks/1',
107
+ }),
108
+ }));
109
+ });
110
+ it('should serialize array query params as JSON', async () => {
111
+ mockJsonResponse({ success: true, data: [] });
112
+ await client().request('get', '/tasks', {
113
+ params: { ids: ['a', 'b'] },
114
+ });
115
+ const calledUrl = mockFetch.mock.calls[0][0];
116
+ expect(calledUrl).toContain('ids=');
117
+ expect(decodeURIComponent(calledUrl)).toContain('["a","b"]');
118
+ });
119
+ it('should use top-level message field in HTTP error responses', async () => {
120
+ mockFetch.mockResolvedValueOnce({
121
+ ok: false,
122
+ status: 503,
123
+ text: () => Promise.resolve(JSON.stringify({ message: 'service unavailable' })),
124
+ });
125
+ await expect(client().request('get', '/tasks/1')).rejects.toMatchObject({
126
+ name: 'InferenceError',
127
+ message: expect.stringContaining('service unavailable'),
128
+ });
129
+ });
130
+ });
131
+ describe('getStreamableConfig', () => {
132
+ it('should include bearer token in direct mode', () => {
133
+ const config = new HttpClient({ apiKey: 'secret-key' }).getStreamableConfig('/tasks/task-1/stream');
134
+ expect(config.url).toBe('https://api.inference.sh/tasks/task-1/stream');
135
+ expect(config.headers.Authorization).toBe('Bearer secret-key');
136
+ });
137
+ it('should route through proxy with target URL header', () => {
138
+ const config = new HttpClient({
139
+ proxyUrl: 'https://proxy.example.com/api',
140
+ }).getStreamableConfig('/tasks/task-1/stream');
141
+ expect(config.url).toContain('https://proxy.example.com/api');
142
+ expect(config.url).toContain('__inf_target=');
143
+ expect(config.headers['x-inf-target-url']).toBe('https://api.inference.sh/tasks/task-1/stream');
144
+ });
145
+ it('should use getToken when apiKey is not set', () => {
146
+ const config = new HttpClient({
147
+ getToken: () => 'dynamic-token',
148
+ }).getStreamableConfig('/tasks/task-1/stream');
149
+ expect(config.headers.Authorization).toBe('Bearer dynamic-token');
150
+ });
151
+ });
152
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,53 @@
1
+ import { InferenceError, RequirementsNotMetException, SessionEndedError, SessionExpiredError, SessionNotFoundError, WorkerLostError, isInferenceError, isRequirementsNotMetException, isSessionError, } from './errors';
2
+ describe('error type guards', () => {
3
+ it('isRequirementsNotMetException should match instances and plain objects', () => {
4
+ const instance = new RequirementsNotMetException([
5
+ { type: 'secret', key: 'K', message: 'missing' },
6
+ ]);
7
+ expect(isRequirementsNotMetException(instance)).toBe(true);
8
+ const plain = {
9
+ name: 'RequirementsNotMetException',
10
+ statusCode: 412,
11
+ errors: [{ type: 'secret', key: 'K', message: 'missing' }],
12
+ };
13
+ expect(isRequirementsNotMetException(plain)).toBe(true);
14
+ expect(isRequirementsNotMetException(new Error('other'))).toBe(false);
15
+ });
16
+ it('isInferenceError should match instances and plain objects', () => {
17
+ const instance = new InferenceError(500, 'server error');
18
+ expect(isInferenceError(instance)).toBe(true);
19
+ expect(isInferenceError({ name: 'InferenceError', statusCode: 404 })).toBe(true);
20
+ expect(isInferenceError({ name: 'InferenceError' })).toBe(false);
21
+ });
22
+ it('isSessionError should match session error subclasses and plain objects', () => {
23
+ const instance = new SessionNotFoundError('sess-1');
24
+ expect(isSessionError(instance)).toBe(true);
25
+ expect(isSessionError({
26
+ name: 'SessionNotFoundError',
27
+ sessionId: 'sess-1',
28
+ statusCode: 404,
29
+ })).toBe(true);
30
+ expect(isSessionError({ name: 'InferenceError', statusCode: 500 })).toBe(false);
31
+ });
32
+ it('session error subclasses should expose sessionId', () => {
33
+ expect(new SessionNotFoundError('sess-1').sessionId).toBe('sess-1');
34
+ expect(new SessionExpiredError('sess-2').statusCode).toBe(410);
35
+ expect(new SessionEndedError('sess-3').name).toBe('SessionEndedError');
36
+ expect(new WorkerLostError('sess-4').statusCode).toBe(500);
37
+ });
38
+ });
39
+ describe('error classes', () => {
40
+ it('RequirementsNotMetException.fromResponse should default empty errors', () => {
41
+ const err = RequirementsNotMetException.fromResponse({}, 412);
42
+ expect(err.errors).toEqual([]);
43
+ expect(err.statusCode).toBe(412);
44
+ expect(err.message).toBe('requirements not met');
45
+ });
46
+ it('session error subclasses should expose sessionId and statusCode', () => {
47
+ expect(new SessionNotFoundError('sess-a').sessionId).toBe('sess-a');
48
+ expect(new SessionNotFoundError('sess-a').statusCode).toBe(404);
49
+ expect(new SessionExpiredError('sess-b').statusCode).toBe(410);
50
+ expect(new SessionEndedError('sess-c').message).toContain('sess-c');
51
+ expect(new WorkerLostError('sess-d').name).toBe('WorkerLostError');
52
+ });
53
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,85 @@
1
+ import { PollManager } from './poll';
2
+ describe('PollManager', () => {
3
+ beforeEach(() => {
4
+ jest.useFakeTimers();
5
+ });
6
+ afterEach(() => {
7
+ jest.useRealTimers();
8
+ });
9
+ it('should invoke onData after the immediate first poll', async () => {
10
+ const onData = jest.fn();
11
+ const pollFunction = jest.fn().mockResolvedValue({ status: 'running' });
12
+ const manager = new PollManager({
13
+ pollFunction,
14
+ intervalMs: 1000,
15
+ onData,
16
+ });
17
+ manager.start();
18
+ await Promise.resolve();
19
+ expect(pollFunction).toHaveBeenCalledTimes(1);
20
+ expect(onData).toHaveBeenCalledWith({ status: 'running' });
21
+ manager.stop();
22
+ });
23
+ it('should poll repeatedly at intervalMs', async () => {
24
+ const pollFunction = jest.fn().mockResolvedValue({ status: 'running' });
25
+ const manager = new PollManager({
26
+ pollFunction,
27
+ intervalMs: 1000,
28
+ });
29
+ manager.start();
30
+ await Promise.resolve();
31
+ expect(pollFunction).toHaveBeenCalledTimes(1);
32
+ jest.advanceTimersByTime(1000);
33
+ await Promise.resolve();
34
+ expect(pollFunction).toHaveBeenCalledTimes(2);
35
+ manager.stop();
36
+ });
37
+ it('should call onStart when started and onStop when stopped', async () => {
38
+ const onStart = jest.fn();
39
+ const onStop = jest.fn();
40
+ const pollFunction = jest.fn().mockResolvedValue({});
41
+ const manager = new PollManager({ pollFunction, onStart, onStop });
42
+ manager.start();
43
+ expect(onStart).toHaveBeenCalled();
44
+ manager.stop();
45
+ expect(onStop).toHaveBeenCalled();
46
+ });
47
+ it('should stop after maxRetries consecutive poll errors', async () => {
48
+ const onError = jest.fn();
49
+ const onStop = jest.fn();
50
+ const pollFunction = jest.fn().mockRejectedValue(new Error('network error'));
51
+ const manager = new PollManager({
52
+ pollFunction,
53
+ intervalMs: 100,
54
+ maxRetries: 3,
55
+ onError,
56
+ onStop,
57
+ });
58
+ manager.start();
59
+ // Immediate first poll
60
+ await Promise.resolve();
61
+ jest.advanceTimersByTime(100);
62
+ await Promise.resolve();
63
+ jest.advanceTimersByTime(100);
64
+ await Promise.resolve();
65
+ expect(onError).toHaveBeenCalledTimes(3);
66
+ expect(onStop).toHaveBeenCalled();
67
+ expect(pollFunction.mock.calls.length).toBeGreaterThanOrEqual(3);
68
+ });
69
+ it('should not invoke onData after stop', async () => {
70
+ const onData = jest.fn();
71
+ const pollFunction = jest.fn().mockResolvedValue({ status: 'running' });
72
+ const manager = new PollManager({
73
+ pollFunction,
74
+ intervalMs: 100,
75
+ onData,
76
+ });
77
+ manager.start();
78
+ await Promise.resolve();
79
+ manager.stop();
80
+ const callsBefore = onData.mock.calls.length;
81
+ jest.advanceTimersByTime(500);
82
+ await Promise.resolve();
83
+ expect(onData.mock.calls.length).toBe(callsBefore);
84
+ });
85
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,147 @@
1
+ import { INF_TARGET_HEADER, INF_TARGET_PARAM, processProxyRequest, } from './index';
2
+ function createTestAdapter(overrides = {}) {
3
+ const state = { status: 0 };
4
+ const adapter = {
5
+ framework: 'test',
6
+ method: 'POST',
7
+ body: async () => '{"ok":true}',
8
+ headers: () => ({}),
9
+ header: () => undefined,
10
+ query: () => undefined,
11
+ setHeader: (name, value) => {
12
+ state.headers = state.headers ?? {};
13
+ state.headers[name] = value;
14
+ },
15
+ error: (status, message) => {
16
+ state.status = status;
17
+ state.body = message;
18
+ return state;
19
+ },
20
+ respond: async (response) => {
21
+ state.status = response.status;
22
+ state.body = await response.text();
23
+ return state;
24
+ },
25
+ ...overrides,
26
+ };
27
+ return adapter;
28
+ }
29
+ describe('processProxyRequest', () => {
30
+ const originalFetch = global.fetch;
31
+ const originalApiKey = process.env.INFERENCE_API_KEY;
32
+ beforeEach(() => {
33
+ jest.clearAllMocks();
34
+ process.env.INFERENCE_API_KEY = 'env-test-key';
35
+ global.fetch = jest.fn().mockResolvedValue(new Response(JSON.stringify({ ok: true }), {
36
+ status: 200,
37
+ headers: { 'content-type': 'application/json' },
38
+ }));
39
+ });
40
+ afterEach(() => {
41
+ global.fetch = originalFetch;
42
+ if (originalApiKey === undefined) {
43
+ delete process.env.INFERENCE_API_KEY;
44
+ }
45
+ else {
46
+ process.env.INFERENCE_API_KEY = originalApiKey;
47
+ }
48
+ });
49
+ it('should reject requests without a target URL', async () => {
50
+ const result = await processProxyRequest(createTestAdapter());
51
+ expect(result.status).toBe(400);
52
+ expect(result.body).toEqual({
53
+ error: `Missing ${INF_TARGET_HEADER} header or ${INF_TARGET_PARAM} query param`,
54
+ });
55
+ expect(global.fetch).not.toHaveBeenCalled();
56
+ });
57
+ it('should reject invalid target URLs', async () => {
58
+ const result = await processProxyRequest(createTestAdapter({
59
+ header: (name) => (name === INF_TARGET_HEADER ? 'not-a-url' : undefined),
60
+ }));
61
+ expect(result.status).toBe(400);
62
+ expect(result.body).toEqual({ error: 'Invalid target URL' });
63
+ expect(global.fetch).not.toHaveBeenCalled();
64
+ });
65
+ it('should reject non-inference.sh domains', async () => {
66
+ const result = await processProxyRequest(createTestAdapter({
67
+ header: (name) => name === INF_TARGET_HEADER ? 'https://evil.example.com/run' : undefined,
68
+ }));
69
+ expect(result.status).toBe(412);
70
+ expect(result.body).toEqual({
71
+ error: 'Target must be an inference.sh domain, got: evil.example.com',
72
+ });
73
+ expect(global.fetch).not.toHaveBeenCalled();
74
+ });
75
+ it('should reject when no API key is configured', async () => {
76
+ delete process.env.INFERENCE_API_KEY;
77
+ const result = await processProxyRequest(createTestAdapter({
78
+ header: (name) => name === INF_TARGET_HEADER ? 'https://api.inference.sh/run' : undefined,
79
+ }));
80
+ expect(result.status).toBe(401);
81
+ expect(result.body).toEqual({
82
+ error: 'Missing INFERENCE_API_KEY environment variable',
83
+ });
84
+ expect(global.fetch).not.toHaveBeenCalled();
85
+ });
86
+ it('should proxy valid inference.sh requests with env API key', async () => {
87
+ const target = 'https://api.inference.sh/v1/tasks/1';
88
+ const result = await processProxyRequest(createTestAdapter({
89
+ header: (name) => (name === INF_TARGET_HEADER ? target : undefined),
90
+ }));
91
+ expect(result.status).toBe(200);
92
+ expect(global.fetch).toHaveBeenCalledWith(target, expect.objectContaining({
93
+ method: 'POST',
94
+ headers: expect.objectContaining({
95
+ authorization: 'Bearer env-test-key',
96
+ 'x-inf-proxy': '@inferencesh/sdk-proxy/test',
97
+ }),
98
+ }));
99
+ });
100
+ it('should use query param fallback when header is missing (SSE clients)', async () => {
101
+ const target = 'https://api.inference.sh/v1/stream';
102
+ const encoded = encodeURIComponent(target);
103
+ await processProxyRequest(createTestAdapter({
104
+ query: (name) => (name === INF_TARGET_PARAM ? encoded : undefined),
105
+ }));
106
+ expect(global.fetch).toHaveBeenCalledWith(target, expect.any(Object));
107
+ });
108
+ it('should honor custom allowedDomains', async () => {
109
+ const target = 'https://cdn.custom.example/upload';
110
+ await processProxyRequest(createTestAdapter({
111
+ header: (name) => (name === INF_TARGET_HEADER ? target : undefined),
112
+ }), { apiKey: 'custom-key', allowedDomains: [/custom\.example$/] });
113
+ expect(global.fetch).toHaveBeenCalledWith(target, expect.any(Object));
114
+ });
115
+ it('should forward x-inf-* request headers to upstream', async () => {
116
+ const target = 'https://api.inference.sh/run';
117
+ await processProxyRequest(createTestAdapter({
118
+ header: (name) => {
119
+ if (name === INF_TARGET_HEADER)
120
+ return target;
121
+ if (name === 'x-inf-session-id')
122
+ return 'sess-123';
123
+ return undefined;
124
+ },
125
+ headers: () => ({
126
+ [INF_TARGET_HEADER]: target,
127
+ 'x-inf-session-id': 'sess-123',
128
+ }),
129
+ }), { apiKey: 'key' });
130
+ expect(global.fetch).toHaveBeenCalledWith(target, expect.objectContaining({
131
+ headers: expect.objectContaining({
132
+ 'x-inf-session-id': 'sess-123',
133
+ }),
134
+ }));
135
+ });
136
+ it('should omit body for GET requests', async () => {
137
+ const target = 'https://api.inference.sh/tasks/1';
138
+ await processProxyRequest(createTestAdapter({
139
+ method: 'GET',
140
+ header: (name) => (name === INF_TARGET_HEADER ? target : undefined),
141
+ }), { apiKey: 'key' });
142
+ expect(global.fetch).toHaveBeenCalledWith(target, expect.objectContaining({
143
+ method: 'GET',
144
+ body: undefined,
145
+ }));
146
+ });
147
+ });
@@ -128,6 +128,54 @@ describe('StreamManager', () => {
128
128
  expect(onData).not.toHaveBeenCalled();
129
129
  });
130
130
  });
131
+ describe('addEventListener', () => {
132
+ it('should deliver typed SSE events to registered listeners', async () => {
133
+ const typedListeners = {};
134
+ const eventSource = {
135
+ onmessage: null,
136
+ onerror: null,
137
+ close: jest.fn(),
138
+ addEventListener: (eventName, handler) => {
139
+ const listeners = typedListeners[eventName] || new Set();
140
+ listeners.add(handler);
141
+ typedListeners[eventName] = listeners;
142
+ },
143
+ };
144
+ const manager = new StreamManager({
145
+ createEventSource: async () => eventSource,
146
+ });
147
+ const onChat = jest.fn();
148
+ manager.addEventListener('chats', onChat);
149
+ await manager.connect();
150
+ const payload = { id: 'chat-1', status: 'open' };
151
+ typedListeners.chats?.forEach((handler) => handler({ data: JSON.stringify(payload) }));
152
+ expect(onChat).toHaveBeenCalledWith(payload);
153
+ });
154
+ it('should unwrap partial wrappers for typed events', async () => {
155
+ const typedListeners = {};
156
+ const eventSource = {
157
+ onmessage: null,
158
+ onerror: null,
159
+ close: jest.fn(),
160
+ addEventListener: (eventName, handler) => {
161
+ const listeners = typedListeners[eventName] || new Set();
162
+ listeners.add(handler);
163
+ typedListeners[eventName] = listeners;
164
+ },
165
+ };
166
+ const manager = new StreamManager({
167
+ createEventSource: async () => eventSource,
168
+ });
169
+ const onMessage = jest.fn();
170
+ manager.addEventListener('chat_messages', onMessage);
171
+ await manager.connect();
172
+ const inner = { id: 'msg-1', order: 1 };
173
+ typedListeners.chat_messages?.forEach((handler) => handler({
174
+ data: JSON.stringify({ data: inner, fields: ['order'] }),
175
+ }));
176
+ expect(onMessage).toHaveBeenCalledWith(inner);
177
+ });
178
+ });
131
179
  describe('reconnection', () => {
132
180
  it('should attempt reconnection on error when autoReconnect is true', async () => {
133
181
  jest.useFakeTimers();