@falai/agent 2.4.3 → 2.6.0
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/cjs/core/FlowRouter.d.ts.map +1 -1
- package/dist/cjs/core/FlowRouter.js +0 -11
- package/dist/cjs/core/FlowRouter.js.map +1 -1
- package/dist/cjs/core/ResponseModal.d.ts +51 -2
- package/dist/cjs/core/ResponseModal.d.ts.map +1 -1
- package/dist/cjs/core/ResponseModal.js +272 -269
- package/dist/cjs/core/ResponseModal.js.map +1 -1
- package/dist/cjs/core/ToolLoopExecutor.d.ts +11 -0
- package/dist/cjs/core/ToolLoopExecutor.d.ts.map +1 -1
- package/dist/cjs/core/ToolLoopExecutor.js +75 -51
- package/dist/cjs/core/ToolLoopExecutor.js.map +1 -1
- package/dist/cjs/index.d.ts +3 -0
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +4 -2
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/providers/AnthropicProvider.d.ts.map +1 -1
- package/dist/cjs/providers/AnthropicProvider.js +18 -35
- package/dist/cjs/providers/AnthropicProvider.js.map +1 -1
- package/dist/cjs/providers/DeepSeekProvider.d.ts +1 -12
- package/dist/cjs/providers/DeepSeekProvider.d.ts.map +1 -1
- package/dist/cjs/providers/DeepSeekProvider.js +3 -20
- package/dist/cjs/providers/DeepSeekProvider.js.map +1 -1
- package/dist/cjs/providers/GeminiProvider.d.ts.map +1 -1
- package/dist/cjs/providers/GeminiProvider.js +22 -37
- package/dist/cjs/providers/GeminiProvider.js.map +1 -1
- package/dist/cjs/providers/GenericOpenAICompatibleProvider.d.ts +69 -0
- package/dist/cjs/providers/GenericOpenAICompatibleProvider.d.ts.map +1 -0
- package/dist/cjs/providers/GenericOpenAICompatibleProvider.js +93 -0
- package/dist/cjs/providers/GenericOpenAICompatibleProvider.js.map +1 -0
- package/dist/cjs/providers/OpenAICompatibleProvider.d.ts +23 -5
- package/dist/cjs/providers/OpenAICompatibleProvider.d.ts.map +1 -1
- package/dist/cjs/providers/OpenAICompatibleProvider.js +46 -44
- package/dist/cjs/providers/OpenAICompatibleProvider.js.map +1 -1
- package/dist/cjs/providers/index.d.ts +3 -1
- package/dist/cjs/providers/index.d.ts.map +1 -1
- package/dist/cjs/providers/index.js +3 -1
- package/dist/cjs/providers/index.js.map +1 -1
- package/dist/cjs/utils/completion.d.ts +20 -0
- package/dist/cjs/utils/completion.d.ts.map +1 -0
- package/dist/cjs/utils/completion.js +33 -0
- package/dist/cjs/utils/completion.js.map +1 -0
- package/dist/cjs/utils/index.d.ts +3 -2
- package/dist/cjs/utils/index.d.ts.map +1 -1
- package/dist/cjs/utils/index.js +7 -1
- package/dist/cjs/utils/index.js.map +1 -1
- package/dist/cjs/utils/retry.d.ts +43 -7
- package/dist/cjs/utils/retry.d.ts.map +1 -1
- package/dist/cjs/utils/retry.js +100 -12
- package/dist/cjs/utils/retry.js.map +1 -1
- package/dist/cjs/utils/streamingMessage.d.ts +48 -0
- package/dist/cjs/utils/streamingMessage.d.ts.map +1 -0
- package/dist/cjs/utils/streamingMessage.js +210 -0
- package/dist/cjs/utils/streamingMessage.js.map +1 -0
- package/dist/core/FlowRouter.d.ts.map +1 -1
- package/dist/core/FlowRouter.js +0 -11
- package/dist/core/FlowRouter.js.map +1 -1
- package/dist/core/ResponseModal.d.ts +51 -2
- package/dist/core/ResponseModal.d.ts.map +1 -1
- package/dist/core/ResponseModal.js +272 -269
- package/dist/core/ResponseModal.js.map +1 -1
- package/dist/core/ToolLoopExecutor.d.ts +11 -0
- package/dist/core/ToolLoopExecutor.d.ts.map +1 -1
- package/dist/core/ToolLoopExecutor.js +76 -52
- package/dist/core/ToolLoopExecutor.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/providers/AnthropicProvider.d.ts.map +1 -1
- package/dist/providers/AnthropicProvider.js +19 -36
- package/dist/providers/AnthropicProvider.js.map +1 -1
- package/dist/providers/DeepSeekProvider.d.ts +1 -12
- package/dist/providers/DeepSeekProvider.d.ts.map +1 -1
- package/dist/providers/DeepSeekProvider.js +3 -20
- package/dist/providers/DeepSeekProvider.js.map +1 -1
- package/dist/providers/GeminiProvider.d.ts.map +1 -1
- package/dist/providers/GeminiProvider.js +23 -38
- package/dist/providers/GeminiProvider.js.map +1 -1
- package/dist/providers/GenericOpenAICompatibleProvider.d.ts +69 -0
- package/dist/providers/GenericOpenAICompatibleProvider.d.ts.map +1 -0
- package/dist/providers/GenericOpenAICompatibleProvider.js +87 -0
- package/dist/providers/GenericOpenAICompatibleProvider.js.map +1 -0
- package/dist/providers/OpenAICompatibleProvider.d.ts +23 -5
- package/dist/providers/OpenAICompatibleProvider.d.ts.map +1 -1
- package/dist/providers/OpenAICompatibleProvider.js +47 -45
- package/dist/providers/OpenAICompatibleProvider.js.map +1 -1
- package/dist/providers/index.d.ts +3 -1
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/index.js +1 -0
- package/dist/providers/index.js.map +1 -1
- package/dist/utils/completion.d.ts +20 -0
- package/dist/utils/completion.d.ts.map +1 -0
- package/dist/utils/completion.js +29 -0
- package/dist/utils/completion.js.map +1 -0
- package/dist/utils/index.d.ts +3 -2
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +3 -1
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/retry.d.ts +43 -7
- package/dist/utils/retry.d.ts.map +1 -1
- package/dist/utils/retry.js +98 -12
- package/dist/utils/retry.js.map +1 -1
- package/dist/utils/streamingMessage.d.ts +48 -0
- package/dist/utils/streamingMessage.d.ts.map +1 -0
- package/dist/utils/streamingMessage.js +205 -0
- package/dist/utils/streamingMessage.js.map +1 -0
- package/package.json +1 -1
- package/src/core/FlowRouter.ts +0 -14
- package/src/core/ResponseModal.ts +332 -299
- package/src/core/ToolLoopExecutor.ts +108 -52
- package/src/index.ts +3 -0
- package/src/providers/AnthropicProvider.ts +22 -43
- package/src/providers/DeepSeekProvider.ts +3 -35
- package/src/providers/GeminiProvider.ts +27 -46
- package/src/providers/GenericOpenAICompatibleProvider.ts +138 -0
- package/src/providers/OpenAICompatibleProvider.ts +87 -67
- package/src/providers/index.ts +4 -0
- package/src/utils/completion.ts +38 -0
- package/src/utils/index.ts +5 -2
- package/src/utils/retry.ts +136 -14
- package/src/utils/streamingMessage.ts +220 -0
package/src/utils/retry.ts
CHANGED
|
@@ -11,6 +11,34 @@ export interface RetryOptions<T> {
|
|
|
11
11
|
onFailure?: (error: unknown) => boolean;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/** Provider timeout (ms) + retry count, after defaults are applied. */
|
|
15
|
+
export interface RetryConfig {
|
|
16
|
+
timeout: number;
|
|
17
|
+
retries: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DEFAULT_RETRY_CONFIG: RetryConfig = {
|
|
21
|
+
timeout: 60000,
|
|
22
|
+
retries: 3,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Normalize a provider's optional retry config against the defaults. `timeout`
|
|
27
|
+
* uses `||` (a 0ms timeout is degenerate — it aborts every call immediately — so
|
|
28
|
+
* fall back to the default), while `retries` uses `??` so an explicit
|
|
29
|
+
* `retries: 0` (disable retries) is honored rather than treated as unset. Single
|
|
30
|
+
* definition so this distinction can't drift between providers.
|
|
31
|
+
*/
|
|
32
|
+
export function resolveRetryConfig(input?: {
|
|
33
|
+
timeout?: number;
|
|
34
|
+
retries?: number;
|
|
35
|
+
}): RetryConfig {
|
|
36
|
+
return {
|
|
37
|
+
timeout: input?.timeout || DEFAULT_RETRY_CONFIG.timeout,
|
|
38
|
+
retries: input?.retries ?? DEFAULT_RETRY_CONFIG.retries,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
14
42
|
export async function retry<T>(options: RetryOptions<T>): Promise<T> {
|
|
15
43
|
const { operation, maxRetries, delay, onRetry, onFailure } = options;
|
|
16
44
|
|
|
@@ -43,8 +71,44 @@ export async function retry<T>(options: RetryOptions<T>): Promise<T> {
|
|
|
43
71
|
throw lastError ?? new Error("Operation failed");
|
|
44
72
|
}
|
|
45
73
|
|
|
74
|
+
/** Capped exponential backoff (1s, 2s, 4s, … max 5s) shared by the retry helpers. */
|
|
75
|
+
const defaultBackoff = (attempt: number): number =>
|
|
76
|
+
Math.min(1000 * Math.pow(2, attempt), 5000);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Combine abort signals into one that aborts as soon as any input aborts.
|
|
80
|
+
* `undefined` inputs are skipped, so a caller can pass an optional caller-signal
|
|
81
|
+
* alongside a required per-attempt one. Returns the sole signal when only one is
|
|
82
|
+
* present (no wrapper), or `undefined` when none are.
|
|
83
|
+
*
|
|
84
|
+
* Prefers the platform `AbortSignal.any`, which cleans up its listeners via weak
|
|
85
|
+
* refs — so merging onto a long-lived caller signal that's reused across many
|
|
86
|
+
* calls can't accumulate listeners. Falls back to a manual controller on older
|
|
87
|
+
* runtimes (< Node 20.3); there the listeners live until a source aborts or is
|
|
88
|
+
* garbage-collected, which is fine for the usual per-request signal.
|
|
89
|
+
*/
|
|
90
|
+
export function combineAbortSignals(
|
|
91
|
+
...signals: Array<AbortSignal | undefined>
|
|
92
|
+
): AbortSignal | undefined {
|
|
93
|
+
const present = signals.filter((s): s is AbortSignal => s != null);
|
|
94
|
+
if (present.length <= 1) return present[0];
|
|
95
|
+
if (typeof AbortSignal.any === "function") return AbortSignal.any(present);
|
|
96
|
+
|
|
97
|
+
const controller = new AbortController();
|
|
98
|
+
for (const signal of present) {
|
|
99
|
+
if (signal.aborted) {
|
|
100
|
+
controller.abort(signal.reason);
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
signal.addEventListener("abort", () => controller.abort(signal.reason), {
|
|
104
|
+
once: true,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return controller.signal;
|
|
108
|
+
}
|
|
109
|
+
|
|
46
110
|
export const withTimeoutAndRetry = async <T>(
|
|
47
|
-
operation: () => Promise<T>,
|
|
111
|
+
operation: (signal: AbortSignal) => Promise<T>,
|
|
48
112
|
timeoutMs: number = 60000,
|
|
49
113
|
maxRetries: number = 3,
|
|
50
114
|
operationName: string = "AI operation"
|
|
@@ -56,8 +120,11 @@ export const withTimeoutAndRetry = async <T>(
|
|
|
56
120
|
}, timeoutMs);
|
|
57
121
|
|
|
58
122
|
try {
|
|
123
|
+
// Hand the operation the timeout signal so the in-flight upstream call is
|
|
124
|
+
// actually cancelled when the deadline fires — otherwise the abandoned
|
|
125
|
+
// attempt keeps running while the retry stacks a second concurrent call.
|
|
59
126
|
const result = await Promise.race([
|
|
60
|
-
operation(),
|
|
127
|
+
operation(controller.signal),
|
|
61
128
|
new Promise<never>((_, reject) => {
|
|
62
129
|
controller.signal.addEventListener("abort", () => {
|
|
63
130
|
reject(new Error(`Operation timed out after ${timeoutMs}ms`));
|
|
@@ -76,14 +143,14 @@ export const withTimeoutAndRetry = async <T>(
|
|
|
76
143
|
return retry<T>({
|
|
77
144
|
operation: createTimeoutOperation(),
|
|
78
145
|
maxRetries,
|
|
79
|
-
delay:
|
|
146
|
+
delay: defaultBackoff,
|
|
80
147
|
onRetry: (attempt: number, error: unknown) => {
|
|
81
148
|
const message = error instanceof Error ? error.message : String(error);
|
|
82
149
|
logger.error(
|
|
83
150
|
`[${operationName}] Failed attempt ${attempt + 1}:`,
|
|
84
151
|
message
|
|
85
152
|
);
|
|
86
|
-
const delay =
|
|
153
|
+
const delay = defaultBackoff(attempt);
|
|
87
154
|
logger.debug(`[${operationName}] Retrying in ${delay}ms...`);
|
|
88
155
|
logger.debug(
|
|
89
156
|
`[${operationName}] Attempt ${attempt + 2}/${maxRetries + 1}`
|
|
@@ -103,38 +170,81 @@ export interface StreamRetryOptions {
|
|
|
103
170
|
delay?: (attempt: number) => number;
|
|
104
171
|
/** Label used in retry logs. */
|
|
105
172
|
operationName?: string;
|
|
173
|
+
/**
|
|
174
|
+
* Max ms to wait for the *first* chunk (time-to-first-token) before treating
|
|
175
|
+
* the attempt as failed. Guards a provider that opens a stream and then
|
|
176
|
+
* stalls. Only the first chunk is bounded — later chunks are unbounded so a
|
|
177
|
+
* long but healthy stream is never cut off. `0`/omitted disables the deadline.
|
|
178
|
+
*/
|
|
179
|
+
firstChunkTimeoutMs?: number;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Reject if `next` hasn't settled within `timeoutMs`; always clears its timer. */
|
|
183
|
+
function raceFirstChunk<T>(
|
|
184
|
+
next: Promise<IteratorResult<T>>,
|
|
185
|
+
timeoutMs: number,
|
|
186
|
+
operationName: string
|
|
187
|
+
): Promise<IteratorResult<T>> {
|
|
188
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
189
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
190
|
+
timer = setTimeout(
|
|
191
|
+
() =>
|
|
192
|
+
reject(
|
|
193
|
+
new Error(
|
|
194
|
+
`[${operationName}] Stream timed out: no first chunk within ${timeoutMs}ms`
|
|
195
|
+
)
|
|
196
|
+
),
|
|
197
|
+
timeoutMs
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
return Promise.race([next, timeout]).finally(() => clearTimeout(timer));
|
|
106
201
|
}
|
|
107
202
|
|
|
108
203
|
/**
|
|
109
204
|
* Streaming analog of {@link withTimeoutAndRetry}. Re-runs an async-generator
|
|
110
|
-
* factory as long as it fails *before yielding its first chunk* —
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* consumer has already received.
|
|
205
|
+
* factory as long as it fails *before yielding its first chunk* — an empty
|
|
206
|
+
* completion that throws "No response", an error while establishing the stream,
|
|
207
|
+
* or (with `firstChunkTimeoutMs`) a stall before the first token. Once any chunk
|
|
208
|
+
* has been yielded the stream is committed and further errors propagate, so a
|
|
209
|
+
* retry can never double-emit deltas the consumer has already received.
|
|
115
210
|
*
|
|
116
211
|
* This mirrors the non-streaming path, where the provider throws on an empty
|
|
117
212
|
* completion inside `withTimeoutAndRetry` and is retried on the same model
|
|
118
213
|
* before the caller falls through to backup models.
|
|
119
214
|
*/
|
|
120
215
|
export async function* withStreamRetry<T>(
|
|
121
|
-
factory: () => AsyncGenerator<T>,
|
|
216
|
+
factory: (signal: AbortSignal) => AsyncGenerator<T>,
|
|
122
217
|
options: StreamRetryOptions = {}
|
|
123
218
|
): AsyncGenerator<T> {
|
|
124
219
|
const {
|
|
125
220
|
maxRetries = 3,
|
|
126
|
-
delay =
|
|
221
|
+
delay = defaultBackoff,
|
|
127
222
|
operationName = "AI stream",
|
|
223
|
+
firstChunkTimeoutMs,
|
|
128
224
|
} = options;
|
|
129
225
|
|
|
130
226
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
227
|
+
// One controller per attempt: aborting it on abandon cancels the upstream
|
|
228
|
+
// SDK call so a retry can't stack a second concurrent stream.
|
|
229
|
+
const controller = new AbortController();
|
|
230
|
+
const iterator = factory(controller.signal);
|
|
131
231
|
let yielded = false;
|
|
232
|
+
let completed = false;
|
|
132
233
|
try {
|
|
133
|
-
|
|
234
|
+
// Drive the iterator by hand so the first chunk can be raced against a
|
|
235
|
+
// deadline; for-await would give no hook for that.
|
|
236
|
+
for (;;) {
|
|
237
|
+
const result =
|
|
238
|
+
!yielded && firstChunkTimeoutMs
|
|
239
|
+
? await raceFirstChunk(iterator.next(), firstChunkTimeoutMs, operationName)
|
|
240
|
+
: await iterator.next();
|
|
241
|
+
if (result.done) {
|
|
242
|
+
completed = true;
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
134
245
|
yielded = true;
|
|
135
|
-
yield
|
|
246
|
+
yield result.value;
|
|
136
247
|
}
|
|
137
|
-
return;
|
|
138
248
|
} catch (error: unknown) {
|
|
139
249
|
// Can't retry once deltas are out, and don't retry past the budget.
|
|
140
250
|
if (yielded || attempt === maxRetries) {
|
|
@@ -147,6 +257,18 @@ export async function* withStreamRetry<T>(
|
|
|
147
257
|
`[${operationName}] Retrying in ${delayMs}ms... (attempt ${attempt + 2}/${maxRetries + 1})`
|
|
148
258
|
);
|
|
149
259
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
260
|
+
} finally {
|
|
261
|
+
// Abandoning an attempt (first-chunk timeout, pre-yield error before a
|
|
262
|
+
// retry, or a consumer that breaks early): abort the upstream SDK call
|
|
263
|
+
// first so it's actually torn down — not left running while we retry —
|
|
264
|
+
// then dispose the iterator. Aborting also unblocks a stalled
|
|
265
|
+
// iterator.next() so return() can run the generator's cleanup. Both are
|
|
266
|
+
// fire-and-forget: a wedged generator's return() may never settle, so we
|
|
267
|
+
// must not await it.
|
|
268
|
+
if (!completed) {
|
|
269
|
+
controller.abort();
|
|
270
|
+
void iterator.return?.(undefined)?.catch(() => undefined);
|
|
271
|
+
}
|
|
150
272
|
}
|
|
151
273
|
}
|
|
152
274
|
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Incremental extraction of the `message` field from a streamed structured
|
|
3
|
+
* JSON response.
|
|
4
|
+
*
|
|
5
|
+
* Providers that enforce a JSON schema stream the raw wrapper object
|
|
6
|
+
* (`{"message":"Hel` → `{"message":"Hello"}`), not the message text. Without
|
|
7
|
+
* this, every streaming consumer has to re-implement partial-JSON unwrapping to
|
|
8
|
+
* recover the user-facing tokens. These helpers do it once, at the framework
|
|
9
|
+
* boundary, so `delta`/`accumulated` carry clean message text and the parsed
|
|
10
|
+
* object is surfaced only when complete.
|
|
11
|
+
*
|
|
12
|
+
* The extractor targets the top-level `message` string field specifically
|
|
13
|
+
* (tracking object depth and string context, so a nested decoy `"message"` key
|
|
14
|
+
* or a `"message"` substring inside another value is never mistaken for it),
|
|
15
|
+
* and is tolerant of truncation at any byte — a dangling escape sequence is
|
|
16
|
+
* held back rather than emitted half-decoded. Input that is not a JSON object
|
|
17
|
+
* (e.g. a provider streaming plain text) is passed through verbatim.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const WHITESPACE = " \t\n\r";
|
|
21
|
+
|
|
22
|
+
const ESCAPE_CHARS: Record<string, string> = {
|
|
23
|
+
'"': '"',
|
|
24
|
+
"\\": "\\",
|
|
25
|
+
"/": "/",
|
|
26
|
+
b: "\b",
|
|
27
|
+
f: "\f",
|
|
28
|
+
n: "\n",
|
|
29
|
+
r: "\r",
|
|
30
|
+
t: "\t",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
interface StringRead {
|
|
34
|
+
/** Decoded value, or decoded prefix when the closing quote has not arrived. */
|
|
35
|
+
value: string;
|
|
36
|
+
/** Index just past the closing quote when complete; the input length otherwise. */
|
|
37
|
+
end: number;
|
|
38
|
+
/** Whether the closing quote was seen. */
|
|
39
|
+
complete: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Read a JSON string token whose opening quote is at `s[start]`. Decodes
|
|
44
|
+
* escapes. When the closing quote has not arrived, returns the decoded prefix
|
|
45
|
+
* with any trailing incomplete escape (`\` or a partial `\uXXXX`) held back, so
|
|
46
|
+
* a half-decoded character is never produced.
|
|
47
|
+
*/
|
|
48
|
+
function readJsonString(s: string, start: number): StringRead {
|
|
49
|
+
let out = "";
|
|
50
|
+
const n = s.length;
|
|
51
|
+
let i = start + 1; // skip opening quote
|
|
52
|
+
|
|
53
|
+
while (i < n) {
|
|
54
|
+
const ch = s[i];
|
|
55
|
+
|
|
56
|
+
if (ch === '"') {
|
|
57
|
+
return { value: out, end: i + 1, complete: true };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (ch === "\\") {
|
|
61
|
+
const esc = s[i + 1];
|
|
62
|
+
if (esc === undefined) {
|
|
63
|
+
// Dangling backslash — wait for the rest of the escape.
|
|
64
|
+
return { value: out, end: n, complete: false };
|
|
65
|
+
}
|
|
66
|
+
if (esc === "u") {
|
|
67
|
+
if (i + 6 > n) {
|
|
68
|
+
// Incomplete \uXXXX — hold it back.
|
|
69
|
+
return { value: out, end: n, complete: false };
|
|
70
|
+
}
|
|
71
|
+
const code = parseInt(s.slice(i + 2, i + 6), 16);
|
|
72
|
+
if (Number.isNaN(code)) {
|
|
73
|
+
return { value: out, end: n, complete: false };
|
|
74
|
+
}
|
|
75
|
+
out += String.fromCharCode(code);
|
|
76
|
+
i += 6;
|
|
77
|
+
} else if (esc in ESCAPE_CHARS) {
|
|
78
|
+
out += ESCAPE_CHARS[esc];
|
|
79
|
+
i += 2;
|
|
80
|
+
} else {
|
|
81
|
+
// Not a valid JSON escape; pass the character through leniently.
|
|
82
|
+
out += esc;
|
|
83
|
+
i += 2;
|
|
84
|
+
}
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
out += ch;
|
|
89
|
+
i++;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { value: out, end: n, complete: false };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface ValueSkip {
|
|
96
|
+
end: number;
|
|
97
|
+
complete: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Skip one JSON value (string, object, array, or primitive) starting at `s[i]`.
|
|
102
|
+
* Tolerant of truncation: an unfinished value reports `complete: false`.
|
|
103
|
+
*/
|
|
104
|
+
function skipJsonValue(s: string, i: number): ValueSkip {
|
|
105
|
+
const n = s.length;
|
|
106
|
+
if (i >= n) return { end: n, complete: false };
|
|
107
|
+
|
|
108
|
+
const ch = s[i];
|
|
109
|
+
|
|
110
|
+
if (ch === '"') {
|
|
111
|
+
const r = readJsonString(s, i);
|
|
112
|
+
return { end: r.end, complete: r.complete };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (ch === "{" || ch === "[") {
|
|
116
|
+
let depth = 0;
|
|
117
|
+
let j = i;
|
|
118
|
+
while (j < n) {
|
|
119
|
+
const c = s[j];
|
|
120
|
+
if (c === '"') {
|
|
121
|
+
const r = readJsonString(s, j);
|
|
122
|
+
if (!r.complete) return { end: n, complete: false };
|
|
123
|
+
j = r.end;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (c === "{" || c === "[") depth++;
|
|
127
|
+
else if (c === "}" || c === "]") {
|
|
128
|
+
depth--;
|
|
129
|
+
if (depth === 0) return { end: j + 1, complete: true };
|
|
130
|
+
}
|
|
131
|
+
j++;
|
|
132
|
+
}
|
|
133
|
+
return { end: n, complete: false };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Primitive (number, true, false, null): runs until a structural delimiter.
|
|
137
|
+
// Hitting end-of-input first means it may still be streaming.
|
|
138
|
+
let j = i;
|
|
139
|
+
while (j < n && !`,}]${WHITESPACE}`.includes(s[j])) j++;
|
|
140
|
+
return j < n ? { end: j, complete: true } : { end: n, complete: false };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Extract the decoded value of the top-level `message` string field from a
|
|
145
|
+
* (possibly partial) JSON object string, returning the text available so far.
|
|
146
|
+
*
|
|
147
|
+
* Returns `""` while the `message` value has not begun streaming, and passes
|
|
148
|
+
* `accumulated` through unchanged when it is not a JSON object.
|
|
149
|
+
*/
|
|
150
|
+
export function extractMessageSoFar(accumulated: string): string {
|
|
151
|
+
const s = accumulated;
|
|
152
|
+
const n = s.length;
|
|
153
|
+
|
|
154
|
+
let i = 0;
|
|
155
|
+
while (i < n && WHITESPACE.includes(s[i])) i++;
|
|
156
|
+
|
|
157
|
+
// Not a JSON object — a plain-text stream; emit verbatim.
|
|
158
|
+
if (i >= n || s[i] !== "{") return accumulated;
|
|
159
|
+
i++; // skip '{'
|
|
160
|
+
|
|
161
|
+
while (i < n) {
|
|
162
|
+
while (i < n && (WHITESPACE.includes(s[i]) || s[i] === ",")) i++;
|
|
163
|
+
if (i >= n) return "";
|
|
164
|
+
if (s[i] === "}") return ""; // object closed without a message
|
|
165
|
+
if (s[i] !== '"') return ""; // key not (fully) arrived
|
|
166
|
+
|
|
167
|
+
const key = readJsonString(s, i);
|
|
168
|
+
if (!key.complete) return ""; // key still streaming
|
|
169
|
+
i = key.end;
|
|
170
|
+
|
|
171
|
+
while (i < n && WHITESPACE.includes(s[i])) i++;
|
|
172
|
+
if (i >= n || s[i] !== ":") return ""; // colon not arrived
|
|
173
|
+
i++;
|
|
174
|
+
while (i < n && WHITESPACE.includes(s[i])) i++;
|
|
175
|
+
if (i >= n) return "";
|
|
176
|
+
|
|
177
|
+
if (key.value === "message") {
|
|
178
|
+
// Found it. Only a string value yields text; null/other → no message yet.
|
|
179
|
+
if (s[i] !== '"') return "";
|
|
180
|
+
return readJsonString(s, i).value;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// A field before `message`: skip its value. If it is still streaming we
|
|
184
|
+
// cannot have reached `message` yet.
|
|
185
|
+
const skipped = skipJsonValue(s, i);
|
|
186
|
+
if (!skipped.complete) return "";
|
|
187
|
+
i = skipped.end;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return "";
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Stateful wrapper over {@link extractMessageSoFar} for a single stream: feed
|
|
195
|
+
* each chunk's accumulated JSON and get back the clean message-so-far plus the
|
|
196
|
+
* newly revealed delta.
|
|
197
|
+
*
|
|
198
|
+
* Each push re-scans the full accumulated buffer (O(n) per chunk, O(n²) over a
|
|
199
|
+
* stream) — deliberately kept simple and stateless: at LLM response sizes (KBs)
|
|
200
|
+
* the cost is negligible, and it avoids carrying cross-chunk parser/escape state.
|
|
201
|
+
*/
|
|
202
|
+
export class StreamingMessageDecoder {
|
|
203
|
+
private previous = "";
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* @param accumulated The provider chunk's full accumulated output so far.
|
|
207
|
+
* @returns `message` (clean text so far) and `delta` (the new text since the
|
|
208
|
+
* previous push).
|
|
209
|
+
*/
|
|
210
|
+
push(accumulated: string): { message: string; delta: string } {
|
|
211
|
+
const message = extractMessageSoFar(accumulated);
|
|
212
|
+
// Decoding is monotonic (each push extends the prefix); the guard is a
|
|
213
|
+
// belt-and-braces reset for any non-prefix anomaly.
|
|
214
|
+
const delta = message.startsWith(this.previous)
|
|
215
|
+
? message.slice(this.previous.length)
|
|
216
|
+
: message;
|
|
217
|
+
this.previous = message;
|
|
218
|
+
return { message, delta };
|
|
219
|
+
}
|
|
220
|
+
}
|