@inferencesh/sdk 0.6.10 → 0.6.13

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.
Files changed (75) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/actions.js +3 -3
  3. package/dist/agent/actions.test.js +231 -1
  4. package/dist/agent/api.test.js +18 -1
  5. package/dist/api/agents.d.ts +10 -0
  6. package/dist/api/agents.js +17 -3
  7. package/dist/api/agents.test.js +500 -0
  8. package/dist/api/api-keys.d.ts +24 -0
  9. package/dist/api/api-keys.js +26 -0
  10. package/dist/api/api-keys.test.d.ts +1 -0
  11. package/dist/api/api-keys.test.js +44 -0
  12. package/dist/api/apps.js +1 -1
  13. package/dist/api/apps.test.js +71 -3
  14. package/dist/api/chats.d.ts +14 -0
  15. package/dist/api/chats.js +18 -0
  16. package/dist/api/chats.test.js +52 -0
  17. package/dist/api/engines.d.ts +12 -0
  18. package/dist/api/engines.js +18 -0
  19. package/dist/api/engines.test.js +78 -0
  20. package/dist/api/files.test.js +183 -0
  21. package/dist/api/flow-runs.test.js +42 -0
  22. package/dist/api/flows.d.ts +4 -0
  23. package/dist/api/flows.js +6 -0
  24. package/dist/api/flows.test.js +75 -0
  25. package/dist/api/integrations.d.ts +41 -0
  26. package/dist/api/integrations.js +56 -0
  27. package/dist/api/integrations.test.d.ts +1 -0
  28. package/dist/api/integrations.test.js +109 -0
  29. package/dist/api/knowledge.d.ts +112 -0
  30. package/dist/api/knowledge.js +163 -0
  31. package/dist/api/knowledge.test.d.ts +1 -0
  32. package/dist/api/knowledge.test.js +238 -0
  33. package/dist/api/mcp-servers.d.ts +45 -0
  34. package/dist/api/mcp-servers.js +62 -0
  35. package/dist/api/mcp-servers.test.d.ts +1 -0
  36. package/dist/api/mcp-servers.test.js +98 -0
  37. package/dist/api/projects.d.ts +29 -0
  38. package/dist/api/projects.js +38 -0
  39. package/dist/api/projects.test.d.ts +1 -0
  40. package/dist/api/projects.test.js +61 -0
  41. package/dist/api/search.d.ts +21 -0
  42. package/dist/api/search.js +20 -0
  43. package/dist/api/search.test.d.ts +1 -0
  44. package/dist/api/search.test.js +39 -0
  45. package/dist/api/secrets.d.ts +29 -0
  46. package/dist/api/secrets.js +38 -0
  47. package/dist/api/secrets.test.d.ts +1 -0
  48. package/dist/api/secrets.test.js +61 -0
  49. package/dist/api/sessions.test.js +12 -0
  50. package/dist/api/tasks.d.ts +13 -1
  51. package/dist/api/tasks.js +18 -0
  52. package/dist/api/tasks.test.js +171 -0
  53. package/dist/api/teams.d.ts +71 -0
  54. package/dist/api/teams.js +92 -0
  55. package/dist/api/teams.test.d.ts +1 -0
  56. package/dist/api/teams.test.js +139 -0
  57. package/dist/client.test.js +112 -1
  58. package/dist/http/client.d.ts +5 -0
  59. package/dist/http/client.js +42 -23
  60. package/dist/http/client.test.js +245 -0
  61. package/dist/http/errors.test.js +13 -0
  62. package/dist/index.d.ts +25 -0
  63. package/dist/index.js +25 -0
  64. package/dist/proxy/hono.test.d.ts +1 -0
  65. package/dist/proxy/hono.test.js +90 -0
  66. package/dist/proxy/nextjs.test.d.ts +1 -0
  67. package/dist/proxy/nextjs.test.js +125 -0
  68. package/dist/proxy/remix.test.d.ts +1 -0
  69. package/dist/proxy/remix.test.js +66 -0
  70. package/dist/proxy/svelte.test.d.ts +1 -0
  71. package/dist/proxy/svelte.test.js +69 -0
  72. package/dist/tool-builder.test.js +11 -1
  73. package/dist/types.d.ts +110 -5
  74. package/dist/types.js +58 -2
  75. package/package.json +6 -6
@@ -0,0 +1,139 @@
1
+ import { HttpClient } from '../http/client';
2
+ import { TeamsAPI } from './teams';
3
+ const mockFetch = jest.fn();
4
+ global.fetch = mockFetch;
5
+ function mockJsonResponse(body) {
6
+ mockFetch.mockResolvedValueOnce({
7
+ ok: true,
8
+ status: 200,
9
+ text: () => Promise.resolve(JSON.stringify(body)),
10
+ });
11
+ }
12
+ describe('TeamsAPI', () => {
13
+ beforeEach(() => {
14
+ jest.clearAllMocks();
15
+ });
16
+ const api = () => new TeamsAPI(new HttpClient({ apiKey: 'test-key' }));
17
+ it('should GET /me for me()', async () => {
18
+ const me = { user: { id: 'user-1' }, team: { id: 'team-1' } };
19
+ mockJsonResponse(me);
20
+ const result = await api().me();
21
+ expect(result).toEqual(me);
22
+ const [url, init] = mockFetch.mock.calls[0];
23
+ expect(url).toContain('/me');
24
+ expect(init.method).toBe('GET');
25
+ });
26
+ it('should GET /teams for list()', async () => {
27
+ const teams = [{ id: 'team-1', name: 'Acme' }];
28
+ mockJsonResponse(teams);
29
+ const result = await api().list();
30
+ expect(result).toEqual(teams);
31
+ const [url, init] = mockFetch.mock.calls[0];
32
+ expect(url).toContain('/teams');
33
+ expect(init.method).toBe('GET');
34
+ });
35
+ it('should POST /teams for create()', async () => {
36
+ const payload = { name: 'New Team', username: 'new-team' };
37
+ const team = { id: 'team-new', ...payload };
38
+ mockJsonResponse(team);
39
+ const result = await api().create(payload);
40
+ expect(result).toEqual(team);
41
+ const [url, init] = mockFetch.mock.calls[0];
42
+ expect(url).toContain('/teams');
43
+ expect(init.method).toBe('POST');
44
+ expect(JSON.parse(init.body)).toEqual(payload);
45
+ });
46
+ it('should GET /teams/check-username with username param for checkUsername()', async () => {
47
+ mockJsonResponse({ available: true });
48
+ const result = await api().checkUsername('acme-corp');
49
+ expect(result).toEqual({ available: true });
50
+ const [url] = mockFetch.mock.calls[0];
51
+ expect(url).toContain('/teams/check-username');
52
+ expect(url).toContain('username=acme-corp');
53
+ });
54
+ it('should POST role for updateMemberRole()', async () => {
55
+ const member = { user_id: 'user-2', role: 'admin' };
56
+ mockJsonResponse(member);
57
+ await api().updateMemberRole('team-1', 'user-2', 'admin');
58
+ const [url, init] = mockFetch.mock.calls[0];
59
+ expect(url).toContain('/teams/team-1/members/user-2/role');
60
+ expect(JSON.parse(init.body)).toEqual({ role: 'admin' });
61
+ });
62
+ it('should DELETE /teams/{id}/members/{userId} for removeMember()', async () => {
63
+ mockJsonResponse(null);
64
+ await api().removeMember('team-1', 'user-3');
65
+ const [url, init] = mockFetch.mock.calls[0];
66
+ expect(url).toContain('/teams/team-1/members/user-3');
67
+ expect(init.method).toBe('DELETE');
68
+ });
69
+ it('should POST /teams/{id}/invites for createInvite()', async () => {
70
+ const payload = { email: 'dev@example.com', role: 'member' };
71
+ const invite = { id: 'inv-1', ...payload };
72
+ mockJsonResponse(invite);
73
+ const result = await api().createInvite('team-1', payload);
74
+ expect(result).toEqual(invite);
75
+ const [url, init] = mockFetch.mock.calls[0];
76
+ expect(url).toContain('/teams/team-1/invites');
77
+ expect(JSON.parse(init.body)).toEqual(payload);
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
+ });
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');
@@ -84,6 +84,11 @@ export declare class HttpClient {
84
84
  url: string;
85
85
  headers: Record<string, string>;
86
86
  };
87
+ /**
88
+ * Pull a human-readable detail string from a parsed problem+json / API error
89
+ * body (RFC 9457 `detail`/`title`, or a plain `message`).
90
+ */
91
+ private extractErrorDetail;
87
92
  /**
88
93
  * Create an EventSource for SSE streaming
89
94
  * @deprecated Use getStreamableConfig() with StreamableManager instead
@@ -128,7 +128,7 @@ export class HttpClient {
128
128
  }
129
129
  let errorDetail;
130
130
  if (data && typeof data === 'object') {
131
- errorDetail = data.detail || data.title || data.message || JSON.stringify(data);
131
+ errorDetail = this.extractErrorDetail(data) ?? JSON.stringify(data);
132
132
  }
133
133
  else if (responseText) {
134
134
  errorDetail = responseText.slice(0, 500);
@@ -164,6 +164,16 @@ export class HttpClient {
164
164
  }
165
165
  return { url, headers };
166
166
  }
167
+ /**
168
+ * Pull a human-readable detail string from a parsed problem+json / API error
169
+ * body (RFC 9457 `detail`/`title`, or a plain `message`).
170
+ */
171
+ extractErrorDetail(data) {
172
+ if (!data || typeof data !== 'object')
173
+ return undefined;
174
+ const d = data;
175
+ return (d.detail || d.title || d.message);
176
+ }
167
177
  /**
168
178
  * Create an EventSource for SSE streaming
169
179
  * @deprecated Use getStreamableConfig() with StreamableManager instead
@@ -183,30 +193,39 @@ export class HttpClient {
183
193
  fetchUrl = targetUrl.toString();
184
194
  }
185
195
  const resolvedHeaders = this.resolveHeaders();
186
- return Promise.resolve(new EventSource(fetchUrl, {
187
- fetch: (input, init) => {
188
- const headers = {
189
- ...init?.headers,
190
- ...resolvedHeaders,
191
- };
192
- if (isProxyMode) {
193
- // Proxy mode: also send target URL as header (for non-browser clients)
194
- headers['x-inf-target-url'] = targetUrl.toString();
196
+ // The EventSource error event omits the response body, so a failed initial
197
+ // connection (e.g. 403 otp_required) is handled here in the fetch wrapper:
198
+ // route it through onError like request() does, and hand the retried
199
+ // response (e.g. after OTP verification) back so the stream connects.
200
+ const doFetch = async (input, init) => {
201
+ const headers = {
202
+ ...init?.headers,
203
+ ...resolvedHeaders,
204
+ };
205
+ if (isProxyMode) {
206
+ headers['x-inf-target-url'] = targetUrl.toString();
207
+ }
208
+ else {
209
+ const token = this.getAuthToken();
210
+ if (token) {
211
+ headers['Authorization'] = `Bearer ${token}`;
195
212
  }
196
- else {
197
- // Direct mode: include authorization header
198
- const token = this.getAuthToken();
199
- if (token) {
200
- headers['Authorization'] = `Bearer ${token}`;
201
- }
213
+ }
214
+ const response = await fetch(input, { ...init, headers, credentials: this.credentials });
215
+ if (!response.ok && this.onError) {
216
+ const body = await response.clone().text().catch(() => '');
217
+ let data;
218
+ try {
219
+ data = JSON.parse(body);
202
220
  }
203
- return fetch(input, {
204
- ...init,
205
- headers,
206
- credentials: this.credentials,
207
- });
208
- },
209
- }));
221
+ catch { /* non-JSON body */ }
222
+ const error = new InferenceError(response.status, this.extractErrorDetail(data) ?? 'Request failed', body);
223
+ const handled = await this.onError(error, () => doFetch(input, init));
224
+ return handled ?? response;
225
+ }
226
+ return response;
227
+ };
228
+ return Promise.resolve(new EventSource(fetchUrl, { fetch: doFetch }));
210
229
  }
211
230
  }
212
231
  /**
@@ -114,6 +114,32 @@ describe('HttpClient', () => {
114
114
  expect(calledUrl).toContain('ids=');
115
115
  expect(decodeURIComponent(calledUrl)).toContain('["a","b"]');
116
116
  });
117
+ it('should serialize object query params as JSON', async () => {
118
+ mockJsonResponse([]);
119
+ await client().request('get', '/tasks', {
120
+ params: { filter: { status: 'active', team_id: 'team-1' } },
121
+ });
122
+ const calledUrl = mockFetch.mock.calls[0][0];
123
+ expect(calledUrl).toContain('filter=');
124
+ expect(decodeURIComponent(calledUrl)).toContain('"status":"active"');
125
+ expect(decodeURIComponent(calledUrl)).toContain('"team_id":"team-1"');
126
+ });
127
+ it('should omit Authorization when getToken returns null', async () => {
128
+ mockJsonResponse({ id: 'task-1' });
129
+ const tokenClient = new HttpClient({ getToken: () => null });
130
+ await tokenClient.request('get', '/tasks/task-1');
131
+ const [, init] = mockFetch.mock.calls[0];
132
+ const headers = init.headers;
133
+ expect(headers.Authorization).toBeUndefined();
134
+ });
135
+ it('should omit Authorization when getToken returns undefined', async () => {
136
+ mockJsonResponse({ id: 'task-1' });
137
+ const tokenClient = new HttpClient({ getToken: () => undefined });
138
+ await tokenClient.request('get', '/tasks/task-1');
139
+ const [, init] = mockFetch.mock.calls[0];
140
+ const headers = init.headers;
141
+ expect(headers.Authorization).toBeUndefined();
142
+ });
117
143
  it('should use top-level message field in HTTP error responses', async () => {
118
144
  mockFetch.mockResolvedValueOnce({
119
145
  ok: false,
@@ -125,6 +151,99 @@ describe('HttpClient', () => {
125
151
  message: expect.stringContaining('service unavailable'),
126
152
  });
127
153
  });
154
+ it('should use getToken for Authorization on regular requests', async () => {
155
+ mockJsonResponse({ id: 'task-1' });
156
+ const tokenClient = new HttpClient({ getToken: () => 'dynamic-key' });
157
+ await tokenClient.request('get', '/tasks/task-1');
158
+ const [, init] = mockFetch.mock.calls[0];
159
+ const headers = init.headers;
160
+ expect(headers.Authorization).toBe('Bearer dynamic-key');
161
+ });
162
+ it('should resolve function-valued headers on each request', async () => {
163
+ mockJsonResponse({ id: 'task-1' });
164
+ mockJsonResponse({ id: 'task-2' });
165
+ let teamId = 'team-a';
166
+ const client = new HttpClient({
167
+ apiKey: 'test-key',
168
+ headers: {
169
+ 'X-Team-ID': () => teamId,
170
+ 'X-Request-Id': () => 'req-1',
171
+ },
172
+ });
173
+ await client.request('get', '/tasks/task-1');
174
+ teamId = 'team-b';
175
+ await client.request('get', '/tasks/task-2');
176
+ const firstHeaders = mockFetch.mock.calls[0][1]?.headers;
177
+ const secondHeaders = mockFetch.mock.calls[1][1]?.headers;
178
+ expect(firstHeaders['X-Team-ID']).toBe('team-a');
179
+ expect(secondHeaders['X-Team-ID']).toBe('team-b');
180
+ expect(firstHeaders['X-Request-Id']).toBe('req-1');
181
+ });
182
+ it('should omit headers when a resolver returns undefined', async () => {
183
+ mockJsonResponse({ id: 'task-1' });
184
+ const client = new HttpClient({
185
+ apiKey: 'test-key',
186
+ headers: {
187
+ 'X-Team-ID': () => undefined,
188
+ 'X-Custom': 'static',
189
+ },
190
+ });
191
+ await client.request('get', '/tasks/task-1');
192
+ const headers = mockFetch.mock.calls[0][1]?.headers;
193
+ expect(headers['X-Team-ID']).toBeUndefined();
194
+ expect(headers['X-Custom']).toBe('static');
195
+ });
196
+ it('should send X-API-Version 2 and X-Client-Source on every request', async () => {
197
+ mockJsonResponse({ id: 'task-1' });
198
+ await client().request('get', '/tasks/task-1');
199
+ const [, init] = mockFetch.mock.calls[0];
200
+ const headers = init.headers;
201
+ expect(headers['X-API-Version']).toBe('2');
202
+ expect(headers['X-Client-Source']).toMatch(/inference-sdk-js\//);
203
+ });
204
+ it('should prefer RFC 9457 detail over title in error responses', async () => {
205
+ mockFetch.mockResolvedValueOnce({
206
+ ok: false,
207
+ status: 422,
208
+ text: () => Promise.resolve(JSON.stringify({
209
+ type: 'about:blank',
210
+ title: 'Validation failed',
211
+ detail: 'app field is required',
212
+ })),
213
+ });
214
+ await expect(client().request('post', '/apps')).rejects.toMatchObject({
215
+ name: 'InferenceError',
216
+ message: expect.stringContaining('app field is required'),
217
+ });
218
+ });
219
+ it('should fall back to RFC 9457 title when detail is absent', async () => {
220
+ mockFetch.mockResolvedValueOnce({
221
+ ok: false,
222
+ status: 403,
223
+ text: () => Promise.resolve(JSON.stringify({ type: 'about:blank', title: 'Forbidden' })),
224
+ });
225
+ await expect(client().request('get', '/tasks/1')).rejects.toMatchObject({
226
+ name: 'InferenceError',
227
+ message: expect.stringContaining('Forbidden'),
228
+ });
229
+ });
230
+ it('should use raw response text when error body is not JSON', async () => {
231
+ mockFetch.mockResolvedValueOnce({
232
+ ok: false,
233
+ status: 502,
234
+ text: () => Promise.resolve('Bad Gateway from upstream'),
235
+ });
236
+ await expect(client().request('get', '/tasks/1')).rejects.toMatchObject({
237
+ name: 'InferenceError',
238
+ message: expect.stringContaining('Bad Gateway from upstream'),
239
+ });
240
+ });
241
+ it('should not unwrap legacy v1 APIResponse envelopes', async () => {
242
+ const v1Envelope = { success: true, data: { id: 'task-legacy' } };
243
+ mockJsonResponse(v1Envelope);
244
+ const result = await client().request('get', '/tasks/legacy');
245
+ expect(result).toEqual(v1Envelope);
246
+ });
128
247
  });
129
248
  describe('getStreamableConfig', () => {
130
249
  it('should include bearer token in direct mode', () => {
@@ -146,6 +265,14 @@ describe('HttpClient', () => {
146
265
  }).getStreamableConfig('/tasks/task-1/stream');
147
266
  expect(config.headers.Authorization).toBe('Bearer dynamic-token');
148
267
  });
268
+ it('should include resolved dynamic headers for streaming requests', () => {
269
+ const config = new HttpClient({
270
+ apiKey: 'secret-key',
271
+ headers: { 'X-Team-ID': () => 'team-stream' },
272
+ }).getStreamableConfig('/tasks/task-1/stream');
273
+ expect(config.headers['X-Team-ID']).toBe('team-stream');
274
+ expect(config.headers.Authorization).toBe('Bearer secret-key');
275
+ });
149
276
  });
150
277
  describe('createEventSource', () => {
151
278
  beforeEach(() => {
@@ -186,5 +313,123 @@ describe('HttpClient', () => {
186
313
  }),
187
314
  }));
188
315
  });
316
+ it('should attach resolved dynamic headers on SSE custom fetch', async () => {
317
+ let capturedFetch;
318
+ MockEventSource.mockImplementation((_url, options) => {
319
+ capturedFetch = options?.fetch;
320
+ return { close: jest.fn(), onmessage: null, onerror: null };
321
+ });
322
+ mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
323
+ const client = new HttpClient({
324
+ apiKey: 'sse-key',
325
+ headers: { 'X-Team-ID': () => 'team-sse' },
326
+ });
327
+ await client.createEventSource('/tasks/task-1/stream');
328
+ await capturedFetch('https://api.inference.sh/tasks/task-1/stream', { headers: {} });
329
+ expect(mockFetch).toHaveBeenCalledWith('https://api.inference.sh/tasks/task-1/stream', expect.objectContaining({
330
+ headers: expect.objectContaining({
331
+ 'X-Team-ID': 'team-sse',
332
+ Authorization: 'Bearer sse-key',
333
+ }),
334
+ }));
335
+ });
336
+ it('should use getToken for Authorization on SSE custom fetch', async () => {
337
+ let capturedFetch;
338
+ MockEventSource.mockImplementation((_url, options) => {
339
+ capturedFetch = options?.fetch;
340
+ return { close: jest.fn(), onmessage: null, onerror: null };
341
+ });
342
+ mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
343
+ const client = new HttpClient({ getToken: () => 'session-token' });
344
+ await client.createEventSource('/tasks/task-1/stream');
345
+ await capturedFetch('https://api.inference.sh/tasks/task-1/stream', { headers: {} });
346
+ expect(mockFetch).toHaveBeenCalledWith('https://api.inference.sh/tasks/task-1/stream', expect.objectContaining({
347
+ headers: expect.objectContaining({
348
+ Authorization: 'Bearer session-token',
349
+ }),
350
+ }));
351
+ });
352
+ it('should omit Authorization on SSE fetch when getToken returns null', async () => {
353
+ let capturedFetch;
354
+ MockEventSource.mockImplementation((_url, options) => {
355
+ capturedFetch = options?.fetch;
356
+ return { close: jest.fn(), onmessage: null, onerror: null };
357
+ });
358
+ mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
359
+ const client = new HttpClient({ getToken: () => null });
360
+ await client.createEventSource('/tasks/task-1/stream');
361
+ await capturedFetch('https://api.inference.sh/tasks/task-1/stream', { headers: {} });
362
+ const [, init] = mockFetch.mock.calls[0];
363
+ const headers = init.headers;
364
+ expect(headers.Authorization).toBeUndefined();
365
+ });
366
+ it('should return EventSource without awaiting the initial fetch', async () => {
367
+ MockEventSource.mockImplementation(() => ({ close: jest.fn() }));
368
+ const client = new HttpClient({ apiKey: 'sse-key' });
369
+ const eventSource = await client.createEventSource('/tasks/task-1/stream');
370
+ expect(eventSource).toBeDefined();
371
+ expect(MockEventSource).toHaveBeenCalled();
372
+ expect(mockFetch).not.toHaveBeenCalled();
373
+ });
374
+ function mockFailedResponse(status, body) {
375
+ return {
376
+ ok: false,
377
+ status,
378
+ clone: () => ({
379
+ text: () => Promise.resolve(body),
380
+ }),
381
+ };
382
+ }
383
+ it('should route failed initial SSE response through onError and return retried response', async () => {
384
+ let capturedFetch;
385
+ MockEventSource.mockImplementation((_url, options) => {
386
+ capturedFetch = options?.fetch;
387
+ return { close: jest.fn(), onmessage: null, onerror: null };
388
+ });
389
+ const onError = jest.fn(async (_error, retry) => retry());
390
+ const client = new HttpClient({ apiKey: 'sse-key', onError });
391
+ mockFetch
392
+ .mockResolvedValueOnce(mockFailedResponse(403, JSON.stringify({ detail: 'otp_required' })))
393
+ .mockResolvedValueOnce({ ok: true, status: 200 });
394
+ await client.createEventSource('/tasks/task-1/stream');
395
+ const response = await capturedFetch('https://api.inference.sh/tasks/task-1/stream', {});
396
+ expect(onError).toHaveBeenCalledTimes(1);
397
+ const [error] = onError.mock.calls[0];
398
+ expect(error).toBeInstanceOf(InferenceError);
399
+ expect(error.statusCode).toBe(403);
400
+ expect(error.message).toContain('otp_required');
401
+ expect(response).toEqual({ ok: true, status: 200 });
402
+ expect(mockFetch).toHaveBeenCalledTimes(2);
403
+ });
404
+ it('should return original failed response when onError does not retry', async () => {
405
+ let capturedFetch;
406
+ MockEventSource.mockImplementation((_url, options) => {
407
+ capturedFetch = options?.fetch;
408
+ return { close: jest.fn(), onmessage: null, onerror: null };
409
+ });
410
+ const failed = mockFailedResponse(401, JSON.stringify({ title: 'Unauthorized' }));
411
+ const onError = jest.fn(async () => undefined);
412
+ const client = new HttpClient({ apiKey: 'sse-key', onError });
413
+ mockFetch.mockResolvedValueOnce(failed);
414
+ await client.createEventSource('/tasks/task-1/stream');
415
+ const response = await capturedFetch('https://api.inference.sh/tasks/task-1/stream', {});
416
+ expect(onError).toHaveBeenCalledTimes(1);
417
+ expect(response).toBe(failed);
418
+ expect(mockFetch).toHaveBeenCalledTimes(1);
419
+ });
420
+ it('should return failed response when no onError handler is configured', async () => {
421
+ let capturedFetch;
422
+ MockEventSource.mockImplementation((_url, options) => {
423
+ capturedFetch = options?.fetch;
424
+ return { close: jest.fn(), onmessage: null, onerror: null };
425
+ });
426
+ const failed = mockFailedResponse(503, 'service unavailable');
427
+ const client = new HttpClient({ apiKey: 'sse-key' });
428
+ mockFetch.mockResolvedValueOnce(failed);
429
+ await client.createEventSource('/tasks/task-1/stream');
430
+ const response = await capturedFetch('https://api.inference.sh/tasks/task-1/stream', {});
431
+ expect(response).toBe(failed);
432
+ expect(mockFetch).toHaveBeenCalledTimes(1);
433
+ });
189
434
  });
190
435
  });
@@ -1,4 +1,5 @@
1
1
  import { InferenceError, RequirementsNotMetException, SessionEndedError, SessionExpiredError, SessionNotFoundError, WorkerLostError, isInferenceError, isRequirementsNotMetException, isSessionError, } from './errors';
2
+ import { RequirementTypeIntegration, RequirementTypeScope, RequirementTypeSecret, } from '../types';
2
3
  describe('error type guards', () => {
3
4
  it('isRequirementsNotMetException should match instances and plain objects', () => {
4
5
  const instance = new RequirementsNotMetException([
@@ -43,6 +44,18 @@ describe('error classes', () => {
43
44
  expect(err.statusCode).toBe(412);
44
45
  expect(err.message).toBe('requirements not met');
45
46
  });
47
+ it('RequirementsNotMetException should preserve typed RequirementType values', () => {
48
+ const errors = [
49
+ { type: RequirementTypeSecret, key: 'API_KEY', message: 'Missing secret' },
50
+ { type: RequirementTypeIntegration, key: 'google', message: 'Not connected' },
51
+ { type: RequirementTypeScope, key: 'calendar.readonly', message: 'Missing scope' },
52
+ ];
53
+ const err = new RequirementsNotMetException(errors);
54
+ expect(err.errors[0].type).toBe('secret');
55
+ expect(err.errors[1].type).toBe('integration');
56
+ expect(err.errors[2].type).toBe('scope');
57
+ expect(isRequirementsNotMetException(err)).toBe(true);
58
+ });
46
59
  it('session error subclasses should expose sessionId and statusCode', () => {
47
60
  expect(new SessionNotFoundError('sess-a').sessionId).toBe('sess-a');
48
61
  expect(new SessionNotFoundError('sess-a').statusCode).toBe(404);