@inferencesh/sdk 0.5.10 → 0.5.12

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.
@@ -5,7 +5,7 @@
5
5
  * These are created once per provider instance with access to dispatch.
6
6
  */
7
7
  import { ToolInvocationStatusAwaitingInput, ToolTypeClient, ChatStatusBusy, } from '../types';
8
- import { StreamManager } from '../http/stream';
8
+ import { StreamableManager } from '../http/streamable';
9
9
  import { PollManager } from '../http/poll';
10
10
  import { isAdHocConfig, extractClientToolHandlers } from './types';
11
11
  import * as api from './api';
@@ -99,11 +99,10 @@ export function createActions(ctx) {
99
99
  return;
100
100
  }
101
101
  // Single unified stream with TypedEvents (both Chat and ChatMessage events)
102
- const manager = new StreamManager({
103
- createEventSource: () => api.createUnifiedStream(client, id),
104
- autoReconnect: true,
105
- maxReconnects: 3,
106
- reconnectDelayMs: 3000,
102
+ const { url, headers } = api.getChatStreamConfig(client, id);
103
+ const manager = new StreamableManager({
104
+ url,
105
+ headers,
107
106
  onError: (error) => {
108
107
  console.warn('[AgentSDK] Stream error:', error);
109
108
  callbacks.onError?.(error);
@@ -112,7 +111,7 @@ export function createActions(ctx) {
112
111
  dispatch({ type: 'SET_STATUS', payload: 'streaming' });
113
112
  callbacks.onStatusChange?.('streaming');
114
113
  },
115
- onStop: () => {
114
+ onEnd: () => {
116
115
  // Only reset if this is an unexpected stop (stream died, max reconnects exhausted).
117
116
  // If stopStream() was called intentionally, it clears the manager ref first,
118
117
  // so getStreamManager() will be undefined and we skip the duplicate dispatch.
@@ -133,7 +132,7 @@ export function createActions(ctx) {
133
132
  updateMessage(message);
134
133
  });
135
134
  setStreamManager(manager);
136
- manager.connect();
135
+ manager.start();
137
136
  };
138
137
  /** Poll-based alternative to streaming for restricted environments */
139
138
  const pollChat = (id) => {
@@ -69,6 +69,9 @@ export declare function alwaysAllowTool(client: AgentClient, chatId: string, too
69
69
  */
70
70
  export declare function uploadFile(client: AgentClient, file: globalThis.File): Promise<UploadedFile>;
71
71
  /**
72
- * Create a unified stream for chat events
72
+ * Get streamable config for chat streaming (NDJSON)
73
73
  */
74
- export declare function createUnifiedStream(client: AgentClient, chatId: string): Promise<EventSource | null>;
74
+ export declare function getChatStreamConfig(client: AgentClient, chatId: string): {
75
+ url: string;
76
+ headers: Record<string, string>;
77
+ };
package/dist/agent/api.js CHANGED
@@ -204,8 +204,8 @@ export async function uploadFile(client, file) {
204
204
  return result;
205
205
  }
206
206
  /**
207
- * Create a unified stream for chat events
207
+ * Get streamable config for chat streaming (NDJSON)
208
208
  */
209
- export function createUnifiedStream(client, chatId) {
210
- return client.http.createEventSource(`/chats/${chatId}/stream`);
209
+ export function getChatStreamConfig(client, chatId) {
210
+ return client.http.getStreamableConfig(`/chats/${chatId}/stream`);
211
211
  }
@@ -6,7 +6,7 @@
6
6
  import type { Dispatch } from 'react';
7
7
  import type { ChatDTO, ChatMessageDTO, AgentTool, AgentConfig as GeneratedAgentConfig, CoreAppConfig } from '../types';
8
8
  import type { HttpClient } from '../http/client';
9
- import type { StreamManager } from '../http/stream';
9
+ import type { StreamableManager } from '../http/streamable';
10
10
  import type { PollManager } from '../http/poll';
11
11
  /**
12
12
  * Minimal file interface returned by upload (just needs uri and content_type)
@@ -21,7 +21,7 @@ export interface UploadedFile {
21
21
  */
22
22
  export interface AgentClient {
23
23
  /** HTTP client for API requests */
24
- http: Pick<HttpClient, 'request' | 'createEventSource' | 'getStreamDefault' | 'getPollIntervalMs'>;
24
+ http: Pick<HttpClient, 'request' | 'getStreamableConfig' | 'getStreamDefault' | 'getPollIntervalMs'>;
25
25
  /** Files API for uploads */
26
26
  files: {
27
27
  upload: (data: string | Blob | globalThis.File) => Promise<UploadedFile>;
@@ -209,7 +209,7 @@ export type ChatAction = {
209
209
  * Context for action creators
210
210
  */
211
211
  /** Union of manager types used for real-time updates */
212
- export type UpdateManager = StreamManager<unknown> | PollManager<unknown>;
212
+ export type UpdateManager = StreamableManager<unknown> | PollManager<unknown>;
213
213
  export interface ActionsContext {
214
214
  client: AgentClient;
215
215
  dispatch: Dispatch<ChatAction>;
@@ -1,4 +1,4 @@
1
- import { StreamManager } from '../http/stream';
1
+ import { StreamableManager } from '../http/streamable';
2
2
  import { PollManager } from '../http/poll';
3
3
  import { ToolTypeClient, ToolInvocationStatusAwaitingInput, ChatStatusBusy, } from '../types';
4
4
  /**
@@ -144,11 +144,12 @@ export class Agent {
144
144
  streamUntilIdle(options) {
145
145
  if (!this.chatId)
146
146
  return Promise.resolve();
147
+ const { url, headers } = this.http.getStreamableConfig(`/chats/${this.chatId}/stream`);
147
148
  return new Promise((resolve) => {
148
149
  this.stream?.stop();
149
- this.stream = new StreamManager({
150
- createEventSource: async () => this.http.createEventSource(`/chats/${this.chatId}/stream`),
151
- autoReconnect: true,
150
+ this.stream = new StreamableManager({
151
+ url,
152
+ headers,
152
153
  });
153
154
  this.stream.addEventListener('chats', (chat) => {
154
155
  options.onChat?.(chat);
@@ -173,7 +174,7 @@ export class Agent {
173
174
  }
174
175
  }
175
176
  });
176
- this.stream.connect();
177
+ this.stream.start();
177
178
  });
178
179
  }
179
180
  /** Poll until chat becomes idle, dispatching callbacks on changes */
@@ -58,5 +58,9 @@ export declare class AppsAPI {
58
58
  * Save app license
59
59
  */
60
60
  saveLicense(appId: string, license: string): Promise<LicenseRecord>;
61
+ /**
62
+ * Set the current (active) version of an app
63
+ */
64
+ setCurrentVersion(appId: string, versionId: string): Promise<App>;
61
65
  }
62
66
  export declare function createAppsAPI(http: HttpClient): AppsAPI;
package/dist/api/apps.js CHANGED
@@ -83,6 +83,12 @@ export class AppsAPI {
83
83
  async saveLicense(appId, license) {
84
84
  return this.http.request('post', `/apps/${appId}/license`, { data: { license } });
85
85
  }
86
+ /**
87
+ * Set the current (active) version of an app
88
+ */
89
+ async setCurrentVersion(appId, versionId) {
90
+ return this.http.request('post', `/apps/${appId}/current-version`, { data: { version_id: versionId } });
91
+ }
86
92
  }
87
93
  export function createAppsAPI(http) {
88
94
  return new AppsAPI(http);
package/dist/api/tasks.js CHANGED
@@ -1,4 +1,4 @@
1
- import { StreamManager } from '../http/stream';
1
+ import { StreamableManager } from '../http/streamable';
2
2
  import { PollManager } from '../http/poll';
3
3
  import { TaskStatusCompleted, TaskStatusFailed, TaskStatusCancelled, } from '../types';
4
4
  import { parseStatus } from '../utils';
@@ -84,15 +84,14 @@ export class TasksAPI {
84
84
  if (!useStream) {
85
85
  return this.pollUntilTerminal(task, options);
86
86
  }
87
- // Wait for completion with optional updates via SSE
87
+ // Wait for completion with optional updates via NDJSON streaming
88
88
  // Accumulate state across partial updates to preserve fields like session_id
89
89
  let accumulatedTask = { ...task };
90
+ const { url, headers } = this.http.getStreamableConfig(`/tasks/${task.id}/stream`);
90
91
  return new Promise((resolve, reject) => {
91
- const streamManager = new StreamManager({
92
- createEventSource: async () => this.http.createEventSource(`/tasks/${task.id}/stream`),
93
- autoReconnect,
94
- maxReconnects,
95
- reconnectDelayMs,
92
+ const streamManager = new StreamableManager({
93
+ url,
94
+ headers,
96
95
  onData: (data) => {
97
96
  // Merge new data, preserving existing fields if not in update
98
97
  accumulatedTask = { ...accumulatedTask, ...data };
@@ -134,7 +133,7 @@ export class TasksAPI {
134
133
  streamManager.stop();
135
134
  },
136
135
  });
137
- streamManager.connect();
136
+ streamManager.start();
138
137
  });
139
138
  }
140
139
  /** Poll GET /tasks/{id}/status until terminal, full-fetch on status change. */
@@ -76,8 +76,17 @@ export declare class HttpClient {
76
76
  * Execute the actual HTTP request (internal)
77
77
  */
78
78
  private executeRequest;
79
+ /**
80
+ * Get URL and headers for NDJSON streaming.
81
+ * Returns the full URL and auth headers needed for streamable requests.
82
+ */
83
+ getStreamableConfig(endpoint: string): {
84
+ url: string;
85
+ headers: Record<string, string>;
86
+ };
79
87
  /**
80
88
  * Create an EventSource for SSE streaming
89
+ * @deprecated Use getStreamableConfig() with StreamableManager instead
81
90
  */
82
91
  createEventSource(endpoint: string): Promise<EventSource | null>;
83
92
  }
@@ -161,8 +161,33 @@ export class HttpClient {
161
161
  }
162
162
  return apiResponse.data;
163
163
  }
164
+ /**
165
+ * Get URL and headers for NDJSON streaming.
166
+ * Returns the full URL and auth headers needed for streamable requests.
167
+ */
168
+ getStreamableConfig(endpoint) {
169
+ const targetUrl = new URL(`${this.baseUrl}${endpoint}`);
170
+ const isProxyMode = !!this.proxyUrl;
171
+ let url;
172
+ const headers = { ...this.resolveHeaders() };
173
+ if (isProxyMode) {
174
+ const proxyUrlWithQuery = new URL(this.proxyUrl, typeof window !== 'undefined' ? window.location.origin : 'http://localhost');
175
+ proxyUrlWithQuery.searchParams.set('__inf_target', targetUrl.toString());
176
+ url = proxyUrlWithQuery.toString();
177
+ headers['x-inf-target-url'] = targetUrl.toString();
178
+ }
179
+ else {
180
+ url = targetUrl.toString();
181
+ const token = this.getAuthToken();
182
+ if (token) {
183
+ headers['Authorization'] = `Bearer ${token}`;
184
+ }
185
+ }
186
+ return { url, headers };
187
+ }
164
188
  /**
165
189
  * Create an EventSource for SSE streaming
190
+ * @deprecated Use getStreamableConfig() with StreamableManager instead
166
191
  */
167
192
  createEventSource(endpoint) {
168
193
  const targetUrl = new URL(`${this.baseUrl}${endpoint}`);
@@ -1,3 +1,4 @@
1
1
  export { HttpClient, HttpClientConfig, ErrorHandler, createHttpClient } from './client';
2
2
  export { StreamManager, StreamManagerOptions, PartialDataWrapper } from './stream';
3
+ export { streamable, streamableRaw, StreamableManager, StreamableOptions, StreamableMessage, StreamableManagerOptions } from './streamable';
3
4
  export { InferenceError, RequirementsNotMetException } from './errors';
@@ -1,3 +1,4 @@
1
1
  export { HttpClient, createHttpClient } from './client';
2
2
  export { StreamManager } from './stream';
3
+ export { streamable, streamableRaw, StreamableManager } from './streamable';
3
4
  export { InferenceError, RequirementsNotMetException } from './errors';
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Streamable HTTP client using fetch + ReadableStream.
3
+ * This is the NDJSON alternative to SSE/EventSource that avoids
4
+ * the browser's ~6 connection limit per domain.
5
+ */
6
+ export interface StreamableOptions {
7
+ /** Additional headers to include */
8
+ headers?: Record<string, string>;
9
+ /** Request body (will be JSON stringified) */
10
+ body?: unknown;
11
+ /** HTTP method (defaults to GET, or POST if body is provided) */
12
+ method?: 'GET' | 'POST';
13
+ /** AbortSignal for cancellation */
14
+ signal?: AbortSignal;
15
+ /** Skip heartbeat messages (default: true) */
16
+ skipHeartbeats?: boolean;
17
+ }
18
+ export interface StreamableMessage<T = unknown> {
19
+ /** Optional event type */
20
+ event?: string;
21
+ /** Data payload */
22
+ data?: T;
23
+ /** Updated fields (for partial updates) */
24
+ fields?: string[];
25
+ /** Message type (e.g., "heartbeat") */
26
+ type?: string;
27
+ }
28
+ /**
29
+ * Stream NDJSON from an HTTP endpoint using fetch + ReadableStream.
30
+ * Yields parsed JSON objects, automatically filtering heartbeats.
31
+ */
32
+ export declare function streamable<T = unknown>(url: string, options?: StreamableOptions): AsyncGenerator<T>;
33
+ /**
34
+ * StreamableManager provides a callback-based API similar to StreamManager
35
+ * but uses fetch + ReadableStream instead of EventSource.
36
+ */
37
+ export interface StreamableManagerOptions<T> {
38
+ /** URL to connect to */
39
+ url: string;
40
+ /** Additional headers */
41
+ headers?: Record<string, string>;
42
+ /** Request body */
43
+ body?: unknown;
44
+ /** Called for each message */
45
+ onData?: (data: T) => void;
46
+ /** Called for partial updates with fields list */
47
+ onPartialData?: (data: T, fields: string[]) => void;
48
+ /** Called on error */
49
+ onError?: (error: Error) => void;
50
+ /** Called when stream starts */
51
+ onStart?: () => void;
52
+ /** Called when stream ends */
53
+ onEnd?: () => void;
54
+ }
55
+ export declare class StreamableManager<T> {
56
+ private options;
57
+ private abortController;
58
+ private isRunning;
59
+ private eventListeners;
60
+ constructor(options: StreamableManagerOptions<T>);
61
+ /**
62
+ * Add a listener for typed events (e.g., 'chats', 'chat_messages').
63
+ * Used when server sends events with {"event": "eventName", "data": ...} format.
64
+ */
65
+ addEventListener<E = unknown>(eventName: string, callback: (data: E) => void): () => void;
66
+ start(): Promise<void>;
67
+ stop(): void;
68
+ }
69
+ /**
70
+ * Raw streamable that yields the full message including wrapper.
71
+ * Use this when you need access to event type or fields.
72
+ */
73
+ export declare function streamableRaw<T = unknown>(url: string, options?: StreamableOptions): AsyncGenerator<StreamableMessage<T> | T>;
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Streamable HTTP client using fetch + ReadableStream.
3
+ * This is the NDJSON alternative to SSE/EventSource that avoids
4
+ * the browser's ~6 connection limit per domain.
5
+ */
6
+ /**
7
+ * Stream NDJSON from an HTTP endpoint using fetch + ReadableStream.
8
+ * Yields parsed JSON objects, automatically filtering heartbeats.
9
+ */
10
+ export async function* streamable(url, options = {}) {
11
+ const { headers = {}, body, method = body ? 'POST' : 'GET', signal, skipHeartbeats = true, } = options;
12
+ const requestHeaders = {
13
+ Accept: 'application/x-ndjson',
14
+ ...headers,
15
+ };
16
+ if (body) {
17
+ requestHeaders['Content-Type'] = 'application/json';
18
+ }
19
+ const res = await fetch(url, {
20
+ method,
21
+ headers: requestHeaders,
22
+ body: body ? JSON.stringify(body) : undefined,
23
+ signal,
24
+ });
25
+ if (!res.ok) {
26
+ const text = await res.text();
27
+ throw new Error(`HTTP ${res.status}: ${text}`);
28
+ }
29
+ if (!res.body) {
30
+ throw new Error('No response body');
31
+ }
32
+ const reader = res.body.getReader();
33
+ const decoder = new TextDecoder();
34
+ let buffer = '';
35
+ try {
36
+ while (true) {
37
+ const { done, value } = await reader.read();
38
+ if (done)
39
+ break;
40
+ buffer += decoder.decode(value, { stream: true });
41
+ const lines = buffer.split('\n');
42
+ buffer = lines.pop();
43
+ for (const line of lines) {
44
+ if (!line.trim())
45
+ continue;
46
+ const parsed = JSON.parse(line);
47
+ // Skip heartbeats
48
+ if (skipHeartbeats && parsed.type === 'heartbeat') {
49
+ continue;
50
+ }
51
+ // If it's a wrapped message with event/data/fields, yield the data
52
+ // Otherwise yield the entire parsed object
53
+ if ('data' in parsed && parsed.data !== undefined) {
54
+ yield parsed.data;
55
+ }
56
+ else {
57
+ yield parsed;
58
+ }
59
+ }
60
+ }
61
+ // Handle remaining buffer
62
+ if (buffer.trim()) {
63
+ const parsed = JSON.parse(buffer);
64
+ if (!(skipHeartbeats && parsed.type === 'heartbeat')) {
65
+ if ('data' in parsed && parsed.data !== undefined) {
66
+ yield parsed.data;
67
+ }
68
+ else {
69
+ yield parsed;
70
+ }
71
+ }
72
+ }
73
+ }
74
+ finally {
75
+ reader.releaseLock();
76
+ }
77
+ }
78
+ export class StreamableManager {
79
+ constructor(options) {
80
+ this.abortController = null;
81
+ this.isRunning = false;
82
+ this.eventListeners = new Map();
83
+ this.options = options;
84
+ }
85
+ /**
86
+ * Add a listener for typed events (e.g., 'chats', 'chat_messages').
87
+ * Used when server sends events with {"event": "eventName", "data": ...} format.
88
+ */
89
+ addEventListener(eventName, callback) {
90
+ const listeners = this.eventListeners.get(eventName) || new Set();
91
+ listeners.add(callback);
92
+ this.eventListeners.set(eventName, listeners);
93
+ // Return cleanup function
94
+ return () => {
95
+ const listeners = this.eventListeners.get(eventName);
96
+ if (listeners) {
97
+ listeners.delete(callback);
98
+ if (listeners.size === 0) {
99
+ this.eventListeners.delete(eventName);
100
+ }
101
+ }
102
+ };
103
+ }
104
+ async start() {
105
+ if (this.isRunning)
106
+ return;
107
+ this.isRunning = true;
108
+ this.abortController = new AbortController();
109
+ try {
110
+ this.options.onStart?.();
111
+ for await (const message of streamableRaw(this.options.url, {
112
+ headers: this.options.headers,
113
+ body: this.options.body,
114
+ signal: this.abortController.signal,
115
+ })) {
116
+ if (!this.isRunning)
117
+ break;
118
+ const wrapper = message;
119
+ // Handle typed events ({"event": "...", "data": ...})
120
+ if (wrapper.event && this.eventListeners.has(wrapper.event)) {
121
+ const listeners = this.eventListeners.get(wrapper.event);
122
+ const eventData = wrapper.data !== undefined ? wrapper.data : message;
123
+ listeners.forEach(callback => callback(eventData));
124
+ continue;
125
+ }
126
+ // Check if it's a partial update
127
+ if (typeof message === 'object' &&
128
+ message !== null &&
129
+ 'data' in message &&
130
+ 'fields' in message &&
131
+ Array.isArray(wrapper.fields)) {
132
+ if (this.options.onPartialData && wrapper.data !== undefined) {
133
+ this.options.onPartialData(wrapper.data, wrapper.fields);
134
+ }
135
+ else if (wrapper.data !== undefined) {
136
+ this.options.onData?.(wrapper.data);
137
+ }
138
+ }
139
+ else if (wrapper.data !== undefined) {
140
+ // Has data wrapper but no fields - unwrap
141
+ this.options.onData?.(wrapper.data);
142
+ }
143
+ else {
144
+ this.options.onData?.(message);
145
+ }
146
+ }
147
+ }
148
+ catch (err) {
149
+ if (err instanceof Error && err.name === 'AbortError') {
150
+ // Intentional stop, not an error
151
+ }
152
+ else {
153
+ this.options.onError?.(err instanceof Error ? err : new Error(String(err)));
154
+ }
155
+ }
156
+ finally {
157
+ this.isRunning = false;
158
+ this.options.onEnd?.();
159
+ }
160
+ }
161
+ stop() {
162
+ this.isRunning = false;
163
+ this.abortController?.abort();
164
+ this.abortController = null;
165
+ }
166
+ }
167
+ /**
168
+ * Raw streamable that yields the full message including wrapper.
169
+ * Use this when you need access to event type or fields.
170
+ */
171
+ export async function* streamableRaw(url, options = {}) {
172
+ const { headers = {}, body, method = body ? 'POST' : 'GET', signal, skipHeartbeats = true, } = options;
173
+ const requestHeaders = {
174
+ Accept: 'application/x-ndjson',
175
+ ...headers,
176
+ };
177
+ if (body) {
178
+ requestHeaders['Content-Type'] = 'application/json';
179
+ }
180
+ const res = await fetch(url, {
181
+ method,
182
+ headers: requestHeaders,
183
+ body: body ? JSON.stringify(body) : undefined,
184
+ signal,
185
+ });
186
+ if (!res.ok) {
187
+ const text = await res.text();
188
+ throw new Error(`HTTP ${res.status}: ${text}`);
189
+ }
190
+ if (!res.body) {
191
+ throw new Error('No response body');
192
+ }
193
+ const reader = res.body.getReader();
194
+ const decoder = new TextDecoder();
195
+ let buffer = '';
196
+ try {
197
+ while (true) {
198
+ const { done, value } = await reader.read();
199
+ if (done)
200
+ break;
201
+ buffer += decoder.decode(value, { stream: true });
202
+ const lines = buffer.split('\n');
203
+ buffer = lines.pop();
204
+ for (const line of lines) {
205
+ if (!line.trim())
206
+ continue;
207
+ const parsed = JSON.parse(line);
208
+ // Skip heartbeats
209
+ if (skipHeartbeats && parsed.type === 'heartbeat') {
210
+ continue;
211
+ }
212
+ yield parsed;
213
+ }
214
+ }
215
+ // Handle remaining buffer
216
+ if (buffer.trim()) {
217
+ const parsed = JSON.parse(buffer);
218
+ if (!(skipHeartbeats && parsed.type === 'heartbeat')) {
219
+ yield parsed;
220
+ }
221
+ }
222
+ }
223
+ finally {
224
+ reader.releaseLock();
225
+ }
226
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Unit tests for streamable HTTP client
3
+ */
4
+ export {};
@@ -0,0 +1,410 @@
1
+ /**
2
+ * Unit tests for streamable HTTP client
3
+ */
4
+ import { streamable, streamableRaw, StreamableManager } from './streamable';
5
+ // Mock fetch for unit tests
6
+ const mockFetch = (chunks) => {
7
+ let chunkIndex = 0;
8
+ const mockReader = {
9
+ read: jest.fn().mockImplementation(async () => {
10
+ if (chunkIndex >= chunks.length) {
11
+ return { done: true, value: undefined };
12
+ }
13
+ const chunk = chunks[chunkIndex++];
14
+ return { done: false, value: new TextEncoder().encode(chunk) };
15
+ }),
16
+ releaseLock: jest.fn(),
17
+ };
18
+ return jest.fn().mockResolvedValue({
19
+ ok: true,
20
+ status: 200,
21
+ body: {
22
+ getReader: () => mockReader,
23
+ },
24
+ });
25
+ };
26
+ describe('streamable', () => {
27
+ const originalFetch = global.fetch;
28
+ afterEach(() => {
29
+ global.fetch = originalFetch;
30
+ });
31
+ it('should parse NDJSON lines', async () => {
32
+ global.fetch = mockFetch([
33
+ '{"id":1,"name":"first"}\n',
34
+ '{"id":2,"name":"second"}\n',
35
+ ]);
36
+ const results = [];
37
+ for await (const item of streamable('http://test.com/stream')) {
38
+ results.push(item);
39
+ }
40
+ expect(results).toHaveLength(2);
41
+ expect(results[0]).toEqual({ id: 1, name: 'first' });
42
+ expect(results[1]).toEqual({ id: 2, name: 'second' });
43
+ });
44
+ it('should skip heartbeats by default', async () => {
45
+ global.fetch = mockFetch([
46
+ '{"id":1}\n',
47
+ '{"type":"heartbeat"}\n',
48
+ '{"id":2}\n',
49
+ ]);
50
+ const results = [];
51
+ for await (const item of streamable('http://test.com/stream')) {
52
+ results.push(item);
53
+ }
54
+ expect(results).toHaveLength(2);
55
+ expect(results[0]).toEqual({ id: 1 });
56
+ expect(results[1]).toEqual({ id: 2 });
57
+ });
58
+ it('should include heartbeats when skipHeartbeats is false', async () => {
59
+ global.fetch = mockFetch([
60
+ '{"id":1}\n',
61
+ '{"type":"heartbeat"}\n',
62
+ '{"id":2}\n',
63
+ ]);
64
+ const results = [];
65
+ for await (const item of streamable('http://test.com/stream', { skipHeartbeats: false })) {
66
+ results.push(item);
67
+ }
68
+ expect(results).toHaveLength(3);
69
+ });
70
+ it('should unwrap data from wrapped messages', async () => {
71
+ global.fetch = mockFetch([
72
+ '{"data":{"id":1},"fields":["id"]}\n',
73
+ '{"event":"update","data":{"id":2}}\n',
74
+ ]);
75
+ const results = [];
76
+ for await (const item of streamable('http://test.com/stream')) {
77
+ results.push(item);
78
+ }
79
+ expect(results).toHaveLength(2);
80
+ expect(results[0]).toEqual({ id: 1 });
81
+ expect(results[1]).toEqual({ id: 2 });
82
+ });
83
+ it('should handle chunked data across multiple reads', async () => {
84
+ global.fetch = mockFetch([
85
+ '{"id":1}\n{"id":', // First chunk ends mid-JSON
86
+ '2}\n', // Second chunk completes it
87
+ ]);
88
+ const results = [];
89
+ for await (const item of streamable('http://test.com/stream')) {
90
+ results.push(item);
91
+ }
92
+ expect(results).toHaveLength(2);
93
+ expect(results[0]).toEqual({ id: 1 });
94
+ expect(results[1]).toEqual({ id: 2 });
95
+ });
96
+ it('should handle empty lines', async () => {
97
+ global.fetch = mockFetch([
98
+ '{"id":1}\n',
99
+ '\n',
100
+ '{"id":2}\n',
101
+ '\n\n',
102
+ ]);
103
+ const results = [];
104
+ for await (const item of streamable('http://test.com/stream')) {
105
+ results.push(item);
106
+ }
107
+ expect(results).toHaveLength(2);
108
+ });
109
+ it('should set correct headers', async () => {
110
+ const mockFetchFn = mockFetch(['{"ok":true}\n']);
111
+ global.fetch = mockFetchFn;
112
+ const results = [];
113
+ for await (const item of streamable('http://test.com/stream', {
114
+ headers: { 'Authorization': 'Bearer token' },
115
+ })) {
116
+ results.push(item);
117
+ }
118
+ expect(mockFetchFn).toHaveBeenCalledWith('http://test.com/stream', expect.objectContaining({
119
+ method: 'GET',
120
+ headers: expect.objectContaining({
121
+ 'Accept': 'application/x-ndjson',
122
+ 'Authorization': 'Bearer token',
123
+ }),
124
+ }));
125
+ });
126
+ it('should use POST when body is provided', async () => {
127
+ const mockFetchFn = mockFetch(['{"ok":true}\n']);
128
+ global.fetch = mockFetchFn;
129
+ const results = [];
130
+ for await (const item of streamable('http://test.com/stream', {
131
+ body: { query: 'test' },
132
+ })) {
133
+ results.push(item);
134
+ }
135
+ expect(mockFetchFn).toHaveBeenCalledWith('http://test.com/stream', expect.objectContaining({
136
+ method: 'POST',
137
+ headers: expect.objectContaining({
138
+ 'Content-Type': 'application/json',
139
+ }),
140
+ body: JSON.stringify({ query: 'test' }),
141
+ }));
142
+ });
143
+ it('should throw on HTTP error', async () => {
144
+ global.fetch = jest.fn().mockResolvedValue({
145
+ ok: false,
146
+ status: 401,
147
+ text: async () => 'Unauthorized',
148
+ });
149
+ await expect(async () => {
150
+ for await (const _ of streamable('http://test.com/stream')) {
151
+ // consume
152
+ }
153
+ }).rejects.toThrow('HTTP 401: Unauthorized');
154
+ });
155
+ // Edge cases for chunking/buffering
156
+ describe('chunking edge cases', () => {
157
+ it('should handle message split across many chunks', async () => {
158
+ // Split a single JSON object into 5 tiny chunks
159
+ global.fetch = mockFetch([
160
+ '{"id":',
161
+ '123,',
162
+ '"name"',
163
+ ':"test',
164
+ '"}\n',
165
+ ]);
166
+ const results = [];
167
+ for await (const item of streamable('http://test.com/stream')) {
168
+ results.push(item);
169
+ }
170
+ expect(results).toHaveLength(1);
171
+ expect(results[0]).toEqual({ id: 123, name: 'test' });
172
+ });
173
+ it('should handle single-byte chunks', async () => {
174
+ const json = '{"id":1}\n';
175
+ const chunks = json.split('').map(c => c); // One char per chunk
176
+ global.fetch = mockFetch(chunks);
177
+ const results = [];
178
+ for await (const item of streamable('http://test.com/stream')) {
179
+ results.push(item);
180
+ }
181
+ expect(results).toHaveLength(1);
182
+ expect(results[0]).toEqual({ id: 1 });
183
+ });
184
+ it('should handle newline exactly at chunk boundary', async () => {
185
+ global.fetch = mockFetch([
186
+ '{"id":1}',
187
+ '\n',
188
+ '{"id":2}\n',
189
+ ]);
190
+ const results = [];
191
+ for await (const item of streamable('http://test.com/stream')) {
192
+ results.push(item);
193
+ }
194
+ expect(results).toHaveLength(2);
195
+ });
196
+ it('should handle multiple messages in single chunk', async () => {
197
+ global.fetch = mockFetch([
198
+ '{"id":1}\n{"id":2}\n{"id":3}\n',
199
+ ]);
200
+ const results = [];
201
+ for await (const item of streamable('http://test.com/stream')) {
202
+ results.push(item);
203
+ }
204
+ expect(results).toHaveLength(3);
205
+ });
206
+ it('should handle empty chunks', async () => {
207
+ global.fetch = mockFetch([
208
+ '{"id":1}\n',
209
+ '',
210
+ '',
211
+ '{"id":2}\n',
212
+ ]);
213
+ const results = [];
214
+ for await (const item of streamable('http://test.com/stream')) {
215
+ results.push(item);
216
+ }
217
+ expect(results).toHaveLength(2);
218
+ });
219
+ it('should handle trailing data without final newline', async () => {
220
+ global.fetch = mockFetch([
221
+ '{"id":1}\n',
222
+ '{"id":2}', // No trailing newline
223
+ ]);
224
+ const results = [];
225
+ for await (const item of streamable('http://test.com/stream')) {
226
+ results.push(item);
227
+ }
228
+ expect(results).toHaveLength(2);
229
+ });
230
+ });
231
+ describe('unicode handling', () => {
232
+ it('should handle unicode in messages', async () => {
233
+ global.fetch = mockFetch([
234
+ '{"text":"Hello 世界 🎉"}\n',
235
+ ]);
236
+ const results = [];
237
+ for await (const item of streamable('http://test.com/stream')) {
238
+ results.push(item);
239
+ }
240
+ expect(results[0]).toEqual({ text: 'Hello 世界 🎉' });
241
+ });
242
+ it('should handle unicode split across chunks', async () => {
243
+ // 世 is 3 bytes in UTF-8: E4 B8 96
244
+ // Split it across chunks
245
+ const encoder = new TextEncoder();
246
+ const fullText = '{"text":"世"}\n';
247
+ const bytes = encoder.encode(fullText);
248
+ // Create mock that returns raw bytes split mid-character
249
+ let chunkIndex = 0;
250
+ const chunks = [
251
+ bytes.slice(0, 10), // '{"text":"' + first byte of 世
252
+ bytes.slice(10), // rest of 世 + '"}\n'
253
+ ];
254
+ const mockReader = {
255
+ read: jest.fn().mockImplementation(async () => {
256
+ if (chunkIndex >= chunks.length) {
257
+ return { done: true, value: undefined };
258
+ }
259
+ return { done: false, value: chunks[chunkIndex++] };
260
+ }),
261
+ releaseLock: jest.fn(),
262
+ };
263
+ global.fetch = jest.fn().mockResolvedValue({
264
+ ok: true,
265
+ body: { getReader: () => mockReader },
266
+ });
267
+ const results = [];
268
+ for await (const item of streamable('http://test.com/stream')) {
269
+ results.push(item);
270
+ }
271
+ expect(results[0]).toEqual({ text: '世' });
272
+ });
273
+ });
274
+ describe('error handling', () => {
275
+ it('should handle invalid JSON gracefully', async () => {
276
+ global.fetch = mockFetch([
277
+ '{"id":1}\n',
278
+ 'not valid json\n',
279
+ '{"id":2}\n',
280
+ ]);
281
+ const results = [];
282
+ await expect(async () => {
283
+ for await (const item of streamable('http://test.com/stream')) {
284
+ results.push(item);
285
+ }
286
+ }).rejects.toThrow(); // Should throw on invalid JSON
287
+ // First valid message should have been processed
288
+ expect(results).toHaveLength(1);
289
+ });
290
+ it('should handle network error mid-stream', async () => {
291
+ let readCount = 0;
292
+ const mockReader = {
293
+ read: jest.fn().mockImplementation(async () => {
294
+ readCount++;
295
+ if (readCount === 1) {
296
+ return { done: false, value: new TextEncoder().encode('{"id":1}\n') };
297
+ }
298
+ throw new Error('Network error');
299
+ }),
300
+ releaseLock: jest.fn(),
301
+ };
302
+ global.fetch = jest.fn().mockResolvedValue({
303
+ ok: true,
304
+ body: { getReader: () => mockReader },
305
+ });
306
+ const results = [];
307
+ await expect(async () => {
308
+ for await (const item of streamable('http://test.com/stream')) {
309
+ results.push(item);
310
+ }
311
+ }).rejects.toThrow('Network error');
312
+ expect(results).toHaveLength(1);
313
+ });
314
+ });
315
+ });
316
+ describe('streamableRaw', () => {
317
+ const originalFetch = global.fetch;
318
+ afterEach(() => {
319
+ global.fetch = originalFetch;
320
+ });
321
+ it('should preserve event and fields in raw mode', async () => {
322
+ global.fetch = mockFetch([
323
+ '{"event":"update","data":{"id":1},"fields":["id"]}\n',
324
+ ]);
325
+ const results = [];
326
+ for await (const item of streamableRaw('http://test.com/stream')) {
327
+ results.push(item);
328
+ }
329
+ expect(results).toHaveLength(1);
330
+ expect(results[0]).toEqual({
331
+ event: 'update',
332
+ data: { id: 1 },
333
+ fields: ['id'],
334
+ });
335
+ });
336
+ });
337
+ describe('StreamableManager', () => {
338
+ const originalFetch = global.fetch;
339
+ afterEach(() => {
340
+ global.fetch = originalFetch;
341
+ });
342
+ it('should call onData for each message', async () => {
343
+ global.fetch = mockFetch([
344
+ '{"id":1}\n',
345
+ '{"id":2}\n',
346
+ ]);
347
+ const messages = [];
348
+ const manager = new StreamableManager({
349
+ url: 'http://test.com/stream',
350
+ onData: (data) => messages.push(data),
351
+ });
352
+ await manager.start();
353
+ expect(messages).toHaveLength(2);
354
+ expect(messages[0]).toEqual({ id: 1 });
355
+ expect(messages[1]).toEqual({ id: 2 });
356
+ });
357
+ it('should call onPartialData for partial updates', async () => {
358
+ global.fetch = mockFetch([
359
+ '{"data":{"id":1},"fields":["id"]}\n',
360
+ ]);
361
+ const partials = [];
362
+ const manager = new StreamableManager({
363
+ url: 'http://test.com/stream',
364
+ onPartialData: (data, fields) => partials.push({ data, fields }),
365
+ });
366
+ await manager.start();
367
+ expect(partials).toHaveLength(1);
368
+ expect(partials[0]).toEqual({ data: { id: 1 }, fields: ['id'] });
369
+ });
370
+ it('should call lifecycle callbacks', async () => {
371
+ global.fetch = mockFetch(['{"ok":true}\n']);
372
+ const events = [];
373
+ const manager = new StreamableManager({
374
+ url: 'http://test.com/stream',
375
+ onStart: () => events.push('start'),
376
+ onEnd: () => events.push('end'),
377
+ onData: () => events.push('data'),
378
+ });
379
+ await manager.start();
380
+ expect(events).toEqual(['start', 'data', 'end']);
381
+ });
382
+ it('should handle stop()', async () => {
383
+ let readCount = 0;
384
+ const mockReader = {
385
+ read: jest.fn().mockImplementation(async () => {
386
+ readCount++;
387
+ if (readCount > 10) {
388
+ return { done: true, value: undefined };
389
+ }
390
+ await new Promise(resolve => setTimeout(resolve, 10));
391
+ return { done: false, value: new TextEncoder().encode('{"id":1}\n') };
392
+ }),
393
+ releaseLock: jest.fn(),
394
+ };
395
+ global.fetch = jest.fn().mockResolvedValue({
396
+ ok: true,
397
+ body: { getReader: () => mockReader },
398
+ });
399
+ const manager = new StreamableManager({
400
+ url: 'http://test.com/stream',
401
+ onData: () => { },
402
+ });
403
+ // Start and immediately stop
404
+ const startPromise = manager.start();
405
+ setTimeout(() => manager.stop(), 25);
406
+ await startPromise;
407
+ // Should have stopped before reading all 10
408
+ expect(readCount).toBeLessThan(10);
409
+ });
410
+ });
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { HttpClient, HttpClientConfig, ErrorHandler, createHttpClient } from './http/client';
2
2
  export { StreamManager, StreamManagerOptions, PartialDataWrapper } from './http/stream';
3
+ export { StreamableManager, StreamableManagerOptions, StreamableMessage, streamable, streamableRaw } from './http/streamable';
3
4
  export { PollManager, PollManagerOptions } from './http/poll';
4
5
  export { InferenceError, RequirementsNotMetException, SessionError, SessionNotFoundError, SessionExpiredError, SessionEndedError, WorkerLostError, isRequirementsNotMetException, isInferenceError, isSessionError, } from './http/errors';
5
6
  export { TasksAPI, RunOptions } from './api/tasks';
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // HTTP utilities
2
2
  export { HttpClient, createHttpClient } from './http/client';
3
3
  export { StreamManager } from './http/stream';
4
+ export { StreamableManager, streamable, streamableRaw } from './http/streamable';
4
5
  export { PollManager } from './http/poll';
5
6
  export { InferenceError, RequirementsNotMetException, SessionError, SessionNotFoundError, SessionExpiredError, SessionEndedError, WorkerLostError,
6
7
  // Type guards (use these instead of instanceof for reliability)
@@ -40,6 +40,7 @@ export function createHandler(options) {
40
40
  // Handle streaming responses
41
41
  const contentType = response.headers.get("content-type");
42
42
  if (contentType?.includes("text/event-stream") ||
43
+ contentType?.includes("application/x-ndjson") ||
43
44
  contentType?.includes("application/octet-stream")) {
44
45
  if (response.body) {
45
46
  const reader = response.body.getReader();
@@ -110,12 +110,13 @@ export async function processProxyRequest(adapter, options) {
110
110
  const userAgent = firstValue(adapter.header("user-agent"));
111
111
  const proxyId = `@inferencesh/sdk-proxy/${adapter.framework}`;
112
112
  // 6. Make upstream request
113
+ const accept = firstValue(adapter.header("accept"));
113
114
  const response = await fetch(targetUrl, {
114
115
  method: adapter.method,
115
116
  headers: {
116
117
  ...forwardHeaders,
117
118
  authorization: firstValue(adapter.header("authorization")) ?? `Bearer ${apiKey}`,
118
- accept: "application/json",
119
+ accept: accept || "application/json",
119
120
  "content-type": contentType || "application/json",
120
121
  "user-agent": userAgent || proxyId,
121
122
  "x-inf-proxy": proxyId,
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Integration tests for streamable HTTP client
3
+ *
4
+ * Tests NDJSON streaming against the real API.
5
+ * Run with: INFERENCE_API_KEY=xxx npm run test:integration
6
+ *
7
+ * Note: Format negotiation tests may be skipped if server doesn't support NDJSON yet.
8
+ *
9
+ * @jest-environment node
10
+ */
11
+ export {};
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Integration tests for streamable HTTP client
3
+ *
4
+ * Tests NDJSON streaming against the real API.
5
+ * Run with: INFERENCE_API_KEY=xxx npm run test:integration
6
+ *
7
+ * Note: Format negotiation tests may be skipped if server doesn't support NDJSON yet.
8
+ *
9
+ * @jest-environment node
10
+ */
11
+ import { streamable, StreamableManager } from './http/streamable';
12
+ import { Inference, TaskStatusCompleted, TaskStatusFailed, TaskStatusCancelled } from './index';
13
+ const isTerminalStatus = (status) => status === TaskStatusCompleted || status === TaskStatusFailed || status === TaskStatusCancelled;
14
+ const API_KEY = process.env.INFERENCE_API_KEY;
15
+ const BASE_URL = process.env.INFERENCE_BASE_URL || 'https://api.inference.sh';
16
+ // Simple test app that completes quickly
17
+ const TEST_APP = 'infsh/text-templating@53bk0yzk';
18
+ const describeIfApiKey = API_KEY ? describe : describe.skip;
19
+ describeIfApiKey('Streamable Integration Tests', () => {
20
+ let client;
21
+ beforeAll(() => {
22
+ client = new Inference({
23
+ apiKey: API_KEY,
24
+ baseUrl: BASE_URL,
25
+ });
26
+ });
27
+ describe('Format Negotiation', () => {
28
+ it('should negotiate format based on Accept header', async () => {
29
+ // Create a task using the SDK
30
+ const task = await client.run({ app: TEST_APP, input: { template: 'Format {1}!', strings: ['Test'] } }, { wait: false });
31
+ // Test NDJSON request
32
+ const ndjsonRes = await fetch(`${BASE_URL}/tasks/${task.id}/stream`, {
33
+ headers: {
34
+ 'Authorization': `Bearer ${API_KEY}`,
35
+ 'Accept': 'application/x-ndjson',
36
+ },
37
+ });
38
+ expect(ndjsonRes.ok).toBe(true);
39
+ const ndjsonContentType = ndjsonRes.headers.get('content-type');
40
+ // Test SSE request
41
+ const sseRes = await fetch(`${BASE_URL}/tasks/${task.id}/stream`, {
42
+ headers: {
43
+ 'Authorization': `Bearer ${API_KEY}`,
44
+ 'Accept': 'text/event-stream',
45
+ },
46
+ });
47
+ expect(sseRes.ok).toBe(true);
48
+ const sseContentType = sseRes.headers.get('content-type');
49
+ // Log what we got for debugging
50
+ console.log('NDJSON request Content-Type:', ndjsonContentType);
51
+ console.log('SSE request Content-Type:', sseContentType);
52
+ // At minimum, SSE should work
53
+ expect(sseContentType).toContain('text/event-stream');
54
+ // If NDJSON is supported, it should return NDJSON content-type
55
+ // Otherwise it falls back to SSE (server not yet updated)
56
+ const supportsNDJSON = ndjsonContentType?.includes('application/x-ndjson');
57
+ if (supportsNDJSON) {
58
+ console.log('✓ Server supports NDJSON format negotiation');
59
+ }
60
+ else {
61
+ console.log('⚠ Server does not yet support NDJSON, falling back to SSE');
62
+ }
63
+ // Clean up - consume the streams
64
+ for (const res of [ndjsonRes, sseRes]) {
65
+ const reader = res.body?.getReader();
66
+ if (reader) {
67
+ while (true) {
68
+ const { done } = await reader.read();
69
+ if (done)
70
+ break;
71
+ }
72
+ }
73
+ }
74
+ }, 60000);
75
+ });
76
+ describe('streamable() with NDJSON server', () => {
77
+ // These tests require the server to support NDJSON
78
+ // Skip if server doesn't support it yet
79
+ it('should stream task updates when server supports NDJSON', async () => {
80
+ // Create a task using the SDK (fire and forget)
81
+ const task = await client.run({ app: TEST_APP, input: { template: 'Hello {1}!', strings: ['Streamable'] } }, { wait: false });
82
+ expect(task.id).toBeDefined();
83
+ // Stream updates using streamable - will request NDJSON format
84
+ const updates = [];
85
+ try {
86
+ for await (const update of streamable(`${BASE_URL}/tasks/${task.id}/stream`, {
87
+ headers: { 'Authorization': `Bearer ${API_KEY}` },
88
+ })) {
89
+ updates.push(update);
90
+ // Stop when task reaches terminal status
91
+ if (isTerminalStatus(update.status))
92
+ break;
93
+ }
94
+ }
95
+ catch (err) {
96
+ // If server doesn't support NDJSON, streamable will fail to parse SSE
97
+ console.log('⚠ Skipping - server returned non-NDJSON:', err.message);
98
+ return;
99
+ }
100
+ expect(updates.length).toBeGreaterThan(0);
101
+ // Last update should be terminal
102
+ const lastUpdate = updates[updates.length - 1];
103
+ expect(isTerminalStatus(lastUpdate.status)).toBe(true);
104
+ }, 60000);
105
+ });
106
+ describe('StreamableManager with NDJSON server', () => {
107
+ it('should receive updates via callbacks when server supports NDJSON', async () => {
108
+ // Create a task using the SDK
109
+ const task = await client.run({ app: TEST_APP, input: { template: 'Manager {1}!', strings: ['Test'] } }, { wait: false });
110
+ const updates = [];
111
+ let started = false;
112
+ let ended = false;
113
+ let error = null;
114
+ const manager = new StreamableManager({
115
+ url: `${BASE_URL}/tasks/${task.id}/stream`,
116
+ headers: { 'Authorization': `Bearer ${API_KEY}` },
117
+ onStart: () => { started = true; },
118
+ onEnd: () => { ended = true; },
119
+ onData: (data) => {
120
+ updates.push(data);
121
+ // Stop when terminal
122
+ if (isTerminalStatus(data.status)) {
123
+ manager.stop();
124
+ }
125
+ },
126
+ onError: (err) => {
127
+ error = err;
128
+ },
129
+ });
130
+ await manager.start();
131
+ // If server doesn't support NDJSON, we'll get a parse error
132
+ if (error) {
133
+ console.log('⚠ Skipping StreamableManager test - server returned non-NDJSON');
134
+ return;
135
+ }
136
+ expect(started).toBe(true);
137
+ expect(ended).toBe(true);
138
+ expect(updates.length).toBeGreaterThan(0);
139
+ }, 60000);
140
+ });
141
+ });
142
+ // Ensure Jest doesn't complain when API key is not set
143
+ describe('Streamable Integration Setup', () => {
144
+ it('checks for API key', () => {
145
+ if (!API_KEY) {
146
+ console.log('⚠️ Skipping streamable integration tests - INFERENCE_API_KEY not set');
147
+ }
148
+ expect(true).toBe(true);
149
+ });
150
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inferencesh/sdk",
3
- "version": "0.5.10",
3
+ "version": "0.5.12",
4
4
  "description": "Official JavaScript/TypeScript SDK for inference.sh - Run AI models with a simple API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",