@ruska/cli 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/chat.js +8 -7
- package/dist/hooks/use-stream.d.ts +2 -1
- package/dist/hooks/use-stream.js +20 -1
- package/dist/lib/output/error-handler.d.ts +2 -0
- package/dist/lib/output/error-handler.js +30 -0
- package/dist/lib/services/stream-service.d.ts +48 -4
- package/dist/lib/services/stream-service.js +255 -7
- package/dist/types/output.d.ts +1 -1
- package/dist/types/stream.d.ts +42 -1
- package/dist/types/stream.js +37 -0
- package/package.json +1 -1
package/dist/commands/chat.js
CHANGED
|
@@ -116,7 +116,7 @@ function ChatCommandTui({ message, assistantId, threadId, tools, truncateOptions
|
|
|
116
116
|
: undefined, [config, assistantId, message, threadId, tools]);
|
|
117
117
|
/* eslint-enable @typescript-eslint/naming-convention */
|
|
118
118
|
// Stream
|
|
119
|
-
const { status, messages, events, error } = useStream(config, request);
|
|
119
|
+
const { status, messages, events, streamMetadata, error } = useStream(config, request);
|
|
120
120
|
// Group messages into blocks by type + name boundaries
|
|
121
121
|
const messageBlocks = useMemo(() => groupMessagesIntoBlocks(messages), [messages]);
|
|
122
122
|
// Exit on completion
|
|
@@ -161,16 +161,17 @@ function ChatCommandTui({ message, assistantId, threadId, tools, truncateOptions
|
|
|
161
161
|
})()))) : (React.createElement(Text, null, block.content))))),
|
|
162
162
|
status === 'done' && (React.createElement(Box, { marginTop: 1, flexDirection: "column" },
|
|
163
163
|
React.createElement(Text, { color: "green" }, "Done"),
|
|
164
|
-
request?.metadata?.thread_id && (React.createElement(Text, { dimColor: true },
|
|
165
|
-
"Thread: ",
|
|
166
|
-
request.metadata.thread_id)),
|
|
167
164
|
extractModelFromEvents(events) && (React.createElement(Text, { dimColor: true },
|
|
168
165
|
"Model: ",
|
|
169
166
|
extractModelFromEvents(events))),
|
|
170
|
-
|
|
171
|
-
"
|
|
167
|
+
streamMetadata?.thread_id && (React.createElement(Text, { dimColor: true },
|
|
168
|
+
"Thread: ",
|
|
169
|
+
streamMetadata.thread_id)),
|
|
170
|
+
streamMetadata?.thread_id && (React.createElement(Text, { dimColor: true },
|
|
171
|
+
"Continue: ruska chat -t ",
|
|
172
|
+
streamMetadata.thread_id,
|
|
172
173
|
' ',
|
|
173
|
-
|
|
174
|
+
"\"message\""))))));
|
|
174
175
|
}
|
|
175
176
|
/**
|
|
176
177
|
* JSON mode chat command - direct streaming without React hooks
|
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
* From Beta proposal with critical values event handling
|
|
4
4
|
*/
|
|
5
5
|
import type { Config } from '../types/index.js';
|
|
6
|
-
import { type MessagePayload, type StreamRequest, type StreamEvent, type ValuesPayload } from '../types/stream.js';
|
|
6
|
+
import { type MessagePayload, type MetadataPayload, type StreamRequest, type StreamEvent, type ValuesPayload } from '../types/stream.js';
|
|
7
7
|
export type StreamStatus = 'idle' | 'connecting' | 'streaming' | 'done' | 'error';
|
|
8
8
|
export type UseStreamResult = {
|
|
9
9
|
status: StreamStatus;
|
|
10
10
|
events: StreamEvent[];
|
|
11
11
|
messages: MessagePayload[];
|
|
12
12
|
finalResponse: ValuesPayload | undefined;
|
|
13
|
+
streamMetadata: MetadataPayload | undefined;
|
|
13
14
|
error: string | undefined;
|
|
14
15
|
errorCode: number | undefined;
|
|
15
16
|
abort: () => void;
|
package/dist/hooks/use-stream.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* From Beta proposal with critical values event handling
|
|
4
4
|
*/
|
|
5
5
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
6
|
-
import { StreamService, StreamConnectionError, } from '../lib/services/stream-service.js';
|
|
6
|
+
import { StreamService, StreamConnectionError, DistributedStreamError, MalformedDistributedResponse, } from '../lib/services/stream-service.js';
|
|
7
7
|
/**
|
|
8
8
|
* React hook for consuming LLM streams.
|
|
9
9
|
* CRITICAL: Captures "values" event payload as finalResponse
|
|
@@ -13,6 +13,7 @@ export function useStream(config, request) {
|
|
|
13
13
|
const [events, setEvents] = useState([]);
|
|
14
14
|
const [messages, setMessages] = useState([]);
|
|
15
15
|
const [finalResponse, setFinalResponse] = useState();
|
|
16
|
+
const [streamMetadata, setStreamMetadata] = useState();
|
|
16
17
|
const [error, setError] = useState();
|
|
17
18
|
const [errorCode, setErrorCode] = useState();
|
|
18
19
|
const handleReference = useRef();
|
|
@@ -29,6 +30,7 @@ export function useStream(config, request) {
|
|
|
29
30
|
setEvents([]);
|
|
30
31
|
setMessages([]);
|
|
31
32
|
setFinalResponse(undefined);
|
|
33
|
+
setStreamMetadata(undefined);
|
|
32
34
|
setError(undefined);
|
|
33
35
|
setErrorCode(undefined);
|
|
34
36
|
try {
|
|
@@ -58,11 +60,21 @@ export function useStream(config, request) {
|
|
|
58
60
|
setFinalResponse(event.payload);
|
|
59
61
|
break;
|
|
60
62
|
}
|
|
63
|
+
case 'metadata': {
|
|
64
|
+
// Capture stream metadata (thread_id, etc.)
|
|
65
|
+
setStreamMetadata(event.payload);
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
61
68
|
case 'error': {
|
|
62
69
|
setError(event.payload.message);
|
|
63
70
|
setStatus('error');
|
|
64
71
|
return;
|
|
65
72
|
}
|
|
73
|
+
case 'done': {
|
|
74
|
+
// Terminal event from distributed mode
|
|
75
|
+
// Stream will end naturally after this
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
66
78
|
default: {
|
|
67
79
|
// Unknown event type - ignore
|
|
68
80
|
break;
|
|
@@ -77,6 +89,12 @@ export function useStream(config, request) {
|
|
|
77
89
|
setError(error_.message);
|
|
78
90
|
setErrorCode(error_.statusCode);
|
|
79
91
|
}
|
|
92
|
+
else if (error_ instanceof DistributedStreamError) {
|
|
93
|
+
setError(`Distributed error (${error_.phase}): ${error_.message}`);
|
|
94
|
+
}
|
|
95
|
+
else if (error_ instanceof MalformedDistributedResponse) {
|
|
96
|
+
setError(`Invalid distributed response: ${error_.message}`);
|
|
97
|
+
}
|
|
80
98
|
else if (error_ instanceof Error) {
|
|
81
99
|
setError(error_.message);
|
|
82
100
|
}
|
|
@@ -98,6 +116,7 @@ export function useStream(config, request) {
|
|
|
98
116
|
events,
|
|
99
117
|
messages,
|
|
100
118
|
finalResponse,
|
|
119
|
+
streamMetadata,
|
|
101
120
|
error,
|
|
102
121
|
errorCode,
|
|
103
122
|
abort,
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Error handler with taxonomy for CLI output
|
|
3
3
|
* From Gamma proposal with structured error codes
|
|
4
4
|
*/
|
|
5
|
+
import { DistributedStreamError, MalformedDistributedResponse, } from '../services/stream-service.js';
|
|
5
6
|
/**
|
|
6
7
|
* Exit codes for scripting
|
|
7
8
|
*/
|
|
@@ -12,6 +13,8 @@ export const exitCodes = {
|
|
|
12
13
|
rateLimited: 3,
|
|
13
14
|
timeout: 4,
|
|
14
15
|
serverError: 5,
|
|
16
|
+
distributedError: 6,
|
|
17
|
+
distributedTimeout: 7,
|
|
15
18
|
};
|
|
16
19
|
/**
|
|
17
20
|
* Map raw errors to structured error responses
|
|
@@ -45,6 +48,33 @@ export function classifyError(error, statusCode) {
|
|
|
45
48
|
}
|
|
46
49
|
}
|
|
47
50
|
if (error instanceof Error) {
|
|
51
|
+
// Handle distributed mode specific errors
|
|
52
|
+
if (error instanceof DistributedStreamError) {
|
|
53
|
+
// Check if it's a timeout during distributed streaming
|
|
54
|
+
if (error.message.includes('timeout') ||
|
|
55
|
+
error.message.includes('aborted')) {
|
|
56
|
+
return {
|
|
57
|
+
code: 'DISTRIBUTED_TIMEOUT',
|
|
58
|
+
message: `Distributed stream timed out during ${error.phase} phase.`,
|
|
59
|
+
recoverable: true,
|
|
60
|
+
exitCode: exitCodes.distributedTimeout,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
code: 'DISTRIBUTED_ERROR',
|
|
65
|
+
message: `Distributed stream error (${error.phase}): ${error.message}`,
|
|
66
|
+
recoverable: error.phase === 'handshake',
|
|
67
|
+
exitCode: exitCodes.distributedError,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
if (error instanceof MalformedDistributedResponse) {
|
|
71
|
+
return {
|
|
72
|
+
code: 'DISTRIBUTED_ERROR',
|
|
73
|
+
message: `Invalid distributed response: ${error.message}`,
|
|
74
|
+
recoverable: false,
|
|
75
|
+
exitCode: exitCodes.distributedError,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
48
78
|
const errorMessage = error.message.toLowerCase();
|
|
49
79
|
// Network errors
|
|
50
80
|
if (errorMessage.includes('fetch failed') ||
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Stream service implementation for SSE consumption
|
|
3
|
-
*
|
|
3
|
+
* Supports both sync mode (HTTP 200) and distributed worker mode (HTTP 202)
|
|
4
|
+
* @see backend/src/routes/v0/llm.py - distributed mode detection
|
|
5
|
+
* @see backend/src/routes/v0/thread.py - thread stream endpoint
|
|
4
6
|
*/
|
|
5
7
|
import type { Config } from '../../types/index.js';
|
|
6
|
-
import type
|
|
8
|
+
import { type StreamRequest, type StreamHandle } from '../../types/stream.js';
|
|
7
9
|
import type { StreamServiceInterface } from './stream-service.interface.js';
|
|
8
10
|
/**
|
|
9
11
|
* Custom error for stream connection failures
|
|
@@ -12,16 +14,58 @@ export declare class StreamConnectionError extends Error {
|
|
|
12
14
|
readonly statusCode: number;
|
|
13
15
|
constructor(message: string, statusCode: number);
|
|
14
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Error specific to distributed streaming mode
|
|
19
|
+
* Includes phase information for debugging
|
|
20
|
+
*/
|
|
21
|
+
export declare class DistributedStreamError extends Error {
|
|
22
|
+
readonly phase: 'handshake' | 'streaming';
|
|
23
|
+
readonly rawResponse?: string | undefined;
|
|
24
|
+
constructor(message: string, phase: 'handshake' | 'streaming', rawResponse?: string | undefined);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Error for malformed distributed response from initial POST
|
|
28
|
+
*/
|
|
29
|
+
export declare class MalformedDistributedResponse extends Error {
|
|
30
|
+
readonly rawResponse?: string | undefined;
|
|
31
|
+
constructor(message: string, rawResponse?: string | undefined);
|
|
32
|
+
}
|
|
15
33
|
/**
|
|
16
34
|
* Production stream service using SSE
|
|
17
35
|
*/
|
|
18
36
|
export declare class StreamService implements StreamServiceInterface {
|
|
19
37
|
private readonly host;
|
|
20
38
|
private readonly apiKey;
|
|
21
|
-
private readonly
|
|
22
|
-
|
|
39
|
+
private readonly connectionTimeoutMs;
|
|
40
|
+
private readonly streamIdleTimeoutMs;
|
|
41
|
+
constructor(config: Config, connectionTimeoutMs?: number, streamIdleTimeoutMs?: number);
|
|
23
42
|
connect(request: StreamRequest): Promise<StreamHandle>;
|
|
24
43
|
private fetchStream;
|
|
44
|
+
/**
|
|
45
|
+
* Handle distributed worker mode (HTTP 202 response)
|
|
46
|
+
* Parses the initial response and connects to thread stream endpoint
|
|
47
|
+
*/
|
|
48
|
+
private handleDistributedMode;
|
|
49
|
+
/**
|
|
50
|
+
* Connect to GET /api/threads/{thread_id}/stream for distributed mode
|
|
51
|
+
* URL-encodes thread_id to prevent path injection
|
|
52
|
+
*/
|
|
53
|
+
private connectToThreadStream;
|
|
54
|
+
/**
|
|
55
|
+
* Parse SSE events from distributed worker stream
|
|
56
|
+
* Handles [DONE] marker, keep-alive comments, and error JSON
|
|
57
|
+
*/
|
|
58
|
+
private parseDistributedEventStream;
|
|
59
|
+
/**
|
|
60
|
+
* Extract events from distributed stream buffer
|
|
61
|
+
* Handles keep-alive comments, [DONE] marker, and error JSON
|
|
62
|
+
*/
|
|
63
|
+
private extractDistributedEvents;
|
|
64
|
+
/**
|
|
65
|
+
* Parse a single distributed event data payload
|
|
66
|
+
* Handles error JSON format and regular event format
|
|
67
|
+
*/
|
|
68
|
+
private parseDistributedEvent;
|
|
25
69
|
private parseEventStream;
|
|
26
70
|
private extractEvents;
|
|
27
71
|
private parseEvent;
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Stream service implementation for SSE consumption
|
|
3
|
-
*
|
|
3
|
+
* Supports both sync mode (HTTP 200) and distributed worker mode (HTTP 202)
|
|
4
|
+
* @see backend/src/routes/v0/llm.py - distributed mode detection
|
|
5
|
+
* @see backend/src/routes/v0/thread.py - thread stream endpoint
|
|
4
6
|
*/
|
|
5
|
-
|
|
7
|
+
import { isDistributedResponse, isDistributedError, STREAM_DONE_MARKER, } from '../../types/stream.js';
|
|
8
|
+
const defaultConnectionTimeoutMs = 30000;
|
|
9
|
+
const defaultStreamIdleTimeoutMs = 60000;
|
|
6
10
|
/**
|
|
7
11
|
* Custom error for stream connection failures
|
|
8
12
|
*/
|
|
@@ -18,11 +22,48 @@ export class StreamConnectionError extends Error {
|
|
|
18
22
|
this.name = 'StreamConnectionError';
|
|
19
23
|
}
|
|
20
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* Error specific to distributed streaming mode
|
|
27
|
+
* Includes phase information for debugging
|
|
28
|
+
*/
|
|
29
|
+
export class DistributedStreamError extends Error {
|
|
30
|
+
constructor(message, phase, rawResponse) {
|
|
31
|
+
super(message);
|
|
32
|
+
Object.defineProperty(this, "phase", {
|
|
33
|
+
enumerable: true,
|
|
34
|
+
configurable: true,
|
|
35
|
+
writable: true,
|
|
36
|
+
value: phase
|
|
37
|
+
});
|
|
38
|
+
Object.defineProperty(this, "rawResponse", {
|
|
39
|
+
enumerable: true,
|
|
40
|
+
configurable: true,
|
|
41
|
+
writable: true,
|
|
42
|
+
value: rawResponse
|
|
43
|
+
});
|
|
44
|
+
this.name = 'DistributedStreamError';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Error for malformed distributed response from initial POST
|
|
49
|
+
*/
|
|
50
|
+
export class MalformedDistributedResponse extends Error {
|
|
51
|
+
constructor(message, rawResponse) {
|
|
52
|
+
super(message);
|
|
53
|
+
Object.defineProperty(this, "rawResponse", {
|
|
54
|
+
enumerable: true,
|
|
55
|
+
configurable: true,
|
|
56
|
+
writable: true,
|
|
57
|
+
value: rawResponse
|
|
58
|
+
});
|
|
59
|
+
this.name = 'MalformedDistributedResponse';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
21
62
|
/**
|
|
22
63
|
* Production stream service using SSE
|
|
23
64
|
*/
|
|
24
65
|
export class StreamService {
|
|
25
|
-
constructor(config,
|
|
66
|
+
constructor(config, connectionTimeoutMs = defaultConnectionTimeoutMs, streamIdleTimeoutMs = defaultStreamIdleTimeoutMs) {
|
|
26
67
|
Object.defineProperty(this, "host", {
|
|
27
68
|
enumerable: true,
|
|
28
69
|
configurable: true,
|
|
@@ -35,7 +76,13 @@ export class StreamService {
|
|
|
35
76
|
writable: true,
|
|
36
77
|
value: void 0
|
|
37
78
|
});
|
|
38
|
-
Object.defineProperty(this, "
|
|
79
|
+
Object.defineProperty(this, "connectionTimeoutMs", {
|
|
80
|
+
enumerable: true,
|
|
81
|
+
configurable: true,
|
|
82
|
+
writable: true,
|
|
83
|
+
value: void 0
|
|
84
|
+
});
|
|
85
|
+
Object.defineProperty(this, "streamIdleTimeoutMs", {
|
|
39
86
|
enumerable: true,
|
|
40
87
|
configurable: true,
|
|
41
88
|
writable: true,
|
|
@@ -43,17 +90,23 @@ export class StreamService {
|
|
|
43
90
|
});
|
|
44
91
|
this.host = config.host.replace(/\/$/, '');
|
|
45
92
|
this.apiKey = config.apiKey;
|
|
46
|
-
this.
|
|
93
|
+
this.connectionTimeoutMs = connectionTimeoutMs;
|
|
94
|
+
this.streamIdleTimeoutMs = streamIdleTimeoutMs;
|
|
47
95
|
}
|
|
48
96
|
async connect(request) {
|
|
49
97
|
const controller = new AbortController();
|
|
50
98
|
// Set timeout for connection
|
|
51
99
|
const timeoutId = setTimeout(() => {
|
|
52
100
|
controller.abort();
|
|
53
|
-
}, this.
|
|
101
|
+
}, this.connectionTimeoutMs);
|
|
54
102
|
try {
|
|
55
103
|
const response = await this.fetchStream(request, controller.signal);
|
|
56
104
|
clearTimeout(timeoutId);
|
|
105
|
+
// Check for distributed mode (HTTP 202)
|
|
106
|
+
if (response.status === 202) {
|
|
107
|
+
return await this.handleDistributedMode(response, controller);
|
|
108
|
+
}
|
|
109
|
+
// Sync mode (HTTP 200)
|
|
57
110
|
if (!response.body) {
|
|
58
111
|
throw new StreamConnectionError('No response body', 0);
|
|
59
112
|
}
|
|
@@ -83,12 +136,207 @@ export class StreamService {
|
|
|
83
136
|
body: JSON.stringify(request),
|
|
84
137
|
signal,
|
|
85
138
|
});
|
|
86
|
-
|
|
139
|
+
// Accept both 200 (sync) and 202 (distributed) as valid
|
|
140
|
+
if (!response.ok && response.status !== 202) {
|
|
87
141
|
const error = await this.parseError(response);
|
|
88
142
|
throw new StreamConnectionError(error, response.status);
|
|
89
143
|
}
|
|
90
144
|
return response;
|
|
91
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* Handle distributed worker mode (HTTP 202 response)
|
|
148
|
+
* Parses the initial response and connects to thread stream endpoint
|
|
149
|
+
*/
|
|
150
|
+
async handleDistributedMode(initialResponse, controller) {
|
|
151
|
+
// Parse the distributed response body
|
|
152
|
+
const rawText = await initialResponse.text();
|
|
153
|
+
let data;
|
|
154
|
+
try {
|
|
155
|
+
data = JSON.parse(rawText);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
throw new MalformedDistributedResponse('Invalid JSON in distributed response', rawText);
|
|
159
|
+
}
|
|
160
|
+
// Validate the response shape
|
|
161
|
+
if (!isDistributedResponse(data)) {
|
|
162
|
+
throw new MalformedDistributedResponse('Invalid distributed response: missing or invalid thread_id', rawText);
|
|
163
|
+
}
|
|
164
|
+
// Check if already aborted before making second request
|
|
165
|
+
if (controller.signal.aborted) {
|
|
166
|
+
throw new DOMException('Aborted', 'AbortError');
|
|
167
|
+
}
|
|
168
|
+
// Connect to the thread stream endpoint
|
|
169
|
+
const streamHandle = await this.connectToThreadStream(data.thread_id, controller);
|
|
170
|
+
return streamHandle;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Connect to GET /api/threads/{thread_id}/stream for distributed mode
|
|
174
|
+
* URL-encodes thread_id to prevent path injection
|
|
175
|
+
*/
|
|
176
|
+
async connectToThreadStream(threadId, controller) {
|
|
177
|
+
// URL-encode thread_id to prevent path injection
|
|
178
|
+
const encodedThreadId = encodeURIComponent(threadId);
|
|
179
|
+
const streamUrl = `${this.host}/api/threads/${encodedThreadId}/stream`;
|
|
180
|
+
// Set connection timeout for the GET request
|
|
181
|
+
const connectionTimeoutId = setTimeout(() => {
|
|
182
|
+
controller.abort();
|
|
183
|
+
}, this.connectionTimeoutMs);
|
|
184
|
+
try {
|
|
185
|
+
const streamResponse = await fetch(streamUrl, {
|
|
186
|
+
method: 'GET',
|
|
187
|
+
headers: {
|
|
188
|
+
'x-api-key': this.apiKey,
|
|
189
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
190
|
+
Accept: 'text/event-stream',
|
|
191
|
+
},
|
|
192
|
+
signal: controller.signal,
|
|
193
|
+
});
|
|
194
|
+
clearTimeout(connectionTimeoutId);
|
|
195
|
+
if (!streamResponse.ok) {
|
|
196
|
+
const error = await this.parseError(streamResponse);
|
|
197
|
+
throw new DistributedStreamError(`Failed to connect to thread stream: ${error}`, 'handshake');
|
|
198
|
+
}
|
|
199
|
+
if (!streamResponse.body) {
|
|
200
|
+
throw new DistributedStreamError('No response body from thread stream', 'handshake');
|
|
201
|
+
}
|
|
202
|
+
const events = this.parseDistributedEventStream(streamResponse.body, controller);
|
|
203
|
+
return {
|
|
204
|
+
events,
|
|
205
|
+
abort() {
|
|
206
|
+
controller.abort();
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
clearTimeout(connectionTimeoutId);
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Parse SSE events from distributed worker stream
|
|
217
|
+
* Handles [DONE] marker, keep-alive comments, and error JSON
|
|
218
|
+
*/
|
|
219
|
+
async *parseDistributedEventStream(body, controller) {
|
|
220
|
+
const reader = body.getReader();
|
|
221
|
+
const decoder = new TextDecoder();
|
|
222
|
+
let buffer = '';
|
|
223
|
+
let idleTimeoutId;
|
|
224
|
+
// Reset idle timeout on data
|
|
225
|
+
const resetIdleTimeout = () => {
|
|
226
|
+
if (idleTimeoutId) {
|
|
227
|
+
clearTimeout(idleTimeoutId);
|
|
228
|
+
}
|
|
229
|
+
idleTimeoutId = setTimeout(() => {
|
|
230
|
+
controller.abort();
|
|
231
|
+
}, this.streamIdleTimeoutMs);
|
|
232
|
+
};
|
|
233
|
+
try {
|
|
234
|
+
resetIdleTimeout();
|
|
235
|
+
while (true) {
|
|
236
|
+
// eslint-disable-next-line no-await-in-loop
|
|
237
|
+
const { done, value } = await reader.read();
|
|
238
|
+
if (done)
|
|
239
|
+
break;
|
|
240
|
+
// Reset idle timeout on any data received
|
|
241
|
+
resetIdleTimeout();
|
|
242
|
+
buffer += decoder.decode(value, { stream: true });
|
|
243
|
+
const result = this.extractDistributedEvents(buffer);
|
|
244
|
+
buffer = result.remaining;
|
|
245
|
+
for (const event of result.parsed) {
|
|
246
|
+
// Check for terminal [DONE] marker
|
|
247
|
+
if (event.type === 'done') {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
yield event;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// Process any remaining buffer
|
|
254
|
+
if (buffer.trim()) {
|
|
255
|
+
const result = this.extractDistributedEvents(buffer + '\n');
|
|
256
|
+
for (const event of result.parsed) {
|
|
257
|
+
if (event.type === 'done') {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
yield event;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
finally {
|
|
265
|
+
if (idleTimeoutId) {
|
|
266
|
+
clearTimeout(idleTimeoutId);
|
|
267
|
+
}
|
|
268
|
+
reader.releaseLock();
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Extract events from distributed stream buffer
|
|
273
|
+
* Handles keep-alive comments, [DONE] marker, and error JSON
|
|
274
|
+
*/
|
|
275
|
+
extractDistributedEvents(buffer) {
|
|
276
|
+
const parsed = [];
|
|
277
|
+
const lines = buffer.split('\n');
|
|
278
|
+
let remaining = '';
|
|
279
|
+
for (let i = 0; i < lines.length; i++) {
|
|
280
|
+
const line = lines[i];
|
|
281
|
+
// Incomplete line at end (no newline after it)
|
|
282
|
+
if (i === lines.length - 1 && !buffer.endsWith('\n')) {
|
|
283
|
+
remaining = line;
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
// Skip empty lines (SSE event separator)
|
|
287
|
+
if (line.trim() === '') {
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
// Skip keep-alive comments (lines starting with :)
|
|
291
|
+
if (line.startsWith(':')) {
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
// Parse data lines
|
|
295
|
+
if (line.startsWith('data: ')) {
|
|
296
|
+
const data = line.slice(6);
|
|
297
|
+
// Check for [DONE] marker
|
|
298
|
+
if (data === STREAM_DONE_MARKER) {
|
|
299
|
+
parsed.push({ type: 'done', payload: undefined });
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
// Try to parse as JSON
|
|
303
|
+
const event = this.parseDistributedEvent(data);
|
|
304
|
+
if (event) {
|
|
305
|
+
parsed.push(event);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return { parsed, remaining };
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Parse a single distributed event data payload
|
|
313
|
+
* Handles error JSON format and regular event format
|
|
314
|
+
*/
|
|
315
|
+
parseDistributedEvent(data) {
|
|
316
|
+
try {
|
|
317
|
+
const parsed = JSON.parse(data);
|
|
318
|
+
// Check for distributed error format: {"error": "..."}
|
|
319
|
+
if (isDistributedError(parsed)) {
|
|
320
|
+
return {
|
|
321
|
+
type: 'error',
|
|
322
|
+
payload: { message: parsed.error },
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
// Regular event format: [type, payload]
|
|
326
|
+
if (Array.isArray(parsed) && parsed.length >= 2) {
|
|
327
|
+
const [type, payload] = parsed;
|
|
328
|
+
// Type assertion needed because we're parsing dynamic SSE data
|
|
329
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
330
|
+
return { type, payload };
|
|
331
|
+
}
|
|
332
|
+
// Unknown format - skip
|
|
333
|
+
return undefined;
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
// Parse error - skip this event
|
|
337
|
+
return undefined;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
92
340
|
async *parseEventStream(body) {
|
|
93
341
|
const reader = body.getReader();
|
|
94
342
|
const decoder = new TextDecoder();
|
package/dist/types/output.d.ts
CHANGED
|
@@ -37,7 +37,7 @@ export type ErrorOutput = {
|
|
|
37
37
|
/**
|
|
38
38
|
* Typed error codes for programmatic handling
|
|
39
39
|
*/
|
|
40
|
-
export type ErrorCode = 'AUTH_FAILED' | 'NETWORK_ERROR' | 'RATE_LIMITED' | 'INVALID_REQUEST' | 'STREAM_INTERRUPTED' | 'PARSE_ERROR' | 'SERVER_ERROR' | 'TIMEOUT';
|
|
40
|
+
export type ErrorCode = 'AUTH_FAILED' | 'NETWORK_ERROR' | 'RATE_LIMITED' | 'INVALID_REQUEST' | 'STREAM_INTERRUPTED' | 'PARSE_ERROR' | 'SERVER_ERROR' | 'TIMEOUT' | 'DISTRIBUTED_ERROR' | 'DISTRIBUTED_TIMEOUT';
|
|
41
41
|
/**
|
|
42
42
|
* Union of all output types
|
|
43
43
|
*/
|
package/dist/types/stream.d.ts
CHANGED
|
@@ -5,7 +5,33 @@
|
|
|
5
5
|
/**
|
|
6
6
|
* Stream event types matching backend SSE format
|
|
7
7
|
*/
|
|
8
|
-
export type StreamEventType = 'messages' | 'values' | 'error';
|
|
8
|
+
export type StreamEventType = 'messages' | 'values' | 'error' | 'metadata' | 'done';
|
|
9
|
+
/**
|
|
10
|
+
* Special SSE markers from distributed stream
|
|
11
|
+
*/
|
|
12
|
+
export declare const STREAM_DONE_MARKER = "[DONE]";
|
|
13
|
+
/**
|
|
14
|
+
* Response from POST /llm/stream when DISTRIBUTED_WORKERS=true
|
|
15
|
+
*/
|
|
16
|
+
export type DistributedResponse = {
|
|
17
|
+
thread_id: string;
|
|
18
|
+
distributed: true;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Type guard for distributed response with strict validation
|
|
22
|
+
* Validates thread_id exists, is non-empty, and within bounds
|
|
23
|
+
*/
|
|
24
|
+
export declare function isDistributedResponse(data: unknown): data is DistributedResponse;
|
|
25
|
+
/**
|
|
26
|
+
* Error response format from distributed stream
|
|
27
|
+
*/
|
|
28
|
+
export type DistributedErrorResponse = {
|
|
29
|
+
error: string;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Type guard for distributed error format
|
|
33
|
+
*/
|
|
34
|
+
export declare function isDistributedError(data: unknown): data is DistributedErrorResponse;
|
|
9
35
|
/**
|
|
10
36
|
* Content block for multi-modal messages
|
|
11
37
|
*/
|
|
@@ -49,6 +75,15 @@ export type ValuesPayload = {
|
|
|
49
75
|
export type ErrorPayload = {
|
|
50
76
|
message: string;
|
|
51
77
|
};
|
|
78
|
+
/**
|
|
79
|
+
* Metadata payload from 'metadata' events
|
|
80
|
+
* Sent at the start of stream with thread/session info
|
|
81
|
+
*/
|
|
82
|
+
export type MetadataPayload = {
|
|
83
|
+
thread_id?: string;
|
|
84
|
+
assistant_id?: string;
|
|
85
|
+
project_id?: string;
|
|
86
|
+
};
|
|
52
87
|
/**
|
|
53
88
|
* Discriminated union for stream events
|
|
54
89
|
*/
|
|
@@ -62,6 +97,12 @@ export type StreamEvent = {
|
|
|
62
97
|
} | {
|
|
63
98
|
type: 'error';
|
|
64
99
|
payload: ErrorPayload;
|
|
100
|
+
} | {
|
|
101
|
+
type: 'metadata';
|
|
102
|
+
payload: MetadataPayload;
|
|
103
|
+
} | {
|
|
104
|
+
type: 'done';
|
|
105
|
+
payload: undefined;
|
|
65
106
|
};
|
|
66
107
|
/**
|
|
67
108
|
* Request body for /llm/stream endpoint
|
package/dist/types/stream.js
CHANGED
|
@@ -2,6 +2,43 @@
|
|
|
2
2
|
* Stream types for /api/llm/stream endpoint
|
|
3
3
|
* @see backend/src/utils/stream.py:handle_multi_mode
|
|
4
4
|
*/
|
|
5
|
+
/**
|
|
6
|
+
* Special SSE markers from distributed stream
|
|
7
|
+
*/
|
|
8
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
9
|
+
export const STREAM_DONE_MARKER = '[DONE]';
|
|
10
|
+
/**
|
|
11
|
+
* Type guard for distributed response with strict validation
|
|
12
|
+
* Validates thread_id exists, is non-empty, and within bounds
|
|
13
|
+
*/
|
|
14
|
+
export function isDistributedResponse(data) {
|
|
15
|
+
if (typeof data !== 'object' || data === null) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
const obj = data;
|
|
19
|
+
if (obj['distributed'] !== true) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
const threadId = obj['thread_id'];
|
|
23
|
+
if (typeof threadId !== 'string') {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
// Strict validation: non-empty and bounded length (prevent DoS)
|
|
27
|
+
if (threadId.length === 0 || threadId.length > 256) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Type guard for distributed error format
|
|
34
|
+
*/
|
|
35
|
+
export function isDistributedError(data) {
|
|
36
|
+
if (typeof data !== 'object' || data === null) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
const obj = data;
|
|
40
|
+
return typeof obj['error'] === 'string';
|
|
41
|
+
}
|
|
5
42
|
/**
|
|
6
43
|
* Extract text content from a MessagePayload.
|
|
7
44
|
* Handles both string content and multi-modal content blocks.
|