@inferencesh/sdk 0.6.5 → 0.6.7
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.
- package/README.md +1 -1
- package/dist/agent/reducer.test.d.ts +1 -0
- package/dist/agent/reducer.test.js +127 -0
- package/dist/api/agents.test.d.ts +1 -0
- package/dist/api/agents.test.js +141 -0
- package/dist/api/files.test.d.ts +1 -0
- package/dist/api/files.test.js +68 -0
- package/dist/api/tasks.d.ts +1 -5
- package/dist/api/tasks.js +1 -1
- package/dist/api/tasks.test.d.ts +1 -0
- package/dist/api/tasks.test.js +157 -0
- package/dist/http/client.test.d.ts +1 -0
- package/dist/http/client.test.js +152 -0
- package/dist/http/errors.test.d.ts +1 -0
- package/dist/http/errors.test.js +53 -0
- package/dist/http/poll.test.d.ts +1 -0
- package/dist/http/poll.test.js +85 -0
- package/dist/proxy/index.test.d.ts +1 -0
- package/dist/proxy/index.test.js +147 -0
- package/dist/stream.test.js +48 -0
- package/dist/utils.test.d.ts +1 -0
- package/dist/utils.test.js +43 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -409,7 +409,7 @@ import type { Task, ApiTaskRequest, RunOptions } from '@inferencesh/sdk';
|
|
|
409
409
|
- [documentation](https://inference.sh/docs) — getting started guides and api reference
|
|
410
410
|
- [blog](https://inference.sh/blog) — tutorials on ai agents, image generation, and more
|
|
411
411
|
- [app store](https://app.inference.sh) — browse 250+ ai models
|
|
412
|
-
- [discord](https://discord.gg/
|
|
412
|
+
- [discord](https://discord.gg/inference) — community support
|
|
413
413
|
- [github](https://github.com/inference-sh) — open source projects
|
|
414
414
|
|
|
415
415
|
## license
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { ChatStatusBusy, ChatStatusIdle } from '../types';
|
|
2
|
+
import { chatReducer, initialState } from './reducer';
|
|
3
|
+
function makeMessage(id, order, chatId = 'chat-1') {
|
|
4
|
+
return {
|
|
5
|
+
id,
|
|
6
|
+
short_id: id,
|
|
7
|
+
created_at: '2026-01-01T00:00:00Z',
|
|
8
|
+
updated_at: '2026-01-01T00:00:00Z',
|
|
9
|
+
user_id: 'user-1',
|
|
10
|
+
team_id: 'team-1',
|
|
11
|
+
visibility: 'private',
|
|
12
|
+
chat_id: chatId,
|
|
13
|
+
order,
|
|
14
|
+
status: 'completed',
|
|
15
|
+
role: 'user',
|
|
16
|
+
content: [{ type: 'text', text: `message ${id}` }],
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function makeChat(overrides = {}) {
|
|
20
|
+
return {
|
|
21
|
+
id: 'chat-1',
|
|
22
|
+
short_id: 'c1',
|
|
23
|
+
created_at: '2026-01-01T00:00:00Z',
|
|
24
|
+
updated_at: '2026-01-01T00:00:00Z',
|
|
25
|
+
user_id: 'user-1',
|
|
26
|
+
team_id: 'team-1',
|
|
27
|
+
visibility: 'private',
|
|
28
|
+
status: ChatStatusBusy,
|
|
29
|
+
name: 'Test chat',
|
|
30
|
+
description: '',
|
|
31
|
+
children: [],
|
|
32
|
+
chat_messages: [makeMessage('msg-1', 1), makeMessage('msg-2', 2)],
|
|
33
|
+
agent_data: {},
|
|
34
|
+
...overrides,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
describe('chatReducer', () => {
|
|
38
|
+
it('SET_CHAT should sort messages by order', () => {
|
|
39
|
+
const chat = makeChat({
|
|
40
|
+
chat_messages: [makeMessage('msg-2', 2), makeMessage('msg-1', 1)],
|
|
41
|
+
});
|
|
42
|
+
const next = chatReducer(initialState, { type: 'SET_CHAT', payload: chat });
|
|
43
|
+
expect(next.messages.map((m) => m.id)).toEqual(['msg-1', 'msg-2']);
|
|
44
|
+
expect(next.chat?.status).toBe(ChatStatusBusy);
|
|
45
|
+
});
|
|
46
|
+
it('UPDATE_CHAT should update chat metadata without replacing messages', () => {
|
|
47
|
+
const chat = makeChat();
|
|
48
|
+
const withMessages = chatReducer(initialState, { type: 'SET_CHAT', payload: chat });
|
|
49
|
+
const idleChat = makeChat({ status: ChatStatusIdle, chat_messages: [] });
|
|
50
|
+
const next = chatReducer(withMessages, { type: 'UPDATE_CHAT', payload: idleChat });
|
|
51
|
+
expect(next.chat?.status).toBe(ChatStatusIdle);
|
|
52
|
+
expect(next.messages).toHaveLength(2);
|
|
53
|
+
expect(next.messages.map((m) => m.id)).toEqual(['msg-1', 'msg-2']);
|
|
54
|
+
});
|
|
55
|
+
it('UPDATE_CHAT with null payload should leave state unchanged', () => {
|
|
56
|
+
const chat = makeChat();
|
|
57
|
+
const withChat = chatReducer(initialState, { type: 'SET_CHAT', payload: chat });
|
|
58
|
+
const next = chatReducer(withChat, { type: 'UPDATE_CHAT', payload: null });
|
|
59
|
+
expect(next).toBe(withChat);
|
|
60
|
+
});
|
|
61
|
+
it('UPDATE_MESSAGE should replace an existing message by id', () => {
|
|
62
|
+
const chat = makeChat();
|
|
63
|
+
const state = chatReducer(initialState, { type: 'SET_CHAT', payload: chat });
|
|
64
|
+
const updated = makeMessage('msg-1', 1);
|
|
65
|
+
updated.content = [{ type: 'text', text: 'edited' }];
|
|
66
|
+
const next = chatReducer(state, { type: 'UPDATE_MESSAGE', payload: updated });
|
|
67
|
+
expect(next.messages.find((m) => m.id === 'msg-1')?.content[0]?.text).toBe('edited');
|
|
68
|
+
expect(next.messages).toHaveLength(2);
|
|
69
|
+
});
|
|
70
|
+
it('UPDATE_MESSAGE should append and sort new messages', () => {
|
|
71
|
+
const chat = makeChat({ chat_messages: [makeMessage('msg-1', 1)] });
|
|
72
|
+
const state = chatReducer(initialState, { type: 'SET_CHAT', payload: chat });
|
|
73
|
+
const next = chatReducer(state, { type: 'UPDATE_MESSAGE', payload: makeMessage('msg-2', 2) });
|
|
74
|
+
expect(next.messages.map((m) => m.id)).toEqual(['msg-1', 'msg-2']);
|
|
75
|
+
});
|
|
76
|
+
it('SET_CHAT with null should clear chat and messages', () => {
|
|
77
|
+
const chat = makeChat();
|
|
78
|
+
const state = chatReducer(initialState, { type: 'SET_CHAT', payload: chat });
|
|
79
|
+
const next = chatReducer(state, { type: 'SET_CHAT', payload: null });
|
|
80
|
+
expect(next.chat).toBeNull();
|
|
81
|
+
expect(next.messages).toEqual([]);
|
|
82
|
+
expect(next.connectionStatus).toBe('idle');
|
|
83
|
+
});
|
|
84
|
+
it('ADD_MESSAGE should append and sort by order', () => {
|
|
85
|
+
const chat = makeChat({ chat_messages: [makeMessage('msg-1', 1)] });
|
|
86
|
+
const state = chatReducer(initialState, { type: 'SET_CHAT', payload: chat });
|
|
87
|
+
const next = chatReducer(state, {
|
|
88
|
+
type: 'ADD_MESSAGE',
|
|
89
|
+
payload: makeMessage('msg-0', 0),
|
|
90
|
+
});
|
|
91
|
+
expect(next.messages.map((m) => m.id)).toEqual(['msg-0', 'msg-1']);
|
|
92
|
+
});
|
|
93
|
+
it('RESET should return initial state', () => {
|
|
94
|
+
const chat = makeChat();
|
|
95
|
+
const state = chatReducer(initialState, { type: 'SET_CHAT', payload: chat });
|
|
96
|
+
expect(chatReducer(state, { type: 'RESET' })).toEqual(initialState);
|
|
97
|
+
});
|
|
98
|
+
it('SET_CHAT_ID should update chatId', () => {
|
|
99
|
+
const next = chatReducer(initialState, { type: 'SET_CHAT_ID', payload: 'chat-99' });
|
|
100
|
+
expect(next.chatId).toBe('chat-99');
|
|
101
|
+
});
|
|
102
|
+
it('SET_MESSAGES should replace the message list', () => {
|
|
103
|
+
const messages = [makeMessage('msg-a', 1)];
|
|
104
|
+
const next = chatReducer(initialState, { type: 'SET_MESSAGES', payload: messages });
|
|
105
|
+
expect(next.messages).toEqual(messages);
|
|
106
|
+
});
|
|
107
|
+
it('ADD_MESSAGE after SET_MESSAGES should append and sort by order', () => {
|
|
108
|
+
const state = chatReducer(initialState, {
|
|
109
|
+
type: 'SET_MESSAGES',
|
|
110
|
+
payload: [makeMessage('msg-2', 2)],
|
|
111
|
+
});
|
|
112
|
+
const next = chatReducer(state, { type: 'ADD_MESSAGE', payload: makeMessage('msg-1', 1) });
|
|
113
|
+
expect(next.messages.map((m) => m.id)).toEqual(['msg-1', 'msg-2']);
|
|
114
|
+
});
|
|
115
|
+
it('SET_CONNECTION_STATUS and SET_ERROR should update connection fields', () => {
|
|
116
|
+
const streaming = chatReducer(initialState, {
|
|
117
|
+
type: 'SET_CONNECTION_STATUS',
|
|
118
|
+
payload: 'streaming',
|
|
119
|
+
});
|
|
120
|
+
expect(streaming.connectionStatus).toBe('streaming');
|
|
121
|
+
const errored = chatReducer(streaming, {
|
|
122
|
+
type: 'SET_ERROR',
|
|
123
|
+
payload: 'stream failed',
|
|
124
|
+
});
|
|
125
|
+
expect(errored.error).toBe('stream failed');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { HttpClient } from '../http/client';
|
|
2
|
+
import { ChatStatusBusy, ChatStatusIdle, ToolInvocationStatusAwaitingInput, ToolTypeClient, } from '../types';
|
|
3
|
+
import { FilesAPI } from './files';
|
|
4
|
+
import { AgentsAPI } from './agents';
|
|
5
|
+
const mockFetch = jest.fn();
|
|
6
|
+
global.fetch = mockFetch;
|
|
7
|
+
function mockJsonResponse(body) {
|
|
8
|
+
mockFetch.mockResolvedValueOnce({
|
|
9
|
+
ok: true,
|
|
10
|
+
status: 200,
|
|
11
|
+
text: () => Promise.resolve(JSON.stringify(body)),
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
function makeMessage(overrides = {}) {
|
|
15
|
+
return {
|
|
16
|
+
id: 'msg-1',
|
|
17
|
+
chat_id: 'chat-1',
|
|
18
|
+
role: 'assistant',
|
|
19
|
+
content: 'hello',
|
|
20
|
+
...overrides,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
describe('Agent.sendMessage (polling mode)', () => {
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
jest.clearAllMocks();
|
|
26
|
+
});
|
|
27
|
+
const agent = () => {
|
|
28
|
+
const http = new HttpClient({
|
|
29
|
+
apiKey: 'test-key',
|
|
30
|
+
stream: false,
|
|
31
|
+
pollIntervalMs: 20,
|
|
32
|
+
});
|
|
33
|
+
return new AgentsAPI(http, new FilesAPI(http)).create('my-agent');
|
|
34
|
+
};
|
|
35
|
+
it('should wait until chat is idle when stream is false', async () => {
|
|
36
|
+
const userMessage = makeMessage({ id: 'user-1', role: 'user' });
|
|
37
|
+
const assistantMessage = makeMessage({ id: 'asst-1' });
|
|
38
|
+
mockJsonResponse({
|
|
39
|
+
success: true,
|
|
40
|
+
data: { user_message: userMessage, assistant_message: assistantMessage },
|
|
41
|
+
});
|
|
42
|
+
mockJsonResponse({ success: true, data: { status: ChatStatusBusy } });
|
|
43
|
+
mockJsonResponse({
|
|
44
|
+
success: true,
|
|
45
|
+
data: { id: 'chat-1', status: ChatStatusBusy, chat_messages: [] },
|
|
46
|
+
});
|
|
47
|
+
mockJsonResponse({ success: true, data: { status: ChatStatusIdle } });
|
|
48
|
+
mockJsonResponse({
|
|
49
|
+
success: true,
|
|
50
|
+
data: { id: 'chat-1', status: ChatStatusIdle, chat_messages: [] },
|
|
51
|
+
});
|
|
52
|
+
const onChat = jest.fn();
|
|
53
|
+
const result = await agent().sendMessage('hello', { stream: false, onChat });
|
|
54
|
+
expect(result.userMessage).toEqual(userMessage);
|
|
55
|
+
expect(result.assistantMessage).toEqual(assistantMessage);
|
|
56
|
+
expect(onChat).toHaveBeenCalledWith(expect.objectContaining({ id: 'chat-1', status: ChatStatusIdle }));
|
|
57
|
+
});
|
|
58
|
+
it('should dispatch onToolCall once per client tool invocation', async () => {
|
|
59
|
+
const toolInvocation = {
|
|
60
|
+
id: 'tool-inv-1',
|
|
61
|
+
type: ToolTypeClient,
|
|
62
|
+
status: ToolInvocationStatusAwaitingInput,
|
|
63
|
+
function: { name: 'my_tool', arguments: { x: 1 } },
|
|
64
|
+
};
|
|
65
|
+
const messageWithTool = makeMessage({ tool_invocations: [toolInvocation] });
|
|
66
|
+
mockJsonResponse({
|
|
67
|
+
success: true,
|
|
68
|
+
data: {
|
|
69
|
+
user_message: makeMessage({ id: 'user-1', role: 'user' }),
|
|
70
|
+
assistant_message: makeMessage(),
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
mockJsonResponse({ success: true, data: { status: ChatStatusBusy } });
|
|
74
|
+
mockJsonResponse({
|
|
75
|
+
success: true,
|
|
76
|
+
data: {
|
|
77
|
+
id: 'chat-1',
|
|
78
|
+
status: ChatStatusBusy,
|
|
79
|
+
chat_messages: [messageWithTool],
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
// Same status again — stub poll should not re-dispatch tool
|
|
83
|
+
mockJsonResponse({ success: true, data: { status: ChatStatusBusy } });
|
|
84
|
+
mockJsonResponse({ success: true, data: { status: ChatStatusIdle } });
|
|
85
|
+
mockJsonResponse({
|
|
86
|
+
success: true,
|
|
87
|
+
data: { id: 'chat-1', status: ChatStatusIdle, chat_messages: [messageWithTool] },
|
|
88
|
+
});
|
|
89
|
+
const onMessage = jest.fn();
|
|
90
|
+
const onToolCall = jest.fn();
|
|
91
|
+
await agent().sendMessage('run tool', { stream: false, onMessage, onToolCall });
|
|
92
|
+
expect(onToolCall).toHaveBeenCalledTimes(1);
|
|
93
|
+
expect(onToolCall).toHaveBeenCalledWith({
|
|
94
|
+
id: 'tool-inv-1',
|
|
95
|
+
name: 'my_tool',
|
|
96
|
+
args: { x: 1 },
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
it('should return chat output from run() after polling completes', async () => {
|
|
100
|
+
const userMessage = makeMessage({ id: 'user-1', role: 'user' });
|
|
101
|
+
const assistantMessage = makeMessage();
|
|
102
|
+
mockJsonResponse({
|
|
103
|
+
success: true,
|
|
104
|
+
data: { user_message: userMessage, assistant_message: assistantMessage },
|
|
105
|
+
});
|
|
106
|
+
mockJsonResponse({ success: true, data: { status: ChatStatusBusy } });
|
|
107
|
+
mockJsonResponse({
|
|
108
|
+
success: true,
|
|
109
|
+
data: { id: 'chat-1', status: ChatStatusBusy },
|
|
110
|
+
});
|
|
111
|
+
mockJsonResponse({ success: true, data: { status: ChatStatusIdle } });
|
|
112
|
+
mockJsonResponse({
|
|
113
|
+
success: true,
|
|
114
|
+
data: { id: 'chat-1', status: ChatStatusIdle },
|
|
115
|
+
});
|
|
116
|
+
mockJsonResponse({
|
|
117
|
+
success: true,
|
|
118
|
+
data: { id: 'chat-1', status: ChatStatusIdle, output: { answer: 42 } },
|
|
119
|
+
});
|
|
120
|
+
const output = await agent().run('compute');
|
|
121
|
+
expect(output).toEqual({ answer: 42 });
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
describe('Agent.submitToolResult', () => {
|
|
125
|
+
beforeEach(() => {
|
|
126
|
+
jest.clearAllMocks();
|
|
127
|
+
});
|
|
128
|
+
it('should JSON-stringify structured action results', async () => {
|
|
129
|
+
const http = new HttpClient({ apiKey: 'test-key' });
|
|
130
|
+
const agentInstance = new AgentsAPI(http, new FilesAPI(http)).create('my-agent');
|
|
131
|
+
mockJsonResponse({ success: true, data: null });
|
|
132
|
+
const payload = {
|
|
133
|
+
action: { type: 'form_submit', payload: { field: 'value' } },
|
|
134
|
+
form_data: { field: 'value' },
|
|
135
|
+
};
|
|
136
|
+
await agentInstance.submitToolResult('inv-99', payload);
|
|
137
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
138
|
+
const body = JSON.parse(String(init.body));
|
|
139
|
+
expect(body.result).toBe(JSON.stringify(payload));
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { HttpClient } from '../http/client';
|
|
2
|
+
import { FilesAPI } from './files';
|
|
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('FilesAPI', () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
jest.clearAllMocks();
|
|
15
|
+
});
|
|
16
|
+
const api = () => new FilesAPI(new HttpClient({ apiKey: 'test-key' }));
|
|
17
|
+
describe('processInput', () => {
|
|
18
|
+
it('should not treat short plain strings as base64 file uploads', async () => {
|
|
19
|
+
const result = await api().processInput({ key: 'key1', note: 'hello' });
|
|
20
|
+
expect(result).toEqual({ key: 'key1', note: 'hello' });
|
|
21
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
22
|
+
});
|
|
23
|
+
it('should upload data URIs embedded in nested objects', async () => {
|
|
24
|
+
const fileRecord = {
|
|
25
|
+
id: 'file-1',
|
|
26
|
+
uri: 'inf://files/abc',
|
|
27
|
+
upload_url: 'https://upload.example.com/put',
|
|
28
|
+
content_type: 'image/png',
|
|
29
|
+
};
|
|
30
|
+
mockJsonResponse({ success: true, data: [fileRecord] });
|
|
31
|
+
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
|
32
|
+
const input = {
|
|
33
|
+
prompt: 'draw',
|
|
34
|
+
image: 'data:image/png;base64,iVBORw0KGgo=',
|
|
35
|
+
};
|
|
36
|
+
const result = (await api().processInput(input));
|
|
37
|
+
expect(result.prompt).toBe('draw');
|
|
38
|
+
expect(result.image).toBe('inf://files/abc');
|
|
39
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
describe('upload', () => {
|
|
43
|
+
it('should reject invalid data URI format when uploading content', async () => {
|
|
44
|
+
mockJsonResponse({
|
|
45
|
+
success: true,
|
|
46
|
+
data: [{ id: 'file-x', uri: '', upload_url: 'https://upload.example.com/put' }],
|
|
47
|
+
});
|
|
48
|
+
await expect(api().upload('data:invalid')).rejects.toThrow('Invalid data URI format');
|
|
49
|
+
});
|
|
50
|
+
it('should decode URL-safe base64 in data URIs', async () => {
|
|
51
|
+
const fileRecord = {
|
|
52
|
+
id: 'file-2',
|
|
53
|
+
uri: 'inf://files/def',
|
|
54
|
+
upload_url: 'https://upload.example.com/put',
|
|
55
|
+
content_type: 'text/plain',
|
|
56
|
+
};
|
|
57
|
+
mockJsonResponse({ success: true, data: [fileRecord] });
|
|
58
|
+
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
|
59
|
+
// "SGVsbG8" is "Hello" in standard base64; URL-safe variant uses '-' instead of '+'
|
|
60
|
+
const dataUri = 'data:text/plain;base64,SGVsbG8';
|
|
61
|
+
const result = await api().upload(dataUri);
|
|
62
|
+
expect(result.uri).toBe('inf://files/def');
|
|
63
|
+
const putCall = mockFetch.mock.calls[1];
|
|
64
|
+
expect(putCall[0]).toBe('https://upload.example.com/put');
|
|
65
|
+
expect(putCall[1]?.method).toBe('PUT');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
});
|
package/dist/api/tasks.d.ts
CHANGED
|
@@ -7,12 +7,8 @@ export interface RunOptions {
|
|
|
7
7
|
onPartialUpdate?: (update: Task, fields: string[]) => void;
|
|
8
8
|
/** Wait for task completion (default: true) */
|
|
9
9
|
wait?: boolean;
|
|
10
|
-
/**
|
|
11
|
-
autoReconnect?: boolean;
|
|
12
|
-
/** Maximum reconnection attempts (default: 5) */
|
|
10
|
+
/** Maximum retry attempts when using polling mode (stream: false). Default: 5 */
|
|
13
11
|
maxReconnects?: number;
|
|
14
|
-
/** Delay between reconnection attempts in ms (default: 1000) */
|
|
15
|
-
reconnectDelayMs?: number;
|
|
16
12
|
/** Use SSE streaming (true) or polling (false). Overrides client default. */
|
|
17
13
|
stream?: boolean;
|
|
18
14
|
/** Polling interval in ms when stream is false. Overrides client default. */
|
package/dist/api/tasks.js
CHANGED
|
@@ -69,7 +69,7 @@ export class TasksAPI {
|
|
|
69
69
|
* Run a task and optionally wait for completion
|
|
70
70
|
*/
|
|
71
71
|
async run(params, processedInput, options = {}) {
|
|
72
|
-
const { onUpdate, onPartialUpdate, wait = true,
|
|
72
|
+
const { onUpdate, onPartialUpdate, wait = true, } = options;
|
|
73
73
|
const task = await this.http.request('post', '/apps/run', {
|
|
74
74
|
data: {
|
|
75
75
|
...params,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { HttpClient } from '../http/client';
|
|
2
|
+
import { TaskStatusCancelled, TaskStatusCompleted, TaskStatusFailed, TaskStatusRunning, } from '../types';
|
|
3
|
+
import { TasksAPI } from './tasks';
|
|
4
|
+
const mockFetch = jest.fn();
|
|
5
|
+
global.fetch = mockFetch;
|
|
6
|
+
function mockJsonResponse(body) {
|
|
7
|
+
mockFetch.mockResolvedValueOnce({
|
|
8
|
+
ok: true,
|
|
9
|
+
status: 200,
|
|
10
|
+
text: () => Promise.resolve(JSON.stringify(body)),
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
function makeTask(overrides = {}) {
|
|
14
|
+
return {
|
|
15
|
+
id: 'task-1',
|
|
16
|
+
status: TaskStatusRunning,
|
|
17
|
+
created_at: '2026-01-01T00:00:00Z',
|
|
18
|
+
updated_at: '2026-01-01T00:00:00Z',
|
|
19
|
+
input: { prompt: 'hi' },
|
|
20
|
+
output: null,
|
|
21
|
+
logs: [],
|
|
22
|
+
session_id: 'sess-1',
|
|
23
|
+
...overrides,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
describe('TasksAPI.run (polling mode)', () => {
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
jest.clearAllMocks();
|
|
29
|
+
});
|
|
30
|
+
const api = () => new TasksAPI(new HttpClient({
|
|
31
|
+
apiKey: 'test-key',
|
|
32
|
+
stream: false,
|
|
33
|
+
pollIntervalMs: 20,
|
|
34
|
+
}));
|
|
35
|
+
it('should resolve when status polling detects completion', async () => {
|
|
36
|
+
const runningTask = makeTask();
|
|
37
|
+
const completedTask = makeTask({ status: TaskStatusCompleted, output: { ok: true } });
|
|
38
|
+
mockJsonResponse({ success: true, data: runningTask });
|
|
39
|
+
mockJsonResponse({ success: true, data: { status: TaskStatusRunning } });
|
|
40
|
+
mockJsonResponse({ success: true, data: { status: TaskStatusCompleted } });
|
|
41
|
+
mockJsonResponse({ success: true, data: completedTask });
|
|
42
|
+
const onUpdate = jest.fn();
|
|
43
|
+
const result = await api().run({ app: 'test-app', input: {} }, { prompt: 'hi' }, { wait: true, stream: false, onUpdate });
|
|
44
|
+
expect(result.status).toBe(TaskStatusCompleted);
|
|
45
|
+
expect(onUpdate).toHaveBeenCalledWith(expect.objectContaining({ id: 'task-1', status: TaskStatusCompleted }));
|
|
46
|
+
});
|
|
47
|
+
it('should reject when polling detects a failed task', async () => {
|
|
48
|
+
const runningTask = makeTask();
|
|
49
|
+
const failedTask = makeTask({ status: TaskStatusFailed, error: 'model error' });
|
|
50
|
+
mockJsonResponse({ success: true, data: runningTask });
|
|
51
|
+
mockJsonResponse({ success: true, data: { status: TaskStatusRunning } });
|
|
52
|
+
mockJsonResponse({ success: true, data: { status: TaskStatusFailed } });
|
|
53
|
+
mockJsonResponse({ success: true, data: failedTask });
|
|
54
|
+
await expect(api().run({ app: 'test-app', input: {} }, {}, { wait: true, stream: false })).rejects.toThrow('model error');
|
|
55
|
+
});
|
|
56
|
+
it('should reject when polling detects a cancelled task', async () => {
|
|
57
|
+
const runningTask = makeTask();
|
|
58
|
+
const cancelledTask = makeTask({ status: TaskStatusCancelled });
|
|
59
|
+
mockJsonResponse({ success: true, data: runningTask });
|
|
60
|
+
mockJsonResponse({ success: true, data: { status: TaskStatusRunning } });
|
|
61
|
+
mockJsonResponse({ success: true, data: { status: TaskStatusCancelled } });
|
|
62
|
+
mockJsonResponse({ success: true, data: cancelledTask });
|
|
63
|
+
await expect(api().run({ app: 'test-app', input: {} }, {}, { wait: true, stream: false })).rejects.toThrow('task cancelled');
|
|
64
|
+
});
|
|
65
|
+
it('should parse string terminal statuses from the status endpoint', async () => {
|
|
66
|
+
const runningTask = makeTask();
|
|
67
|
+
const completedTask = makeTask({ status: TaskStatusCompleted });
|
|
68
|
+
mockJsonResponse({ success: true, data: runningTask });
|
|
69
|
+
mockJsonResponse({ success: true, data: { status: TaskStatusRunning } });
|
|
70
|
+
mockJsonResponse({ success: true, data: { status: 'completed' } });
|
|
71
|
+
mockJsonResponse({ success: true, data: completedTask });
|
|
72
|
+
const result = await api().run({ app: 'test-app', input: {} }, {}, { wait: true, stream: false });
|
|
73
|
+
expect(result.status).toBe(TaskStatusCompleted);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
function mockNdjsonStream(chunks) {
|
|
77
|
+
let chunkIndex = 0;
|
|
78
|
+
const mockReader = {
|
|
79
|
+
read: jest.fn().mockImplementation(async () => {
|
|
80
|
+
if (chunkIndex >= chunks.length) {
|
|
81
|
+
return { done: true, value: undefined };
|
|
82
|
+
}
|
|
83
|
+
return { done: false, value: new TextEncoder().encode(chunks[chunkIndex++]) };
|
|
84
|
+
}),
|
|
85
|
+
releaseLock: jest.fn(),
|
|
86
|
+
};
|
|
87
|
+
return {
|
|
88
|
+
ok: true,
|
|
89
|
+
status: 200,
|
|
90
|
+
body: { getReader: () => mockReader },
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
describe('TasksAPI.run (general)', () => {
|
|
94
|
+
beforeEach(() => {
|
|
95
|
+
jest.clearAllMocks();
|
|
96
|
+
});
|
|
97
|
+
const streamingApi = () => new TasksAPI(new HttpClient({ apiKey: 'test-key', stream: true }));
|
|
98
|
+
it('should return immediately when wait is false', async () => {
|
|
99
|
+
const task = makeTask();
|
|
100
|
+
mockJsonResponse({ success: true, data: task });
|
|
101
|
+
const result = await streamingApi().run({ app: 'test-app', input: {} }, { prompt: 'hi' }, { wait: false });
|
|
102
|
+
expect(result.id).toBe('task-1');
|
|
103
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
describe('TasksAPI.run (streaming mode)', () => {
|
|
107
|
+
beforeEach(() => {
|
|
108
|
+
jest.clearAllMocks();
|
|
109
|
+
});
|
|
110
|
+
const api = () => new TasksAPI(new HttpClient({ apiKey: 'test-key', stream: true }));
|
|
111
|
+
function setupStreamMocks(ndjsonChunks, initialTask = makeTask()) {
|
|
112
|
+
mockFetch.mockImplementation((url) => {
|
|
113
|
+
if (url.includes('/apps/run')) {
|
|
114
|
+
return Promise.resolve({
|
|
115
|
+
ok: true,
|
|
116
|
+
status: 200,
|
|
117
|
+
text: () => Promise.resolve(JSON.stringify({ success: true, data: initialTask })),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return Promise.resolve(mockNdjsonStream(ndjsonChunks));
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
it('should resolve when NDJSON stream reports completion', async () => {
|
|
124
|
+
setupStreamMocks([
|
|
125
|
+
`${JSON.stringify({ status: TaskStatusRunning, id: 'task-1' })}\n`,
|
|
126
|
+
`${JSON.stringify({ status: TaskStatusCompleted, id: 'task-1', output: { ok: true } })}\n`,
|
|
127
|
+
]);
|
|
128
|
+
const onUpdate = jest.fn();
|
|
129
|
+
const result = await api().run({ app: 'test-app', input: {} }, { prompt: 'hi' }, { wait: true, onUpdate });
|
|
130
|
+
expect(result.status).toBe(TaskStatusCompleted);
|
|
131
|
+
expect(onUpdate).toHaveBeenCalled();
|
|
132
|
+
});
|
|
133
|
+
it('should reject when NDJSON stream reports failure', async () => {
|
|
134
|
+
setupStreamMocks([
|
|
135
|
+
`${JSON.stringify({ status: TaskStatusFailed, id: 'task-1', error: 'gpu OOM' })}\n`,
|
|
136
|
+
]);
|
|
137
|
+
await expect(api().run({ app: 'test-app', input: {} }, {}, { wait: true })).rejects.toThrow('gpu OOM');
|
|
138
|
+
});
|
|
139
|
+
it('should reject when NDJSON stream reports cancellation', async () => {
|
|
140
|
+
setupStreamMocks([
|
|
141
|
+
`${JSON.stringify({ status: TaskStatusCancelled, id: 'task-1' })}\n`,
|
|
142
|
+
]);
|
|
143
|
+
await expect(api().run({ app: 'test-app', input: {} }, {}, { wait: true })).rejects.toThrow('task cancelled');
|
|
144
|
+
});
|
|
145
|
+
it('should handle partial updates via onPartialUpdate', async () => {
|
|
146
|
+
setupStreamMocks([
|
|
147
|
+
`${JSON.stringify({
|
|
148
|
+
data: { status: TaskStatusCompleted, id: 'task-1', session_id: 'sess-1' },
|
|
149
|
+
fields: ['status'],
|
|
150
|
+
})}\n`,
|
|
151
|
+
]);
|
|
152
|
+
const onPartialUpdate = jest.fn();
|
|
153
|
+
const result = await api().run({ app: 'test-app', input: {} }, {}, { wait: true, onPartialUpdate });
|
|
154
|
+
expect(result.status).toBe(TaskStatusCompleted);
|
|
155
|
+
expect(onPartialUpdate).toHaveBeenCalledWith(expect.objectContaining({ id: 'task-1', status: TaskStatusCompleted }), ['status']);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
+
});
|
package/dist/stream.test.js
CHANGED
|
@@ -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();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { TaskStatusCancelled, TaskStatusCompleted, TaskStatusFailed, TaskStatusRunning, TaskStatusUnknown, } from './types';
|
|
2
|
+
import { isTerminalStatus, parseStatus } from './utils';
|
|
3
|
+
describe('parseStatus', () => {
|
|
4
|
+
it('should return TaskStatusUnknown for null and undefined', () => {
|
|
5
|
+
expect(parseStatus(null)).toBe(TaskStatusUnknown);
|
|
6
|
+
expect(parseStatus(undefined)).toBe(TaskStatusUnknown);
|
|
7
|
+
});
|
|
8
|
+
it('should pass through numeric status values', () => {
|
|
9
|
+
expect(parseStatus(TaskStatusRunning)).toBe(TaskStatusRunning);
|
|
10
|
+
expect(parseStatus(TaskStatusCompleted)).toBe(TaskStatusCompleted);
|
|
11
|
+
});
|
|
12
|
+
it('should map lowercase string status names to TaskStatus', () => {
|
|
13
|
+
expect(parseStatus('running')).toBe(TaskStatusRunning);
|
|
14
|
+
expect(parseStatus('completed')).toBe(TaskStatusCompleted);
|
|
15
|
+
expect(parseStatus('failed')).toBe(TaskStatusFailed);
|
|
16
|
+
expect(parseStatus('cancelled')).toBe(TaskStatusCancelled);
|
|
17
|
+
});
|
|
18
|
+
it('should be case-insensitive for string statuses', () => {
|
|
19
|
+
expect(parseStatus('COMPLETED')).toBe(TaskStatusCompleted);
|
|
20
|
+
expect(parseStatus('Running')).toBe(TaskStatusRunning);
|
|
21
|
+
});
|
|
22
|
+
it('should return TaskStatusUnknown for unrecognized strings', () => {
|
|
23
|
+
expect(parseStatus('not_a_real_status')).toBe(TaskStatusUnknown);
|
|
24
|
+
});
|
|
25
|
+
it('should return TaskStatusUnknown for non-string non-number values', () => {
|
|
26
|
+
expect(parseStatus(true)).toBe(TaskStatusUnknown);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
describe('isTerminalStatus', () => {
|
|
30
|
+
it('should be true for completed, failed, and cancelled (int or string)', () => {
|
|
31
|
+
expect(isTerminalStatus(TaskStatusCompleted)).toBe(true);
|
|
32
|
+
expect(isTerminalStatus(TaskStatusFailed)).toBe(true);
|
|
33
|
+
expect(isTerminalStatus(TaskStatusCancelled)).toBe(true);
|
|
34
|
+
expect(isTerminalStatus('completed')).toBe(true);
|
|
35
|
+
expect(isTerminalStatus('failed')).toBe(true);
|
|
36
|
+
expect(isTerminalStatus('cancelled')).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
it('should be false for non-terminal statuses', () => {
|
|
39
|
+
expect(isTerminalStatus(TaskStatusRunning)).toBe(false);
|
|
40
|
+
expect(isTerminalStatus('running')).toBe(false);
|
|
41
|
+
expect(isTerminalStatus(null)).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
});
|