@aragon-agent/core 0.2.17 → 0.2.20
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/llm/provider.d.ts +2 -1
- package/dist/llm/provider.js +4 -0
- package/dist/llm/providers/anthropic.js +14 -31
- package/dist/llm/providers/google.js +26 -7
- package/dist/llm/providers/openai.d.ts +1 -0
- package/dist/llm/providers/openai.js +225 -178
- package/dist/llm/retry.js +4 -0
- package/dist/llm/sse-parser.d.ts +25 -0
- package/dist/llm/sse-parser.js +103 -0
- package/dist/llm/stream-utils.d.ts +3 -10
- package/dist/llm/stream-utils.js +66 -80
- package/package.json +1 -1
package/dist/llm/provider.d.ts
CHANGED
|
@@ -56,7 +56,8 @@ export interface LLMProvider {
|
|
|
56
56
|
* Stream a completion from the model.
|
|
57
57
|
*
|
|
58
58
|
* The returned iterator yields {@link StreamEvent} items and MUST terminate
|
|
59
|
-
* with either a `done` or an `error` event.
|
|
59
|
+
* with either a `done` or an `error` event, unless the caller cancels. Caller
|
|
60
|
+
* cancellation ends silently without executable tool results or a terminal.
|
|
60
61
|
*/
|
|
61
62
|
stream(request: LLMRequest): AsyncIterableIterator<StreamEvent>;
|
|
62
63
|
/**
|
package/dist/llm/provider.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* Keep both usages inside functions.
|
|
14
14
|
*/
|
|
15
15
|
import { parseRetryAfterMs } from './retry.js';
|
|
16
|
+
import { SseFrameLimitError } from './sse-parser.js';
|
|
16
17
|
export class LLMError extends Error {
|
|
17
18
|
provider;
|
|
18
19
|
errorType;
|
|
@@ -106,6 +107,9 @@ export function classifyHttpError(statusCode, body, provider, headers) {
|
|
|
106
107
|
export function wrapFetchError(err, provider) {
|
|
107
108
|
if (err instanceof LLMError)
|
|
108
109
|
return err;
|
|
110
|
+
if (err instanceof SseFrameLimitError) {
|
|
111
|
+
return new LLMError('SSE_FRAME_TOO_LARGE', provider, 'unknown', false);
|
|
112
|
+
}
|
|
109
113
|
const message = err instanceof Error ? err.message : String(err);
|
|
110
114
|
if (err instanceof Error && err.name === 'AbortError') {
|
|
111
115
|
return new LLMError(`${provider} request aborted: ${message}`, provider, 'timeout', true);
|
|
@@ -33,6 +33,8 @@ export class AnthropicProvider {
|
|
|
33
33
|
// stream
|
|
34
34
|
// -----------------------------------------------------------------------
|
|
35
35
|
async *stream(request) {
|
|
36
|
+
if (request.signal?.aborted)
|
|
37
|
+
return;
|
|
36
38
|
const baseUrl = (request.baseUrl || this.defaultBaseUrl).replace(/\/+$/, '');
|
|
37
39
|
const url = `${baseUrl}/v1/messages`;
|
|
38
40
|
const body = buildRequestBody(request);
|
|
@@ -59,9 +61,13 @@ export class AnthropicProvider {
|
|
|
59
61
|
errorBody = attempt.bodyText;
|
|
60
62
|
}
|
|
61
63
|
catch (err) {
|
|
64
|
+
if (request.signal?.aborted)
|
|
65
|
+
return;
|
|
62
66
|
yield { type: 'error', error: wrapFetchError(err, this.id) };
|
|
63
67
|
return;
|
|
64
68
|
}
|
|
69
|
+
if (request.signal?.aborted)
|
|
70
|
+
return;
|
|
65
71
|
if (!response.ok) {
|
|
66
72
|
// `errorBody` is the ALREADY-CONSUMED body; re-reading it would throw.
|
|
67
73
|
// `response.headers` carries `Retry-After` / `anthropic-ratelimit-*-reset`
|
|
@@ -86,6 +92,8 @@ export class AnthropicProvider {
|
|
|
86
92
|
let currentBlockIndex = -1;
|
|
87
93
|
try {
|
|
88
94
|
for await (const sse of parseSSEStream(response.body, request.signal)) {
|
|
95
|
+
if (request.signal?.aborted)
|
|
96
|
+
return;
|
|
89
97
|
// Anthropic uses the `event` field to distinguish event types
|
|
90
98
|
const eventType = sse.event;
|
|
91
99
|
if (!eventType || !sse.data)
|
|
@@ -220,6 +228,8 @@ export class AnthropicProvider {
|
|
|
220
228
|
break;
|
|
221
229
|
}
|
|
222
230
|
case 'message_stop': {
|
|
231
|
+
if (request.signal?.aborted)
|
|
232
|
+
return;
|
|
223
233
|
const message = {
|
|
224
234
|
role: 'assistant',
|
|
225
235
|
content: contentBlocks,
|
|
@@ -249,44 +259,17 @@ export class AnthropicProvider {
|
|
|
249
259
|
break;
|
|
250
260
|
}
|
|
251
261
|
}
|
|
252
|
-
//
|
|
253
|
-
|
|
254
|
-
// Reaching here means the SSE ended WITHOUT `message_stop`: that case
|
|
255
|
-
// `return`s from inside the loop above, and so does the in-stream `error`
|
|
256
|
-
// case. No `sawTerminal` flag is needed for this adapter precisely because
|
|
257
|
-
// of those two returns — the absence is the signal. (`openai.ts` and
|
|
258
|
-
// `google.ts` DO need a flag, because their terminal frames only `break`.)
|
|
259
|
-
//
|
|
260
|
-
// TWO VERY DIFFERENT SITUATIONS SHARE THIS LINE, and conflating them is how
|
|
261
|
-
// every user abort would become a fake network error (constraint 1 / R-17):
|
|
262
|
-
//
|
|
263
|
-
// - the request was ABORTED. `parseSSEStream` `break`s on
|
|
264
|
-
// `signal.aborted` rather than throwing, so Esc lands here. An abort is
|
|
265
|
-
// not a provider failure, and `agent-loop` discards the `done` anyway.
|
|
266
|
-
// - the stream was TRUNCATED — a proxy idle cut, a load-balancer timeout,
|
|
267
|
-
// an HTTP/2 GOAWAY, `message_stop` never arriving. This USED to yield
|
|
268
|
-
// `done` with partial content, which is indistinguishable from success
|
|
269
|
-
// at every layer above and is the single failure the retry machinery
|
|
270
|
-
// exists to recover. It is now a retryable `network_error`.
|
|
271
|
-
//
|
|
272
|
-
// An empty-but-complete turn is unaffected: Anthropic always sends
|
|
273
|
-
// `message_stop`, so a model that emits zero content still returns above.
|
|
274
|
-
if (request.signal?.aborted) {
|
|
275
|
-
const message = {
|
|
276
|
-
role: 'assistant',
|
|
277
|
-
content: contentBlocks,
|
|
278
|
-
usage,
|
|
279
|
-
stopReason,
|
|
280
|
-
};
|
|
281
|
-
yield { type: 'done', message, usage };
|
|
262
|
+
// A caller abort is silent; only an incomplete transport EOF is retried.
|
|
263
|
+
if (request.signal?.aborted)
|
|
282
264
|
return;
|
|
283
|
-
}
|
|
284
265
|
yield {
|
|
285
266
|
type: 'error',
|
|
286
267
|
error: new LLMError(`${this.id} stream truncated before message_stop`, this.id, 'network_error', true),
|
|
287
268
|
};
|
|
288
269
|
}
|
|
289
270
|
catch (err) {
|
|
271
|
+
if (request.signal?.aborted)
|
|
272
|
+
return;
|
|
290
273
|
yield { type: 'error', error: wrapFetchError(err, this.id) };
|
|
291
274
|
}
|
|
292
275
|
}
|
|
@@ -31,6 +31,8 @@ export class GoogleProvider {
|
|
|
31
31
|
// stream
|
|
32
32
|
// -----------------------------------------------------------------------
|
|
33
33
|
async *stream(request) {
|
|
34
|
+
if (request.signal?.aborted)
|
|
35
|
+
return;
|
|
34
36
|
const baseUrl = (request.baseUrl || this.defaultBaseUrl).replace(/\/+$/, '');
|
|
35
37
|
const url = `${baseUrl}/v1beta/models/${request.model}:streamGenerateContent?alt=sse&key=${encodeURIComponent(request.apiKey)}`;
|
|
36
38
|
const body = buildRequestBody(request);
|
|
@@ -53,9 +55,13 @@ export class GoogleProvider {
|
|
|
53
55
|
errorBody = attempt.bodyText;
|
|
54
56
|
}
|
|
55
57
|
catch (err) {
|
|
58
|
+
if (request.signal?.aborted)
|
|
59
|
+
return;
|
|
56
60
|
yield { type: 'error', error: wrapFetchError(err, this.id) };
|
|
57
61
|
return;
|
|
58
62
|
}
|
|
63
|
+
if (request.signal?.aborted)
|
|
64
|
+
return;
|
|
59
65
|
if (!response.ok) {
|
|
60
66
|
// `errorBody` is the ALREADY-CONSUMED body; re-reading it would throw.
|
|
61
67
|
// `response.headers` carries `Retry-After` out on `LLMError.retryAfterMs`
|
|
@@ -86,6 +92,8 @@ export class GoogleProvider {
|
|
|
86
92
|
let sawTerminal = false;
|
|
87
93
|
try {
|
|
88
94
|
for await (const sse of parseSSEStream(response.body, request.signal)) {
|
|
95
|
+
if (request.signal?.aborted)
|
|
96
|
+
return;
|
|
89
97
|
const raw = sse.data.trim();
|
|
90
98
|
if (!raw)
|
|
91
99
|
continue;
|
|
@@ -120,19 +128,29 @@ export class GoogleProvider {
|
|
|
120
128
|
if (!parts)
|
|
121
129
|
continue;
|
|
122
130
|
for (const part of parts) {
|
|
131
|
+
if (request.signal?.aborted)
|
|
132
|
+
return;
|
|
123
133
|
// Text part
|
|
124
134
|
if (typeof part.text === 'string') {
|
|
125
135
|
textAccumulator += part.text;
|
|
126
136
|
yield { type: 'text_delta', delta: part.text };
|
|
127
137
|
}
|
|
138
|
+
if (request.signal?.aborted)
|
|
139
|
+
return;
|
|
128
140
|
// Function call — arrives complete in a single chunk
|
|
129
141
|
const functionCall = part.functionCall;
|
|
130
142
|
if (functionCall) {
|
|
131
143
|
const toolName = functionCall.name || '';
|
|
132
144
|
const args = functionCall.args || {};
|
|
133
145
|
const toolCallId = `call_${randomUUID().slice(0, 8)}`;
|
|
146
|
+
if (request.signal?.aborted)
|
|
147
|
+
return;
|
|
134
148
|
yield { type: 'tool_call_start', toolCallId, toolName };
|
|
149
|
+
if (request.signal?.aborted)
|
|
150
|
+
return;
|
|
135
151
|
yield { type: 'tool_call_end', toolCallId, toolName, args };
|
|
152
|
+
if (request.signal?.aborted)
|
|
153
|
+
return;
|
|
136
154
|
contentBlocks.push({
|
|
137
155
|
type: 'tool_call',
|
|
138
156
|
toolCallId,
|
|
@@ -142,13 +160,10 @@ export class GoogleProvider {
|
|
|
142
160
|
}
|
|
143
161
|
}
|
|
144
162
|
}
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
// `network_error` would re-create the trap `isRetryableError` check 1
|
|
150
|
-
// closes (constraint 1 / R-17). Signal first; that path yields `done`.
|
|
151
|
-
if (!sawTerminal && !request.signal?.aborted) {
|
|
163
|
+
// A caller abort is silent; only an incomplete transport EOF is retried.
|
|
164
|
+
if (request.signal?.aborted)
|
|
165
|
+
return;
|
|
166
|
+
if (!sawTerminal) {
|
|
152
167
|
yield {
|
|
153
168
|
type: 'error',
|
|
154
169
|
error: new LLMError(`${this.id} stream truncated before a terminal chunk`, this.id, 'network_error', true),
|
|
@@ -159,6 +174,8 @@ export class GoogleProvider {
|
|
|
159
174
|
if (textAccumulator) {
|
|
160
175
|
contentBlocks.push({ type: 'text', text: textAccumulator });
|
|
161
176
|
}
|
|
177
|
+
if (request.signal?.aborted)
|
|
178
|
+
return;
|
|
162
179
|
const message = {
|
|
163
180
|
role: 'assistant',
|
|
164
181
|
content: contentBlocks,
|
|
@@ -168,6 +185,8 @@ export class GoogleProvider {
|
|
|
168
185
|
yield { type: 'done', message, usage };
|
|
169
186
|
}
|
|
170
187
|
catch (err) {
|
|
188
|
+
if (request.signal?.aborted)
|
|
189
|
+
return;
|
|
171
190
|
yield { type: 'error', error: wrapFetchError(err, this.id) };
|
|
172
191
|
}
|
|
173
192
|
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import type { AssistantMessage, ModelInfo, StreamEvent } from '../types.js';
|
|
14
14
|
import type { LLMProvider, LLMRequest } from '../provider.js';
|
|
15
|
+
export declare const OPENAI_TERMINAL_DRAIN_MS = 750;
|
|
15
16
|
/**
|
|
16
17
|
* Which parameter name carries the cap for this model.
|
|
17
18
|
*
|
|
@@ -19,6 +19,7 @@ import { consumeStream } from '../stream-utils.js';
|
|
|
19
19
|
// Constants
|
|
20
20
|
// ---------------------------------------------------------------------------
|
|
21
21
|
const DEFAULT_BASE_URL = 'https://api.openai.com/v1';
|
|
22
|
+
export const OPENAI_TERMINAL_DRAIN_MS = 750;
|
|
22
23
|
/** Reasoning-family models reject `max_tokens` in favour of `max_completion_tokens`. */
|
|
23
24
|
const REASONING_MODEL_PATTERN = /^(o1|o3|o4|gpt-5)/;
|
|
24
25
|
// Model ID patterns to filter out non-chat models
|
|
@@ -68,194 +69,38 @@ export class OpenAIProvider {
|
|
|
68
69
|
// stream
|
|
69
70
|
// -----------------------------------------------------------------------
|
|
70
71
|
async *stream(request) {
|
|
71
|
-
|
|
72
|
-
const url = `${baseUrl}/chat/completions`;
|
|
73
|
-
const body = buildRequestBody(request);
|
|
74
|
-
let response;
|
|
75
|
-
let errorBody = '';
|
|
76
|
-
try {
|
|
77
|
-
const attempt = await sendWithOutputLimitRecovery({
|
|
78
|
-
providerId: this.id,
|
|
79
|
-
modelId: request.model,
|
|
80
|
-
body,
|
|
81
|
-
tokenField: openAiTokenField(request.model),
|
|
82
|
-
send: (payload) => fetch(url, {
|
|
83
|
-
method: 'POST',
|
|
84
|
-
headers: {
|
|
85
|
-
'Content-Type': 'application/json',
|
|
86
|
-
'Authorization': `Bearer ${request.apiKey}`,
|
|
87
|
-
},
|
|
88
|
-
body: JSON.stringify(payload),
|
|
89
|
-
signal: request.signal,
|
|
90
|
-
}),
|
|
91
|
-
});
|
|
92
|
-
response = attempt.response;
|
|
93
|
-
errorBody = attempt.bodyText;
|
|
94
|
-
}
|
|
95
|
-
catch (err) {
|
|
96
|
-
yield { type: 'error', error: wrapFetchError(err, this.id) };
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
if (!response.ok) {
|
|
100
|
-
// `errorBody` is the ALREADY-CONSUMED body; re-reading it would throw.
|
|
101
|
-
// `response.headers` carries `Retry-After` / `x-ratelimit-reset-after` out
|
|
102
|
-
// on `LLMError.retryAfterMs` so a 429 waits as long as the server asked.
|
|
103
|
-
yield {
|
|
104
|
-
type: 'error',
|
|
105
|
-
error: classifyHttpError(response.status, errorBody, this.id, response.headers),
|
|
106
|
-
};
|
|
107
|
-
return;
|
|
108
|
-
}
|
|
109
|
-
if (!response.body) {
|
|
110
|
-
yield { type: 'error', error: new LLMError('Response body is null', this.id, 'network_error', true) };
|
|
72
|
+
if (request.signal?.aborted)
|
|
111
73
|
return;
|
|
112
|
-
|
|
113
|
-
// -- Parse SSE events --
|
|
114
|
-
const usage = { inputTokens: 0, outputTokens: 0 };
|
|
115
|
-
const contentBlocks = [];
|
|
116
|
-
let textAccumulator = '';
|
|
117
|
-
let stopReason;
|
|
118
|
-
// Accumulate tool calls by index
|
|
119
|
-
const toolAccumulators = new Map();
|
|
120
|
-
/**
|
|
121
|
-
* Whether this stream reached a terminal frame (§4.5a). Unlike Anthropic's
|
|
122
|
-
* `message_stop`, neither of OpenAI's terminal signals `return`s, so the
|
|
123
|
-
* absence of a return cannot stand in for the flag.
|
|
124
|
-
*
|
|
125
|
-
* TWO SIGNALS, NOT ONE, and the second is not optional. The `[DONE]`
|
|
126
|
-
* sentinel is the documented terminator, but it is a convention of OpenAI's
|
|
127
|
-
* own server rather than a property of the wire format: several
|
|
128
|
-
* OpenAI-compatible endpoints (the ones `--base-url` exists for) close the
|
|
129
|
-
* connection after the last chunk without sending it. Keying truncation on
|
|
130
|
-
* `[DONE]` alone would make every request against such a server a retryable
|
|
131
|
-
* error, i.e. ten retries and then a failure, for a stream that completed
|
|
132
|
-
* perfectly. A `finish_reason` on any choice is the model's own statement
|
|
133
|
-
* that it stopped, and a stream cut mid-answer carries neither.
|
|
134
|
-
*/
|
|
135
|
-
let sawTerminal = false;
|
|
74
|
+
const scope = new OpenAIRequestScope(request.signal);
|
|
136
75
|
try {
|
|
137
|
-
|
|
138
|
-
const raw = sse.data.trim();
|
|
139
|
-
// [DONE] sentinel
|
|
140
|
-
if (raw === '[DONE]') {
|
|
141
|
-
sawTerminal = true;
|
|
142
|
-
break;
|
|
143
|
-
}
|
|
144
|
-
let data;
|
|
145
|
-
try {
|
|
146
|
-
data = JSON.parse(raw);
|
|
147
|
-
}
|
|
148
|
-
catch {
|
|
149
|
-
continue;
|
|
150
|
-
}
|
|
151
|
-
// Extract usage if present (some providers include it in the final chunk)
|
|
152
|
-
const usageData = data.usage;
|
|
153
|
-
if (usageData) {
|
|
154
|
-
usage.inputTokens = usageData.prompt_tokens ?? usage.inputTokens;
|
|
155
|
-
usage.outputTokens = usageData.completion_tokens ?? usage.outputTokens;
|
|
156
|
-
}
|
|
157
|
-
const choices = data.choices;
|
|
158
|
-
if (!choices || choices.length === 0)
|
|
159
|
-
continue;
|
|
160
|
-
const choice = choices[0];
|
|
161
|
-
const delta = choice.delta;
|
|
162
|
-
const finishReason = choice.finish_reason;
|
|
163
|
-
if (delta) {
|
|
164
|
-
// Text content
|
|
165
|
-
const content = delta.content;
|
|
166
|
-
if (content) {
|
|
167
|
-
textAccumulator += content;
|
|
168
|
-
yield { type: 'text_delta', delta: content };
|
|
169
|
-
}
|
|
170
|
-
// Tool calls
|
|
171
|
-
const toolCalls = delta.tool_calls;
|
|
172
|
-
if (toolCalls) {
|
|
173
|
-
for (const tc of toolCalls) {
|
|
174
|
-
const idx = tc.index ?? 0;
|
|
175
|
-
const fn = tc.function;
|
|
176
|
-
let acc = toolAccumulators.get(idx);
|
|
177
|
-
if (!acc) {
|
|
178
|
-
acc = {
|
|
179
|
-
index: idx,
|
|
180
|
-
toolCallId: tc.id || `call_${idx}`,
|
|
181
|
-
toolName: fn?.name || '',
|
|
182
|
-
argsJson: '',
|
|
183
|
-
started: false,
|
|
184
|
-
};
|
|
185
|
-
toolAccumulators.set(idx, acc);
|
|
186
|
-
}
|
|
187
|
-
// Update name if present in this chunk
|
|
188
|
-
if (fn?.name) {
|
|
189
|
-
acc.toolName = fn.name;
|
|
190
|
-
}
|
|
191
|
-
// Emit tool_call_start on first encounter
|
|
192
|
-
if (!acc.started && acc.toolName) {
|
|
193
|
-
acc.started = true;
|
|
194
|
-
yield { type: 'tool_call_start', toolCallId: acc.toolCallId, toolName: acc.toolName };
|
|
195
|
-
}
|
|
196
|
-
// Accumulate argument fragments
|
|
197
|
-
if (fn?.arguments) {
|
|
198
|
-
const argChunk = fn.arguments;
|
|
199
|
-
acc.argsJson += argChunk;
|
|
200
|
-
if (acc.started) {
|
|
201
|
-
yield { type: 'tool_call_delta', toolCallId: acc.toolCallId, argsDelta: argChunk };
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
// Finish reason handling
|
|
208
|
-
if (finishReason) {
|
|
209
|
-
sawTerminal = true;
|
|
210
|
-
stopReason = mapFinishReason(finishReason);
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
// ---- The terminal-sentinel contract (§4.5a) -------------------------
|
|
214
|
-
//
|
|
215
|
-
// An abort is NOT a truncation: `parseSSEStream` `break`s on
|
|
216
|
-
// `signal.aborted`, which lands on exactly this path, and turning that into
|
|
217
|
-
// a retryable `network_error` would re-create the trap `isRetryableError`
|
|
218
|
-
// check 1 exists to close (constraint 1 / R-17). The signal is therefore
|
|
219
|
-
// tested FIRST and that path keeps yielding `done`.
|
|
220
|
-
if (!sawTerminal && !request.signal?.aborted) {
|
|
221
|
-
yield {
|
|
222
|
-
type: 'error',
|
|
223
|
-
error: new LLMError(`${this.id} stream truncated before a terminal chunk`, this.id, 'network_error', true),
|
|
224
|
-
};
|
|
76
|
+
if (request.signal?.aborted)
|
|
225
77
|
return;
|
|
78
|
+
const attempt = await sendOpenAIRequest(request, this.defaultBaseUrl, scope.signal);
|
|
79
|
+
if (request.signal?.aborted)
|
|
80
|
+
return;
|
|
81
|
+
const response = attempt.response;
|
|
82
|
+
if (!response.ok) {
|
|
83
|
+
throw classifyHttpError(response.status, attempt.bodyText, this.id, response.headers);
|
|
226
84
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
contentBlocks.push({ type: 'text', text: textAccumulator });
|
|
85
|
+
if (!response.body) {
|
|
86
|
+
throw new LLMError('Response body is null', this.id, 'network_error', true);
|
|
230
87
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
catch {
|
|
238
|
-
args = { __raw: acc.argsJson };
|
|
239
|
-
}
|
|
240
|
-
contentBlocks.push({
|
|
241
|
-
type: 'tool_call',
|
|
242
|
-
toolCallId: acc.toolCallId,
|
|
243
|
-
toolName: acc.toolName,
|
|
244
|
-
args,
|
|
245
|
-
});
|
|
246
|
-
yield { type: 'tool_call_end', toolCallId: acc.toolCallId, toolName: acc.toolName, args };
|
|
88
|
+
for await (const event of readOpenAIResponse(response.body, scope)) {
|
|
89
|
+
if (request.signal?.aborted)
|
|
90
|
+
return;
|
|
91
|
+
yield event;
|
|
92
|
+
if (request.signal?.aborted)
|
|
93
|
+
return;
|
|
247
94
|
}
|
|
248
|
-
const message = {
|
|
249
|
-
role: 'assistant',
|
|
250
|
-
content: contentBlocks,
|
|
251
|
-
usage,
|
|
252
|
-
stopReason,
|
|
253
|
-
};
|
|
254
|
-
yield { type: 'done', message, usage };
|
|
255
95
|
}
|
|
256
96
|
catch (err) {
|
|
97
|
+
if (request.signal?.aborted)
|
|
98
|
+
return;
|
|
257
99
|
yield { type: 'error', error: wrapFetchError(err, this.id) };
|
|
258
100
|
}
|
|
101
|
+
finally {
|
|
102
|
+
scope.dispose();
|
|
103
|
+
}
|
|
259
104
|
}
|
|
260
105
|
// -----------------------------------------------------------------------
|
|
261
106
|
// complete
|
|
@@ -303,6 +148,208 @@ export class OpenAIProvider {
|
|
|
303
148
|
}
|
|
304
149
|
}
|
|
305
150
|
}
|
|
151
|
+
/** Owns the request signal and the absolute, non-renewable terminal deadline. */
|
|
152
|
+
class OpenAIRequestScope {
|
|
153
|
+
caller;
|
|
154
|
+
controller = new AbortController();
|
|
155
|
+
timer;
|
|
156
|
+
cancelCause = null;
|
|
157
|
+
onCallerAbort = () => {
|
|
158
|
+
this.cancelCause = 'caller';
|
|
159
|
+
this.controller.abort();
|
|
160
|
+
};
|
|
161
|
+
constructor(caller) {
|
|
162
|
+
this.caller = caller;
|
|
163
|
+
caller?.addEventListener('abort', this.onCallerAbort, { once: true });
|
|
164
|
+
if (caller?.aborted)
|
|
165
|
+
this.onCallerAbort();
|
|
166
|
+
}
|
|
167
|
+
get signal() { return this.controller.signal; }
|
|
168
|
+
get callerAborted() { return this.caller?.aborted === true; }
|
|
169
|
+
get terminalDrainExpired() { return this.cancelCause === 'terminal-drain'; }
|
|
170
|
+
startDrain() {
|
|
171
|
+
if (this.timer !== undefined || this.cancelCause !== null)
|
|
172
|
+
return;
|
|
173
|
+
this.timer = setTimeout(() => {
|
|
174
|
+
if (this.callerAborted) {
|
|
175
|
+
this.onCallerAbort();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
this.cancelCause = 'terminal-drain';
|
|
179
|
+
this.controller.abort();
|
|
180
|
+
}, OPENAI_TERMINAL_DRAIN_MS);
|
|
181
|
+
}
|
|
182
|
+
dispose() {
|
|
183
|
+
if (this.timer !== undefined)
|
|
184
|
+
clearTimeout(this.timer);
|
|
185
|
+
this.caller?.removeEventListener('abort', this.onCallerAbort);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function sendOpenAIRequest(request, defaultBaseUrl, signal) {
|
|
189
|
+
const baseUrl = (request.baseUrl || defaultBaseUrl).replace(/\/+$/, '');
|
|
190
|
+
return sendWithOutputLimitRecovery({
|
|
191
|
+
providerId: 'openai',
|
|
192
|
+
modelId: request.model,
|
|
193
|
+
body: buildRequestBody(request),
|
|
194
|
+
tokenField: openAiTokenField(request.model),
|
|
195
|
+
send: (payload) => fetch(`${baseUrl}/chat/completions`, {
|
|
196
|
+
method: 'POST',
|
|
197
|
+
headers: {
|
|
198
|
+
'Content-Type': 'application/json',
|
|
199
|
+
'Authorization': `Bearer ${request.apiKey}`,
|
|
200
|
+
},
|
|
201
|
+
body: JSON.stringify(payload),
|
|
202
|
+
signal,
|
|
203
|
+
}),
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
async function* readOpenAIResponse(body, scope) {
|
|
207
|
+
const state = new OpenAIStreamState();
|
|
208
|
+
try {
|
|
209
|
+
for await (const sse of parseSSEStream(body, scope.signal)) {
|
|
210
|
+
if (scope.callerAborted)
|
|
211
|
+
return;
|
|
212
|
+
const raw = sse.data.trim();
|
|
213
|
+
if (raw === '[DONE]') {
|
|
214
|
+
state.sawTerminal = true;
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
for (const event of state.consume(raw, scope)) {
|
|
218
|
+
if (scope.callerAborted)
|
|
219
|
+
return;
|
|
220
|
+
yield event;
|
|
221
|
+
if (scope.callerAborted)
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
if (scope.callerAborted)
|
|
228
|
+
return;
|
|
229
|
+
if (!scope.terminalDrainExpired || !state.sawTerminal)
|
|
230
|
+
throw err;
|
|
231
|
+
}
|
|
232
|
+
if (scope.callerAborted)
|
|
233
|
+
return;
|
|
234
|
+
if (!state.sawTerminal) {
|
|
235
|
+
throw new LLMError('openai stream truncated before a terminal chunk', 'openai', 'network_error', true);
|
|
236
|
+
}
|
|
237
|
+
for (const event of state.finish()) {
|
|
238
|
+
if (scope.callerAborted)
|
|
239
|
+
return;
|
|
240
|
+
yield event;
|
|
241
|
+
if (scope.callerAborted)
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
class OpenAIStreamState {
|
|
246
|
+
usage = { inputTokens: 0, outputTokens: 0 };
|
|
247
|
+
sawTerminal = false;
|
|
248
|
+
text = '';
|
|
249
|
+
thinking = '';
|
|
250
|
+
stopReason;
|
|
251
|
+
tools = new Map();
|
|
252
|
+
*consume(raw, scope) {
|
|
253
|
+
let chunk;
|
|
254
|
+
try {
|
|
255
|
+
chunk = JSON.parse(raw);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (!chunk || typeof chunk !== 'object')
|
|
261
|
+
return;
|
|
262
|
+
const usage = chunk.usage;
|
|
263
|
+
if (usage) {
|
|
264
|
+
this.usage.inputTokens = usage.prompt_tokens ?? this.usage.inputTokens;
|
|
265
|
+
this.usage.outputTokens = usage.completion_tokens ?? this.usage.outputTokens;
|
|
266
|
+
}
|
|
267
|
+
if (this.sawTerminal)
|
|
268
|
+
return;
|
|
269
|
+
const choices = chunk.choices;
|
|
270
|
+
if (!Array.isArray(choices) || choices.length === 0)
|
|
271
|
+
return;
|
|
272
|
+
const choice = choices.find((candidate) => candidate.index === 0)
|
|
273
|
+
?? (choices[0].index === undefined ? choices[0] : undefined);
|
|
274
|
+
if (!choice)
|
|
275
|
+
return;
|
|
276
|
+
if (typeof choice.finish_reason === 'string' && choice.finish_reason.length > 0) {
|
|
277
|
+
this.sawTerminal = true;
|
|
278
|
+
this.stopReason = mapFinishReason(choice.finish_reason);
|
|
279
|
+
// Start at receipt, including when a consumer pauses on this frame's delta.
|
|
280
|
+
scope.startDrain();
|
|
281
|
+
}
|
|
282
|
+
const delta = choice.delta;
|
|
283
|
+
if (delta)
|
|
284
|
+
yield* this.consumeDelta(delta);
|
|
285
|
+
}
|
|
286
|
+
*consumeDelta(delta) {
|
|
287
|
+
const reasoning = delta.reasoning_content;
|
|
288
|
+
if (typeof reasoning === 'string' && reasoning.length > 0) {
|
|
289
|
+
if (this.thinking.length === 0)
|
|
290
|
+
yield { type: 'thinking_start' };
|
|
291
|
+
this.thinking += reasoning;
|
|
292
|
+
yield { type: 'thinking_delta', delta: reasoning };
|
|
293
|
+
}
|
|
294
|
+
if (typeof delta.content === 'string' && delta.content.length > 0) {
|
|
295
|
+
this.text += delta.content;
|
|
296
|
+
yield { type: 'text_delta', delta: delta.content };
|
|
297
|
+
}
|
|
298
|
+
const tools = delta.tool_calls;
|
|
299
|
+
if (Array.isArray(tools)) {
|
|
300
|
+
for (const tool of tools)
|
|
301
|
+
yield* this.consumeTool(tool);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
*consumeTool(tool) {
|
|
305
|
+
const index = tool.index ?? 0;
|
|
306
|
+
const fn = tool.function;
|
|
307
|
+
let acc = this.tools.get(index);
|
|
308
|
+
if (!acc) {
|
|
309
|
+
acc = {
|
|
310
|
+
index, toolCallId: tool.id || `call_${index}`,
|
|
311
|
+
toolName: fn?.name || '', argsJson: '', started: false,
|
|
312
|
+
};
|
|
313
|
+
this.tools.set(index, acc);
|
|
314
|
+
}
|
|
315
|
+
if (fn?.name)
|
|
316
|
+
acc.toolName = fn.name;
|
|
317
|
+
if (!acc.started && acc.toolName) {
|
|
318
|
+
acc.started = true;
|
|
319
|
+
yield { type: 'tool_call_start', toolCallId: acc.toolCallId, toolName: acc.toolName };
|
|
320
|
+
}
|
|
321
|
+
if (typeof fn?.arguments === 'string' && fn.arguments.length > 0) {
|
|
322
|
+
acc.argsJson += fn.arguments;
|
|
323
|
+
if (acc.started) {
|
|
324
|
+
yield { type: 'tool_call_delta', toolCallId: acc.toolCallId, argsDelta: fn.arguments };
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
*finish() {
|
|
329
|
+
const content = [];
|
|
330
|
+
if (this.thinking)
|
|
331
|
+
content.push({ type: 'thinking', text: this.thinking });
|
|
332
|
+
if (this.text)
|
|
333
|
+
content.push({ type: 'text', text: this.text });
|
|
334
|
+
for (const acc of this.tools.values()) {
|
|
335
|
+
let args = {};
|
|
336
|
+
try {
|
|
337
|
+
args = acc.argsJson ? JSON.parse(acc.argsJson) : {};
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
args = { __raw: acc.argsJson };
|
|
341
|
+
}
|
|
342
|
+
content.push({
|
|
343
|
+
type: 'tool_call', toolCallId: acc.toolCallId, toolName: acc.toolName, args,
|
|
344
|
+
});
|
|
345
|
+
yield { type: 'tool_call_end', toolCallId: acc.toolCallId, toolName: acc.toolName, args };
|
|
346
|
+
}
|
|
347
|
+
const message = {
|
|
348
|
+
role: 'assistant', content, usage: this.usage, stopReason: this.stopReason,
|
|
349
|
+
};
|
|
350
|
+
yield { type: 'done', message, usage: this.usage };
|
|
351
|
+
}
|
|
352
|
+
}
|
|
306
353
|
// ---------------------------------------------------------------------------
|
|
307
354
|
// Internal: request body builder
|
|
308
355
|
// ---------------------------------------------------------------------------
|
package/dist/llm/retry.js
CHANGED
|
@@ -366,6 +366,8 @@ export async function* withRetry(makeStream, opts) {
|
|
|
366
366
|
/** Retries COMPLETED so far. */
|
|
367
367
|
let retryIndex = 0;
|
|
368
368
|
for (;;) {
|
|
369
|
+
if (Boolean(signal?.aborted))
|
|
370
|
+
return;
|
|
369
371
|
let committed = false;
|
|
370
372
|
const toolCallIds = [];
|
|
371
373
|
let sawDone = false;
|
|
@@ -388,6 +390,8 @@ export async function* withRetry(makeStream, opts) {
|
|
|
388
390
|
sawDone = true;
|
|
389
391
|
yield event;
|
|
390
392
|
}
|
|
393
|
+
if (Boolean(signal?.aborted))
|
|
394
|
+
return;
|
|
391
395
|
if (sawDone)
|
|
392
396
|
return; // success; the hot path ends here
|
|
393
397
|
if (terminalError === undefined) {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { SSEEvent } from './stream-utils.js';
|
|
2
|
+
export declare const SSE_FRAME_LIMIT = 1048576;
|
|
3
|
+
/** A single SSE frame exceeded its UTF-16 capacity; retrying cannot repair it. */
|
|
4
|
+
export declare class SseFrameLimitError extends Error {
|
|
5
|
+
constructor();
|
|
6
|
+
}
|
|
7
|
+
/** Incremental model-stream parser; IDs are local to each event, not reconnect state. */
|
|
8
|
+
export declare class SseParser {
|
|
9
|
+
private line;
|
|
10
|
+
private dataLines;
|
|
11
|
+
private event;
|
|
12
|
+
private id;
|
|
13
|
+
private frameUnits;
|
|
14
|
+
private skipNextLf;
|
|
15
|
+
private atStart;
|
|
16
|
+
/** Consume decoded text lazily, throwing SseFrameLimitError on an oversized frame. */
|
|
17
|
+
push(text: string): IterableIterator<SSEEvent>;
|
|
18
|
+
/** Preserve the model adapters' complete-last-line compatibility at normal EOF. */
|
|
19
|
+
finish(): IterableIterator<SSEEvent>;
|
|
20
|
+
/** Discard uncommitted input without dispatching it, including on cancellation. */
|
|
21
|
+
clear(): void;
|
|
22
|
+
private countUnit;
|
|
23
|
+
private finishLine;
|
|
24
|
+
private dispatch;
|
|
25
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
export const SSE_FRAME_LIMIT = 1_048_576;
|
|
2
|
+
/** A single SSE frame exceeded its UTF-16 capacity; retrying cannot repair it. */
|
|
3
|
+
export class SseFrameLimitError extends Error {
|
|
4
|
+
constructor() {
|
|
5
|
+
super(`SSE frame exceeds ${SSE_FRAME_LIMIT} UTF-16 code units`);
|
|
6
|
+
this.name = 'SseFrameLimitError';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
/** Incremental model-stream parser; IDs are local to each event, not reconnect state. */
|
|
10
|
+
export class SseParser {
|
|
11
|
+
line = '';
|
|
12
|
+
dataLines = [];
|
|
13
|
+
event;
|
|
14
|
+
id;
|
|
15
|
+
frameUnits = 0;
|
|
16
|
+
skipNextLf = false;
|
|
17
|
+
atStart = true;
|
|
18
|
+
/** Consume decoded text lazily, throwing SseFrameLimitError on an oversized frame. */
|
|
19
|
+
*push(text) {
|
|
20
|
+
for (let index = 0; index < text.length; index++) {
|
|
21
|
+
const char = text[index];
|
|
22
|
+
if (this.atStart) {
|
|
23
|
+
this.atStart = false;
|
|
24
|
+
if (char === '\uFEFF')
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (this.skipNextLf) {
|
|
28
|
+
this.skipNextLf = false;
|
|
29
|
+
if (char === '\n') {
|
|
30
|
+
// A blank CR already dispatched/reset the frame. Its optional LF
|
|
31
|
+
// belongs to that delimiter, never to the next frame's budget.
|
|
32
|
+
if (this.frameUnits > 0)
|
|
33
|
+
this.countUnit();
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
this.countUnit();
|
|
38
|
+
if (char !== '\r' && char !== '\n') {
|
|
39
|
+
this.line += char;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
this.skipNextLf = char === '\r';
|
|
43
|
+
const event = this.finishLine();
|
|
44
|
+
if (event)
|
|
45
|
+
yield event;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Preserve the model adapters' complete-last-line compatibility at normal EOF. */
|
|
49
|
+
*finish() {
|
|
50
|
+
if (this.line.length > 0)
|
|
51
|
+
this.finishLine();
|
|
52
|
+
const event = this.dispatch();
|
|
53
|
+
if (event)
|
|
54
|
+
yield event;
|
|
55
|
+
}
|
|
56
|
+
/** Discard uncommitted input without dispatching it, including on cancellation. */
|
|
57
|
+
clear() {
|
|
58
|
+
this.line = '';
|
|
59
|
+
this.dataLines = [];
|
|
60
|
+
this.event = undefined;
|
|
61
|
+
this.id = undefined;
|
|
62
|
+
this.frameUnits = 0;
|
|
63
|
+
}
|
|
64
|
+
countUnit() {
|
|
65
|
+
this.frameUnits++;
|
|
66
|
+
if (this.frameUnits <= SSE_FRAME_LIMIT)
|
|
67
|
+
return;
|
|
68
|
+
this.clear();
|
|
69
|
+
throw new SseFrameLimitError();
|
|
70
|
+
}
|
|
71
|
+
finishLine() {
|
|
72
|
+
const line = this.line;
|
|
73
|
+
this.line = '';
|
|
74
|
+
if (line.length === 0)
|
|
75
|
+
return this.dispatch();
|
|
76
|
+
if (line.startsWith(':'))
|
|
77
|
+
return;
|
|
78
|
+
const colon = line.indexOf(':');
|
|
79
|
+
const field = colon < 0 ? line : line.slice(0, colon);
|
|
80
|
+
let value = colon < 0 ? '' : line.slice(colon + 1);
|
|
81
|
+
if (value.startsWith(' '))
|
|
82
|
+
value = value.slice(1);
|
|
83
|
+
switch (field) {
|
|
84
|
+
case 'data':
|
|
85
|
+
this.dataLines.push(value);
|
|
86
|
+
break;
|
|
87
|
+
case 'event':
|
|
88
|
+
this.event = value;
|
|
89
|
+
break;
|
|
90
|
+
case 'id':
|
|
91
|
+
if (!value.includes('\0'))
|
|
92
|
+
this.id = value;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
dispatch() {
|
|
97
|
+
const event = this.dataLines.length > 0 || this.event !== undefined
|
|
98
|
+
? { event: this.event, data: this.dataLines.join('\n'), id: this.id }
|
|
99
|
+
: undefined;
|
|
100
|
+
this.clear();
|
|
101
|
+
return event;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -11,16 +11,9 @@ export interface SSEEvent {
|
|
|
11
11
|
id?: string;
|
|
12
12
|
}
|
|
13
13
|
/**
|
|
14
|
-
* Parse
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* Handles:
|
|
18
|
-
* - Multi-line `data:` fields (concatenated with `\n`)
|
|
19
|
-
* - `event:` and `id:` fields
|
|
20
|
-
* - Proper `\n\n` event boundary detection
|
|
21
|
-
* - Graceful abort via `signal`
|
|
22
|
-
*
|
|
23
|
-
* The iterator ends when the stream closes or `signal` fires.
|
|
14
|
+
* Parse model SSE frames, retaining the normal EOF compatibility extension.
|
|
15
|
+
* Abort drops pending data; all early exits cancel once without awaiting cleanup.
|
|
16
|
+
* Throws SseFrameLimitError when an individual frame exceeds its capacity.
|
|
24
17
|
*/
|
|
25
18
|
export declare function parseSSEStream(body: ReadableStream<Uint8Array>, signal?: AbortSignal): AsyncIterableIterator<SSEEvent>;
|
|
26
19
|
/**
|
package/dist/llm/stream-utils.js
CHANGED
|
@@ -1,106 +1,92 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SSE stream parser and convenience helpers for LLM streaming.
|
|
3
3
|
*/
|
|
4
|
+
import { SseParser } from './sse-parser.js';
|
|
4
5
|
// ---------------------------------------------------------------------------
|
|
5
6
|
// Generic SSE parser
|
|
6
7
|
// ---------------------------------------------------------------------------
|
|
7
8
|
/**
|
|
8
|
-
* Parse
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* Handles:
|
|
12
|
-
* - Multi-line `data:` fields (concatenated with `\n`)
|
|
13
|
-
* - `event:` and `id:` fields
|
|
14
|
-
* - Proper `\n\n` event boundary detection
|
|
15
|
-
* - Graceful abort via `signal`
|
|
16
|
-
*
|
|
17
|
-
* The iterator ends when the stream closes or `signal` fires.
|
|
9
|
+
* Parse model SSE frames, retaining the normal EOF compatibility extension.
|
|
10
|
+
* Abort drops pending data; all early exits cancel once without awaiting cleanup.
|
|
11
|
+
* Throws SseFrameLimitError when an individual frame exceeds its capacity.
|
|
18
12
|
*/
|
|
19
13
|
export async function* parseSSEStream(body, signal) {
|
|
14
|
+
if (signal?.aborted)
|
|
15
|
+
return;
|
|
20
16
|
const reader = body.getReader();
|
|
21
|
-
const decoder = new TextDecoder();
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
17
|
+
const decoder = new TextDecoder('utf-8', { ignoreBOM: true });
|
|
18
|
+
const parser = new SseParser();
|
|
19
|
+
let reachedEof = false;
|
|
20
|
+
let cancelled = false;
|
|
21
|
+
const cancelOnce = () => {
|
|
22
|
+
if (cancelled)
|
|
23
|
+
return;
|
|
24
|
+
cancelled = true;
|
|
25
|
+
try {
|
|
26
|
+
// A transport can reject or never settle its cleanup promise.
|
|
27
|
+
void reader.cancel().catch(() => { });
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// A synchronous transport cleanup failure must not mask the stream result.
|
|
31
|
+
}
|
|
26
32
|
};
|
|
27
|
-
signal?.addEventListener('abort',
|
|
33
|
+
signal?.addEventListener('abort', cancelOnce, { once: true });
|
|
28
34
|
try {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
35
|
+
if (signal?.aborted) {
|
|
36
|
+
cancelOnce();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
while (!signal?.aborted) {
|
|
32
40
|
const { done, value } = await reader.read();
|
|
33
|
-
if (
|
|
41
|
+
if (signal?.aborted)
|
|
42
|
+
return;
|
|
43
|
+
if (done) {
|
|
44
|
+
reachedEof = true;
|
|
34
45
|
break;
|
|
35
|
-
buffer += decoder.decode(value, { stream: true });
|
|
36
|
-
// Process complete events separated by \n\n
|
|
37
|
-
let boundary;
|
|
38
|
-
while ((boundary = buffer.indexOf('\n\n')) !== -1) {
|
|
39
|
-
const rawEvent = buffer.slice(0, boundary);
|
|
40
|
-
buffer = buffer.slice(boundary + 2);
|
|
41
|
-
const parsed = parseSSEBlock(rawEvent);
|
|
42
|
-
if (parsed) {
|
|
43
|
-
yield parsed;
|
|
44
|
-
}
|
|
45
46
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
47
|
+
for (const event of decodeChunk(value, decoder, parser)) {
|
|
48
|
+
if (signal?.aborted)
|
|
49
|
+
return;
|
|
50
|
+
yield event;
|
|
51
|
+
if (signal?.aborted)
|
|
52
|
+
return;
|
|
52
53
|
}
|
|
53
54
|
}
|
|
55
|
+
if (signal?.aborted)
|
|
56
|
+
return;
|
|
57
|
+
for (const event of parser.push(decoder.decode())) {
|
|
58
|
+
if (signal?.aborted)
|
|
59
|
+
return;
|
|
60
|
+
yield event;
|
|
61
|
+
}
|
|
62
|
+
if (signal?.aborted)
|
|
63
|
+
return;
|
|
64
|
+
for (const event of parser.finish()) {
|
|
65
|
+
if (signal?.aborted)
|
|
66
|
+
return;
|
|
67
|
+
yield event;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
cancelOnce();
|
|
72
|
+
if (signal?.aborted)
|
|
73
|
+
return;
|
|
74
|
+
throw err;
|
|
54
75
|
}
|
|
55
76
|
finally {
|
|
56
|
-
signal?.removeEventListener('abort',
|
|
77
|
+
signal?.removeEventListener('abort', cancelOnce);
|
|
78
|
+
parser.clear();
|
|
79
|
+
if (!reachedEof)
|
|
80
|
+
cancelOnce();
|
|
57
81
|
reader.releaseLock();
|
|
58
82
|
}
|
|
59
83
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
let event;
|
|
66
|
-
let id;
|
|
67
|
-
const dataLines = [];
|
|
68
|
-
for (const line of block.split('\n')) {
|
|
69
|
-
// Comment lines start with ':'
|
|
70
|
-
if (line.startsWith(':'))
|
|
71
|
-
continue;
|
|
72
|
-
const colonIdx = line.indexOf(':');
|
|
73
|
-
if (colonIdx === -1) {
|
|
74
|
-
// Field with no value — treat field name as the value (per SSE spec)
|
|
75
|
-
continue;
|
|
76
|
-
}
|
|
77
|
-
const field = line.slice(0, colonIdx);
|
|
78
|
-
// Value starts after ':' — strip a single leading space if present
|
|
79
|
-
let value = line.slice(colonIdx + 1);
|
|
80
|
-
if (value.startsWith(' ')) {
|
|
81
|
-
value = value.slice(1);
|
|
82
|
-
}
|
|
83
|
-
switch (field) {
|
|
84
|
-
case 'event':
|
|
85
|
-
event = value;
|
|
86
|
-
break;
|
|
87
|
-
case 'data':
|
|
88
|
-
dataLines.push(value);
|
|
89
|
-
break;
|
|
90
|
-
case 'id':
|
|
91
|
-
id = value;
|
|
92
|
-
break;
|
|
93
|
-
// 'retry' and unknown fields are ignored
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
if (dataLines.length === 0 && event === undefined) {
|
|
97
|
-
return undefined;
|
|
84
|
+
function* decodeChunk(bytes, decoder, parser) {
|
|
85
|
+
const decodeSliceBytes = 16 * 1024;
|
|
86
|
+
for (let offset = 0; offset < bytes.length; offset += decodeSliceBytes) {
|
|
87
|
+
const slice = bytes.subarray(offset, offset + decodeSliceBytes);
|
|
88
|
+
yield* parser.push(decoder.decode(slice, { stream: true }));
|
|
98
89
|
}
|
|
99
|
-
return {
|
|
100
|
-
event,
|
|
101
|
-
data: dataLines.join('\n'),
|
|
102
|
-
id,
|
|
103
|
-
};
|
|
104
90
|
}
|
|
105
91
|
// ---------------------------------------------------------------------------
|
|
106
92
|
// Convenience: streamLLM / completeLLM
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aragon-agent/core",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.20",
|
|
4
4
|
"description": "AragonAgent core engine — LLM provider adapters, streaming, tool registry/execution, steering, idle watchdog, and an optional isolated-vm sandbox.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "AragonAgent contributors",
|