@inferencesh/sdk 0.6.12 → 0.6.14

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.
@@ -61,4 +61,38 @@ describe('MCPServersAPI', () => {
61
61
  expect(url).toContain('/mcp-servers/mcp-1');
62
62
  expect(init.method).toBe('PUT');
63
63
  });
64
+ it('should GET /mcps/{slug} for get()', async () => {
65
+ const server = { slug: 'filesystem', name: 'Filesystem MCP' };
66
+ mockJsonResponse(server);
67
+ const result = await api().get('filesystem');
68
+ expect(result).toEqual(server);
69
+ const [url, init] = mockFetch.mock.calls[0];
70
+ expect(url).toContain('/mcps/filesystem');
71
+ expect(init.method).toBe('GET');
72
+ });
73
+ it('should POST /mcp-servers/list for listOwned()', async () => {
74
+ const page = { items: [{ id: 'mcp-1' }], next_cursor: null };
75
+ mockJsonResponse(page);
76
+ const result = await api().listOwned({ limit: 5 });
77
+ expect(result).toEqual(page);
78
+ const [url, init] = mockFetch.mock.calls[0];
79
+ expect(url).toContain('/mcp-servers/list');
80
+ expect(init.method).toBe('POST');
81
+ });
82
+ it('should GET /mcp-servers/{id} for getOwned()', async () => {
83
+ const server = { id: 'mcp-1', name: 'My MCP' };
84
+ mockJsonResponse(server);
85
+ const result = await api().getOwned('mcp-1');
86
+ expect(result).toEqual(server);
87
+ const [url, init] = mockFetch.mock.calls[0];
88
+ expect(url).toContain('/mcp-servers/mcp-1');
89
+ expect(init.method).toBe('GET');
90
+ });
91
+ it('should DELETE /mcp-servers/{id} for delete()', async () => {
92
+ mockJsonResponse(null);
93
+ await api().delete('mcp-1');
94
+ const [url, init] = mockFetch.mock.calls[0];
95
+ expect(url).toContain('/mcp-servers/mcp-1');
96
+ expect(init.method).toBe('DELETE');
97
+ });
64
98
  });
@@ -49,4 +49,13 @@ describe('ProjectsAPI', () => {
49
49
  expect(url).toContain('/projects/proj-1');
50
50
  expect(init.method).toBe('DELETE');
51
51
  });
52
+ it('should POST /projects/{id} for update()', async () => {
53
+ const project = { id: 'proj-1', name: 'Renamed' };
54
+ mockJsonResponse(project);
55
+ const result = await api().update('proj-1', { name: 'Renamed' });
56
+ expect(result).toEqual(project);
57
+ const [url, init] = mockFetch.mock.calls[0];
58
+ expect(url).toContain('/projects/proj-1');
59
+ expect(JSON.parse(init.body)).toEqual({ name: 'Renamed' });
60
+ });
52
61
  });
@@ -25,6 +25,17 @@ describe('SearchAPI', () => {
25
25
  expect(init.method).toBe('POST');
26
26
  expect(JSON.parse(init.body)).toEqual(payload);
27
27
  });
28
+ it('should forward conversation context in suggest() body', async () => {
29
+ const payload = {
30
+ query: 'flux',
31
+ context: 'previous conversation about image generation',
32
+ limit: 5,
33
+ };
34
+ mockJsonResponse({ results: [] });
35
+ await api().suggest(payload);
36
+ const [, init] = mockFetch.mock.calls[0];
37
+ expect(JSON.parse(init.body)).toEqual(payload);
38
+ });
28
39
  it('should POST /search for search()', async () => {
29
40
  const payload = { q: 'claude', type: 'apps', limit: 5 };
30
41
  const response = { hits: [{ id: 'app-1' }] };
@@ -23,6 +23,18 @@ describe('SessionsAPI', () => {
23
23
  expect(url).toContain('/sessions/sess_1');
24
24
  expect(init.method).toBe('GET');
25
25
  });
26
+ it('should GET /sessions and return session list', async () => {
27
+ const sessions = [
28
+ { id: 'sess_1', status: 'active' },
29
+ { id: 'sess_2', status: 'active' },
30
+ ];
31
+ mockJsonResponse(sessions);
32
+ const result = await api().list();
33
+ expect(result).toEqual(sessions);
34
+ const [url, init] = mockFetch.mock.calls[0];
35
+ expect(url).toContain('/sessions');
36
+ expect(init.method).toBe('GET');
37
+ });
26
38
  it('should return an empty array when list() response is null', async () => {
27
39
  mockJsonResponse(null);
28
40
  const result = await api().list();
@@ -188,6 +188,23 @@ describe('TasksAPI.run (streaming mode)', () => {
188
188
  ]);
189
189
  await expect(api().run({ app: 'test-app', input: {} }, {}, { wait: true })).rejects.toThrow('task cancelled');
190
190
  });
191
+ it('should reject when the NDJSON stream connection fails', async () => {
192
+ mockFetch.mockImplementation((url) => {
193
+ if (url.includes('/apps/run')) {
194
+ return Promise.resolve({
195
+ ok: true,
196
+ status: 200,
197
+ text: () => Promise.resolve(JSON.stringify(makeTask())),
198
+ });
199
+ }
200
+ return Promise.resolve({
201
+ ok: false,
202
+ status: 503,
203
+ text: () => Promise.resolve('service unavailable'),
204
+ });
205
+ });
206
+ await expect(api().run({ app: 'test-app', input: {} }, {}, { wait: true })).rejects.toThrow('HTTP 503');
207
+ });
191
208
  it('should handle partial updates via onPartialUpdate', async () => {
192
209
  setupStreamMocks([
193
210
  `${JSON.stringify({
@@ -200,6 +217,47 @@ describe('TasksAPI.run (streaming mode)', () => {
200
217
  expect(result.status).toBe(TaskStatusCompleted);
201
218
  expect(onPartialUpdate).toHaveBeenCalledWith(expect.objectContaining({ id: 'task-1', status: TaskStatusCompleted }), ['status']);
202
219
  });
220
+ it('should preserve session_id across partial stream updates that omit it', async () => {
221
+ setupStreamMocks([
222
+ `${JSON.stringify({
223
+ data: { status: TaskStatusRunning, id: 'task-1' },
224
+ fields: ['status'],
225
+ })}\n`,
226
+ `${JSON.stringify({
227
+ data: { status: TaskStatusCompleted, id: 'task-1', output: { ok: true } },
228
+ fields: ['status', 'output'],
229
+ })}\n`,
230
+ ], makeTask({ session_id: 'sess-persist' }));
231
+ const onPartialUpdate = jest.fn();
232
+ const result = await api().run({ app: 'test-app', input: {} }, {}, { wait: true, onPartialUpdate });
233
+ expect(result.session_id).toBe('sess-persist');
234
+ expect(onPartialUpdate).toHaveBeenCalledWith(expect.objectContaining({ session_id: 'sess-persist', status: TaskStatusRunning }), ['status']);
235
+ expect(onPartialUpdate).toHaveBeenLastCalledWith(expect.objectContaining({
236
+ session_id: 'sess-persist',
237
+ status: TaskStatusCompleted,
238
+ output: { ok: true },
239
+ }), ['status', 'output']);
240
+ });
241
+ });
242
+ describe('TasksAPI.run (HTTP contract)', () => {
243
+ beforeEach(() => {
244
+ jest.clearAllMocks();
245
+ });
246
+ const api = () => new TasksAPI(new HttpClient({ apiKey: 'test-key' }));
247
+ it('should POST /apps/run with merged params and processedInput', async () => {
248
+ const task = makeTask();
249
+ mockJsonResponse(task);
250
+ const result = await api().run({ app: 'image-gen', version_id: 'ver-2', input: {} }, { prompt: 'sunset', width: 512 }, { wait: false });
251
+ expect(result).toEqual(task);
252
+ const [url, init] = mockFetch.mock.calls[0];
253
+ expect(url).toContain('/apps/run');
254
+ expect(init.method).toBe('POST');
255
+ expect(JSON.parse(init.body)).toEqual({
256
+ app: 'image-gen',
257
+ version_id: 'ver-2',
258
+ input: { prompt: 'sunset', width: 512 },
259
+ });
260
+ });
203
261
  });
204
262
  describe('TasksAPI.create', () => {
205
263
  beforeEach(() => {
@@ -76,4 +76,64 @@ describe('TeamsAPI', () => {
76
76
  expect(url).toContain('/teams/team-1/invites');
77
77
  expect(JSON.parse(init.body)).toEqual(payload);
78
78
  });
79
+ it('should GET /teams/{id} for get()', async () => {
80
+ const team = { id: 'team-1', name: 'Acme' };
81
+ mockJsonResponse(team);
82
+ const result = await api().get('team-1');
83
+ expect(result).toEqual(team);
84
+ const [url, init] = mockFetch.mock.calls[0];
85
+ expect(url).toContain('/teams/team-1');
86
+ expect(init.method).toBe('GET');
87
+ });
88
+ it('should POST /teams/{id} for update()', async () => {
89
+ const team = { id: 'team-1', name: 'Acme Updated' };
90
+ mockJsonResponse(team);
91
+ const result = await api().update('team-1', { name: 'Acme Updated' });
92
+ expect(result).toEqual(team);
93
+ const [url, init] = mockFetch.mock.calls[0];
94
+ expect(url).toContain('/teams/team-1');
95
+ expect(JSON.parse(init.body)).toEqual({ name: 'Acme Updated' });
96
+ });
97
+ it('should DELETE /teams/{id} for delete()', async () => {
98
+ mockJsonResponse(null);
99
+ await api().delete('team-1');
100
+ const [url, init] = mockFetch.mock.calls[0];
101
+ expect(url).toContain('/teams/team-1');
102
+ expect(init.method).toBe('DELETE');
103
+ });
104
+ it('should GET /teams/{id}/members for getMembers()', async () => {
105
+ const members = [{ user_id: 'user-1', role: 'owner' }];
106
+ mockJsonResponse(members);
107
+ const result = await api().getMembers('team-1');
108
+ expect(result).toEqual(members);
109
+ const [url, init] = mockFetch.mock.calls[0];
110
+ expect(url).toContain('/teams/team-1/members');
111
+ expect(init.method).toBe('GET');
112
+ });
113
+ it('should POST /teams/{id}/members for addMember()', async () => {
114
+ const payload = { user_id: 'user-4', role: 'member' };
115
+ const member = { ...payload };
116
+ mockJsonResponse(member);
117
+ const result = await api().addMember('team-1', payload);
118
+ expect(result).toEqual(member);
119
+ const [url, init] = mockFetch.mock.calls[0];
120
+ expect(url).toContain('/teams/team-1/members');
121
+ expect(JSON.parse(init.body)).toEqual(payload);
122
+ });
123
+ it('should GET /teams/{id}/invites for listInvites()', async () => {
124
+ const invites = [{ id: 'inv-1', email: 'dev@example.com' }];
125
+ mockJsonResponse(invites);
126
+ const result = await api().listInvites('team-1');
127
+ expect(result).toEqual(invites);
128
+ const [url, init] = mockFetch.mock.calls[0];
129
+ expect(url).toContain('/teams/team-1/invites');
130
+ expect(init.method).toBe('GET');
131
+ });
132
+ it('should DELETE /teams/{id}/invites/{inviteId} for revokeInvite()', async () => {
133
+ mockJsonResponse(null);
134
+ await api().revokeInvite('team-1', 'inv-9');
135
+ const [url, init] = mockFetch.mock.calls[0];
136
+ expect(url).toContain('/teams/team-1/invites/inv-9');
137
+ expect(init.method).toBe('DELETE');
138
+ });
79
139
  });
@@ -1,5 +1,7 @@
1
- import { Inference, inference } from './index';
1
+ import { Inference, inference, createClient } from './index';
2
2
  import { RequirementsNotMetException } from './http/errors';
3
+ import { HttpClient } from './http/client';
4
+ import { ChatStatusBusy, ChatStatusIdle } from './types';
3
5
  // Mock fetch globally
4
6
  const mockFetch = jest.fn();
5
7
  global.fetch = mockFetch;
@@ -138,6 +140,45 @@ describe('Inference', () => {
138
140
  expect(exception.errors[0].action?.scopes).toEqual(['calendar.readonly', 'calendar.events']);
139
141
  }
140
142
  });
143
+ it('should process Blob inputs via processInput before calling /apps/run', async () => {
144
+ const fileRecord = {
145
+ id: 'file-blob',
146
+ uri: 'inf://files/blob',
147
+ upload_url: 'https://upload.example.com/put',
148
+ content_type: 'image/png',
149
+ };
150
+ const mockTask = {
151
+ id: 'task-456',
152
+ status: 9,
153
+ created_at: new Date().toISOString(),
154
+ updated_at: new Date().toISOString(),
155
+ input: {},
156
+ output: { result: 'done' },
157
+ };
158
+ mockFetch
159
+ .mockResolvedValueOnce({
160
+ ok: true,
161
+ status: 200,
162
+ text: () => Promise.resolve(JSON.stringify([fileRecord])),
163
+ json: () => Promise.resolve([fileRecord]),
164
+ })
165
+ .mockResolvedValueOnce({ ok: true, status: 200 })
166
+ .mockResolvedValueOnce({
167
+ ok: true,
168
+ status: 200,
169
+ text: () => Promise.resolve(JSON.stringify(mockTask)),
170
+ json: () => Promise.resolve(mockTask),
171
+ });
172
+ const client = new Inference({ apiKey: 'test-api-key' });
173
+ const blob = new Blob(['png-bytes'], { type: 'image/png' });
174
+ const result = await client.run({ app: 'test-app', input: { image: blob } }, { wait: false });
175
+ expect(result.id).toBe('task-456');
176
+ expect(mockFetch).toHaveBeenCalledTimes(3);
177
+ const runCall = mockFetch.mock.calls.find((call) => String(call[0]).includes('/apps/run'));
178
+ expect(runCall).toBeDefined();
179
+ const runBody = JSON.parse(runCall[1].body);
180
+ expect(runBody.input.image).toBe('inf://files/blob');
181
+ });
141
182
  });
142
183
  describe('cancel', () => {
143
184
  it('should make a POST request to cancel endpoint', async () => {
@@ -153,6 +194,76 @@ describe('Inference', () => {
153
194
  expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/tasks/task-123/cancel'), expect.objectContaining({ method: 'POST' }));
154
195
  });
155
196
  });
197
+ describe('legacy task helpers', () => {
198
+ it('should delegate getTask() to tasks.get', async () => {
199
+ const mockTask = { id: 'task-legacy', status: 9 };
200
+ mockFetch.mockResolvedValueOnce({
201
+ ok: true,
202
+ status: 200,
203
+ text: () => Promise.resolve(JSON.stringify(mockTask)),
204
+ json: () => Promise.resolve(mockTask),
205
+ });
206
+ const client = new Inference({ apiKey: 'test-api-key' });
207
+ const result = await client.getTask('task-legacy');
208
+ expect(result.id).toBe('task-legacy');
209
+ expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/tasks/task-legacy'), expect.objectContaining({ method: 'GET' }));
210
+ });
211
+ it('should delegate streamTask() to tasks.stream', async () => {
212
+ const client = new Inference({ apiKey: 'test-api-key' });
213
+ const createEventSource = jest
214
+ .spyOn(HttpClient.prototype, 'createEventSource')
215
+ .mockResolvedValue(null);
216
+ await client.streamTask('task-stream');
217
+ expect(createEventSource).toHaveBeenCalledWith('/tasks/task-stream/stream');
218
+ createEventSource.mockRestore();
219
+ });
220
+ });
221
+ describe('agent()', () => {
222
+ it('should return an Agent whose sendMessage hits POST /agents/run', async () => {
223
+ mockFetch
224
+ .mockResolvedValueOnce({
225
+ ok: true,
226
+ status: 200,
227
+ text: () => Promise.resolve(JSON.stringify({
228
+ user_message: { id: 'user-1', chat_id: 'chat-1', role: 'user', content: 'hi' },
229
+ assistant_message: { id: 'asst-1', chat_id: 'chat-1', role: 'assistant', content: 'hello' },
230
+ })),
231
+ })
232
+ .mockResolvedValueOnce({
233
+ ok: true,
234
+ status: 200,
235
+ text: () => Promise.resolve(JSON.stringify({ status: ChatStatusBusy })),
236
+ })
237
+ .mockResolvedValueOnce({
238
+ ok: true,
239
+ status: 200,
240
+ text: () => Promise.resolve(JSON.stringify({ id: 'chat-1', status: ChatStatusBusy, chat_messages: [] })),
241
+ })
242
+ .mockResolvedValueOnce({
243
+ ok: true,
244
+ status: 200,
245
+ text: () => Promise.resolve(JSON.stringify({ status: ChatStatusIdle })),
246
+ })
247
+ .mockResolvedValueOnce({
248
+ ok: true,
249
+ status: 200,
250
+ text: () => Promise.resolve(JSON.stringify({ id: 'chat-1', status: ChatStatusIdle, chat_messages: [] })),
251
+ });
252
+ const client = new Inference({ apiKey: 'test-api-key', stream: false, pollIntervalMs: 20 });
253
+ const agentInstance = client.agent('my-agent');
254
+ await agentInstance.sendMessage('hi', { stream: false });
255
+ const runCall = mockFetch.mock.calls.find(([url]) => String(url).includes('/agents/run'));
256
+ expect(runCall).toBeDefined();
257
+ const body = JSON.parse(runCall[1].body);
258
+ expect(body.agent).toBe('my-agent');
259
+ });
260
+ });
261
+ describe('createClient', () => {
262
+ it('should create an Inference instance with extended HttpClient config', () => {
263
+ const client = createClient({ apiKey: 'extended-key', baseUrl: 'https://api.example.com' });
264
+ expect(client).toBeInstanceOf(Inference);
265
+ });
266
+ });
156
267
  describe('lowercase factory', () => {
157
268
  it('should export lowercase inference factory', () => {
158
269
  expect(typeof inference).toBe('function');
@@ -82,4 +82,65 @@ describe('PollManager', () => {
82
82
  await Promise.resolve();
83
83
  expect(onData.mock.calls.length).toBe(callsBefore);
84
84
  });
85
+ it('should not run overlapping polls when pollFunction is still in flight', async () => {
86
+ let resolvePoll = () => { };
87
+ const pollFunction = jest.fn().mockImplementation(() => new Promise((resolve) => {
88
+ resolvePoll = resolve;
89
+ }));
90
+ const manager = new PollManager({
91
+ pollFunction,
92
+ intervalMs: 100,
93
+ });
94
+ manager.start();
95
+ await Promise.resolve();
96
+ expect(pollFunction).toHaveBeenCalledTimes(1);
97
+ jest.advanceTimersByTime(100);
98
+ await Promise.resolve();
99
+ expect(pollFunction).toHaveBeenCalledTimes(1);
100
+ resolvePoll({ status: 'ok' });
101
+ await Promise.resolve();
102
+ jest.advanceTimersByTime(100);
103
+ await Promise.resolve();
104
+ expect(pollFunction).toHaveBeenCalledTimes(2);
105
+ manager.stop();
106
+ });
107
+ it('should ignore start() when polling is already active', async () => {
108
+ const onStart = jest.fn();
109
+ const pollFunction = jest.fn().mockResolvedValue({});
110
+ const manager = new PollManager({ pollFunction, onStart });
111
+ manager.start();
112
+ manager.start();
113
+ expect(onStart).toHaveBeenCalledTimes(1);
114
+ manager.stop();
115
+ });
116
+ it('should reset consecutive error count after a successful poll', async () => {
117
+ const onError = jest.fn();
118
+ const onStop = jest.fn();
119
+ const pollFunction = jest
120
+ .fn()
121
+ .mockRejectedValueOnce(new Error('fail 1'))
122
+ .mockResolvedValueOnce({ status: 'ok' })
123
+ .mockRejectedValueOnce(new Error('fail 2'))
124
+ .mockRejectedValueOnce(new Error('fail 3'));
125
+ const manager = new PollManager({
126
+ pollFunction,
127
+ intervalMs: 100,
128
+ maxRetries: 2,
129
+ onError,
130
+ onStop,
131
+ });
132
+ manager.start();
133
+ await Promise.resolve();
134
+ jest.advanceTimersByTime(100);
135
+ await Promise.resolve();
136
+ expect(onStop).not.toHaveBeenCalled();
137
+ expect(onError).toHaveBeenCalledTimes(1);
138
+ jest.advanceTimersByTime(100);
139
+ await Promise.resolve();
140
+ jest.advanceTimersByTime(100);
141
+ await Promise.resolve();
142
+ expect(onStop).toHaveBeenCalled();
143
+ expect(onError).toHaveBeenCalledTimes(3);
144
+ manager.stop();
145
+ });
85
146
  });
@@ -152,6 +152,18 @@ describe('streamable', () => {
152
152
  }
153
153
  }).rejects.toThrow('HTTP 401: Unauthorized');
154
154
  });
155
+ it('should throw when the response has no body', async () => {
156
+ global.fetch = jest.fn().mockResolvedValue({
157
+ ok: true,
158
+ status: 200,
159
+ body: null,
160
+ });
161
+ await expect(async () => {
162
+ for await (const _ of streamable('http://test.com/stream')) {
163
+ // consume
164
+ }
165
+ }).rejects.toThrow('No response body');
166
+ });
155
167
  // Edge cases for chunking/buffering
156
168
  describe('chunking edge cases', () => {
157
169
  it('should handle message split across many chunks', async () => {
@@ -379,6 +391,68 @@ describe('StreamableManager', () => {
379
391
  await manager.start();
380
392
  expect(events).toEqual(['start', 'data', 'end']);
381
393
  });
394
+ it('should dispatch typed events to addEventListener subscribers', async () => {
395
+ global.fetch = mockFetch([
396
+ '{"event":"chats","data":{"id":"chat-1","status":"busy"}}\n',
397
+ '{"event":"chat_messages","data":{"id":"msg-1","content":"hi"}}\n',
398
+ ]);
399
+ const onChat = jest.fn();
400
+ const onMessage = jest.fn();
401
+ const manager = new StreamableManager({
402
+ url: 'http://test.com/stream',
403
+ onData: jest.fn(),
404
+ });
405
+ manager.addEventListener('chats', onChat);
406
+ manager.addEventListener('chat_messages', onMessage);
407
+ await manager.start();
408
+ expect(onChat).toHaveBeenCalledWith({ id: 'chat-1', status: 'busy' });
409
+ expect(onMessage).toHaveBeenCalledWith({ id: 'msg-1', content: 'hi' });
410
+ });
411
+ it('should stop dispatching to an unsubscribed listener', async () => {
412
+ global.fetch = mockFetch([
413
+ '{"event":"chats","data":{"id":"chat-1"}}\n',
414
+ ]);
415
+ const removedListener = jest.fn();
416
+ const activeListener = jest.fn();
417
+ const manager = new StreamableManager({
418
+ url: 'http://test.com/stream',
419
+ });
420
+ const unsubscribe = manager.addEventListener('chats', removedListener);
421
+ manager.addEventListener('chats', activeListener);
422
+ unsubscribe();
423
+ await manager.start();
424
+ expect(removedListener).not.toHaveBeenCalled();
425
+ expect(activeListener).toHaveBeenCalledWith({ id: 'chat-1' });
426
+ });
427
+ it('should ignore start() when a stream is already running', async () => {
428
+ let readCount = 0;
429
+ const mockReader = {
430
+ read: jest.fn().mockImplementation(async () => {
431
+ readCount++;
432
+ if (readCount > 3) {
433
+ return { done: true, value: undefined };
434
+ }
435
+ await new Promise((resolve) => setTimeout(resolve, 20));
436
+ return { done: false, value: new TextEncoder().encode('{"id":1}\n') };
437
+ }),
438
+ releaseLock: jest.fn(),
439
+ };
440
+ global.fetch = jest.fn().mockResolvedValue({
441
+ ok: true,
442
+ body: { getReader: () => mockReader },
443
+ });
444
+ const onStart = jest.fn();
445
+ const manager = new StreamableManager({
446
+ url: 'http://test.com/stream',
447
+ onStart,
448
+ onData: () => { },
449
+ });
450
+ const firstStart = manager.start();
451
+ await manager.start();
452
+ await firstStart;
453
+ expect(onStart).toHaveBeenCalledTimes(1);
454
+ expect(global.fetch).toHaveBeenCalledTimes(1);
455
+ });
382
456
  it('should handle stop()', async () => {
383
457
  let readCount = 0;
384
458
  const mockReader = {
@@ -407,4 +481,30 @@ describe('StreamableManager', () => {
407
481
  // Should have stopped before reading all 10
408
482
  expect(readCount).toBeLessThan(10);
409
483
  });
484
+ it('should call onError when the stream request fails', async () => {
485
+ global.fetch = jest.fn().mockResolvedValue({
486
+ ok: false,
487
+ status: 503,
488
+ text: async () => 'Service unavailable',
489
+ });
490
+ const onError = jest.fn();
491
+ const manager = new StreamableManager({
492
+ url: 'http://test.com/stream',
493
+ onError,
494
+ });
495
+ await manager.start();
496
+ expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'HTTP 503: Service unavailable' }));
497
+ });
498
+ it('should route partial updates to onData when onPartialData is not provided', async () => {
499
+ global.fetch = mockFetch([
500
+ '{"data":{"id":1,"status":"busy"},"fields":["status"]}\n',
501
+ ]);
502
+ const onData = jest.fn();
503
+ const manager = new StreamableManager({
504
+ url: 'http://test.com/stream',
505
+ onData,
506
+ });
507
+ await manager.start();
508
+ expect(onData).toHaveBeenCalledWith({ id: 1, status: 'busy' });
509
+ });
410
510
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,90 @@
1
+ import { INF_TARGET_HEADER } from './index';
2
+ import { createHandler } from './hono';
3
+ function createMockContext(overrides = {}) {
4
+ const headers = new Headers(overrides.headers ?? {});
5
+ const url = overrides.url ?? 'http://localhost/api/inference/proxy';
6
+ return {
7
+ req: {
8
+ method: overrides.method ?? 'POST',
9
+ url,
10
+ raw: { headers },
11
+ header: (name) => headers.get(name) ?? undefined,
12
+ text: () => Promise.resolve(overrides.body ?? '{"ok":true}'),
13
+ },
14
+ };
15
+ }
16
+ describe('hono createHandler', () => {
17
+ const originalFetch = global.fetch;
18
+ beforeEach(() => {
19
+ jest.clearAllMocks();
20
+ process.env.INFERENCE_API_KEY = 'hono-test-key';
21
+ });
22
+ afterEach(() => {
23
+ global.fetch = originalFetch;
24
+ });
25
+ it('should return 400 when target URL is missing', async () => {
26
+ const handler = createHandler();
27
+ const response = await handler(createMockContext());
28
+ expect(response.status).toBe(400);
29
+ expect(await response.json()).toEqual({
30
+ error: `Missing ${INF_TARGET_HEADER} header or __inf_target query param`,
31
+ });
32
+ });
33
+ it('should proxy JSON responses', async () => {
34
+ global.fetch = jest.fn().mockResolvedValue(new Response(JSON.stringify({ ok: true }), {
35
+ status: 200,
36
+ headers: { 'content-type': 'application/json' },
37
+ }));
38
+ const handler = createHandler();
39
+ const target = 'https://api.inference.sh/v1/run';
40
+ const response = await handler(createMockContext({
41
+ headers: { [INF_TARGET_HEADER]: target },
42
+ }));
43
+ expect(response.status).toBe(200);
44
+ expect(await response.json()).toEqual({ ok: true });
45
+ expect(global.fetch).toHaveBeenCalledWith(target, expect.objectContaining({
46
+ headers: expect.objectContaining({
47
+ authorization: 'Bearer hono-test-key',
48
+ }),
49
+ }));
50
+ });
51
+ it('should passthrough SSE streaming responses', async () => {
52
+ const encoder = new TextEncoder();
53
+ const stream = new ReadableStream({
54
+ start(controller) {
55
+ controller.enqueue(encoder.encode('data: {"x":1}\n\n'));
56
+ controller.close();
57
+ },
58
+ });
59
+ global.fetch = jest.fn().mockResolvedValue(new Response(stream, {
60
+ status: 200,
61
+ headers: { 'content-type': 'text/event-stream' },
62
+ }));
63
+ const handler = createHandler();
64
+ const target = 'https://api.inference.sh/v1/stream';
65
+ const response = await handler(createMockContext({
66
+ headers: { [INF_TARGET_HEADER]: target },
67
+ }));
68
+ expect(response.status).toBe(200);
69
+ expect(response.headers.get('content-type')).toContain('text/event-stream');
70
+ expect(await response.text()).toContain('data:');
71
+ });
72
+ it('should resolve API key via resolveApiKey option', async () => {
73
+ global.fetch = jest.fn().mockResolvedValue(new Response(JSON.stringify({ ok: true }), {
74
+ status: 200,
75
+ headers: { 'content-type': 'application/json' },
76
+ }));
77
+ const handler = createHandler({
78
+ resolveApiKey: async () => 'custom-hono-key',
79
+ });
80
+ const target = 'https://api.inference.sh/v1/run';
81
+ await handler(createMockContext({
82
+ headers: { [INF_TARGET_HEADER]: target },
83
+ }));
84
+ expect(global.fetch).toHaveBeenCalledWith(target, expect.objectContaining({
85
+ headers: expect.objectContaining({
86
+ authorization: 'Bearer custom-hono-key',
87
+ }),
88
+ }));
89
+ });
90
+ });
@@ -106,6 +106,23 @@ describe('processProxyRequest', () => {
106
106
  }),
107
107
  }));
108
108
  });
109
+ it('should prefer client Authorization header over env API key', async () => {
110
+ const target = 'https://api.inference.sh/v1/tasks/1';
111
+ await processProxyRequest(createTestAdapter({
112
+ header: (name) => {
113
+ if (name === INF_TARGET_HEADER)
114
+ return target;
115
+ if (name === 'authorization')
116
+ return 'Bearer client-session-token';
117
+ return undefined;
118
+ },
119
+ }));
120
+ expect(global.fetch).toHaveBeenCalledWith(target, expect.objectContaining({
121
+ headers: expect.objectContaining({
122
+ authorization: 'Bearer client-session-token',
123
+ }),
124
+ }));
125
+ });
109
126
  it('should use query param fallback when header is missing (SSE clients)', async () => {
110
127
  const target = 'https://api.inference.sh/v1/stream';
111
128
  const encoded = encodeURIComponent(target);
@@ -0,0 +1 @@
1
+ export {};