@pylonsync/react 0.4.21 → 0.4.23
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/index.d.ts +44 -1
- package/dist/useAgentRun.d.ts +47 -0
- package/package.json +3 -3
- package/src/index.ts +230 -48
- package/src/useAgentRun.ts +0 -0
package/dist/index.d.ts
CHANGED
|
@@ -16,6 +16,8 @@ export type { PylonRouter } from "./useRouter";
|
|
|
16
16
|
import { type Storage as PylonStorage } from "@pylonsync/sync";
|
|
17
17
|
export { useQuery, useQueryOne, useReactiveQuery, useMutation, useInfiniteQuery, usePaginatedQuery, useEntityMutation, useAction, useQueryRaw, useQueryOneRaw, useLiveList, useLiveRow, useInsert, useUpdate, useDelete, useFn, useAggregate, useSearch, } from "./hooks";
|
|
18
18
|
export type { QueryOptions, QueryFilter, IncludeSpec, UseQueryReturn, UseQueryOneReturn, UseReactiveQueryReturn, UseMutationReturn, UseInfiniteQueryReturn, UsePaginatedQueryReturn, PaginatedQueryStatus, UseFnReturn, AggregateSpec, UseAggregateReturn, SearchSpec, UseSearchReturn, } from "./hooks";
|
|
19
|
+
export { useAgentRun } from "./useAgentRun";
|
|
20
|
+
export type { AgentRunRow, AgentMessageRow, UseAgentRunReturn, } from "./useAgentRun";
|
|
19
21
|
export { useRoom, useRoomMessages } from "./useRoom";
|
|
20
22
|
export type { RoomPeer, RoomSnapshot, UseRoomOptions, UseRoomReturn, } from "./useRoom";
|
|
21
23
|
export type { RoomMessage } from "@pylonsync/sync";
|
|
@@ -142,9 +144,38 @@ export declare function getReactStorage(): PylonStorage;
|
|
|
142
144
|
export declare function callFn<T = unknown>(name: string, args?: Record<string, unknown>, options?: {
|
|
143
145
|
token?: string;
|
|
144
146
|
}): Promise<T>;
|
|
147
|
+
/** Options for {@link streamFn}. */
|
|
148
|
+
export interface StreamFnOptions {
|
|
149
|
+
token?: string;
|
|
150
|
+
/**
|
|
151
|
+
* Auto-resume on abrupt network failure (default true). The server
|
|
152
|
+
* buffers every fn stream server-side; when the connection drops
|
|
153
|
+
* mid-stream, the generator silently reconnects to
|
|
154
|
+
* `GET /api/fn-streams/<id>` from its last seen cursor and keeps
|
|
155
|
+
* yielding — no chunk is duplicated or lost, and the final result
|
|
156
|
+
* still arrives even if the reconnect lands after the handler
|
|
157
|
+
* finished. Set false to surface disconnects as errors instead.
|
|
158
|
+
*/
|
|
159
|
+
resume?: boolean;
|
|
160
|
+
/** Reconnect attempts per silent gap (default 3). The budget resets
|
|
161
|
+
* whenever a resumed connection makes progress. */
|
|
162
|
+
maxResumes?: number;
|
|
163
|
+
/**
|
|
164
|
+
* Called with the server-assigned stream id as soon as the response
|
|
165
|
+
* upgrades to SSE. Persist it (e.g. in your row for the agent run)
|
|
166
|
+
* to resume from a different tab or a full page reload via
|
|
167
|
+
* {@link resumeStream}.
|
|
168
|
+
*/
|
|
169
|
+
onStreamId?: (streamId: string) => void;
|
|
170
|
+
}
|
|
145
171
|
/**
|
|
146
172
|
* Stream a server-side function's output as Server-Sent Events.
|
|
147
173
|
*
|
|
174
|
+
* Streams are resumable: the server buffers every frame under a stream
|
|
175
|
+
* id (see `X-Pylon-Stream-Id`), so a dropped connection reconnects and
|
|
176
|
+
* catches up transparently — including receiving the final result
|
|
177
|
+
* after the handler already finished. Disable with `resume: false`.
|
|
178
|
+
*
|
|
148
179
|
* @example
|
|
149
180
|
* ```ts
|
|
150
181
|
* for await (const chunk of streamFn("chat", { message: "hello" })) {
|
|
@@ -152,8 +183,20 @@ export declare function callFn<T = unknown>(name: string, args?: Record<string,
|
|
|
152
183
|
* }
|
|
153
184
|
* ```
|
|
154
185
|
*/
|
|
155
|
-
export declare function streamFn(name: string, args?: Record<string, unknown>, options?:
|
|
186
|
+
export declare function streamFn(name: string, args?: Record<string, unknown>, options?: StreamFnOptions): AsyncGenerator<string, unknown, unknown>;
|
|
187
|
+
/**
|
|
188
|
+
* Attach to a buffered fn stream by id — from another tab, a page
|
|
189
|
+
* reload, or any time after the original `streamFn` call went away.
|
|
190
|
+
* Replays from `since` (default 0 = everything, including the final
|
|
191
|
+
* result if the handler already finished), then live-tails.
|
|
192
|
+
*
|
|
193
|
+
* Get the id from `streamFn`'s `onStreamId` callback and persist it
|
|
194
|
+
* wherever the run's state lives. Completed streams stay resumable for
|
|
195
|
+
* an hour (PYLON_STREAM_RETAIN_SECS).
|
|
196
|
+
*/
|
|
197
|
+
export declare function resumeStream(streamId: string, options?: {
|
|
156
198
|
token?: string;
|
|
199
|
+
since?: number;
|
|
157
200
|
}): AsyncGenerator<string, unknown, unknown>;
|
|
158
201
|
/**
|
|
159
202
|
* List all server-side functions available.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live view of an `agent()` run — the AgentRun row plus its ordered
|
|
3
|
+
* transcript — straight from the synced replica. Because AgentRun and
|
|
4
|
+
* AgentMessage are ordinary synced entities (owner-scoped by policy),
|
|
5
|
+
* this updates in real time on every one of the user's devices as the
|
|
6
|
+
* loop persists turns: user input, assistant text, tool calls, tool
|
|
7
|
+
* results.
|
|
8
|
+
*
|
|
9
|
+
* ```tsx
|
|
10
|
+
* const { run, messages } = useAgentRun(runId);
|
|
11
|
+
* // messages[i]: { role: "user" | "assistant", content: string | LlmContentBlock[] , seq, ... }
|
|
12
|
+
* // run.status: "idle" | "running" | "completed" | "failed"
|
|
13
|
+
* // run.streamId: attach to live tokens with resumeStream(run.streamId)
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* Start or continue a run with `streamFn("<agentName>", { input, runId? })`
|
|
17
|
+
* — tokens stream on that connection; the persisted transcript arrives
|
|
18
|
+
* here.
|
|
19
|
+
*/
|
|
20
|
+
export interface AgentRunRow {
|
|
21
|
+
id: string;
|
|
22
|
+
agent: string;
|
|
23
|
+
status: "idle" | "running" | "completed" | "failed" | string;
|
|
24
|
+
userId?: string | null;
|
|
25
|
+
title?: string | null;
|
|
26
|
+
/** Resumable-stream id of the current/last generation. */
|
|
27
|
+
streamId?: string | null;
|
|
28
|
+
error?: string | null;
|
|
29
|
+
createdAt: string;
|
|
30
|
+
updatedAt: string;
|
|
31
|
+
}
|
|
32
|
+
export interface AgentMessageRow {
|
|
33
|
+
id: string;
|
|
34
|
+
runId: string;
|
|
35
|
+
seq: number;
|
|
36
|
+
role: "user" | "assistant" | string;
|
|
37
|
+
/** A string for plain turns, or LlmContentBlock[] for assistant
|
|
38
|
+
* turns with tool_use and tool_result batches. */
|
|
39
|
+
content: unknown;
|
|
40
|
+
createdAt: string;
|
|
41
|
+
}
|
|
42
|
+
export interface UseAgentRunReturn {
|
|
43
|
+
run: AgentRunRow | null;
|
|
44
|
+
messages: AgentMessageRow[];
|
|
45
|
+
loading: boolean;
|
|
46
|
+
}
|
|
47
|
+
export declare function useAgentRun(runId: string | null | undefined): UseAgentRunReturn;
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.4.
|
|
6
|
+
"version": "0.4.23",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"main": "./src/index.ts",
|
|
9
9
|
"types": "./dist/index.d.ts",
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
"prepack": "bun run build"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@pylonsync/sdk": "0.4.
|
|
18
|
-
"@pylonsync/sync": "0.4.
|
|
17
|
+
"@pylonsync/sdk": "0.4.23",
|
|
18
|
+
"@pylonsync/sync": "0.4.23"
|
|
19
19
|
},
|
|
20
20
|
"peerDependencies": {
|
|
21
21
|
"react": ">=19.0.0"
|
package/src/index.ts
CHANGED
|
@@ -107,6 +107,14 @@ export type {
|
|
|
107
107
|
UseSearchReturn,
|
|
108
108
|
} from "./hooks";
|
|
109
109
|
|
|
110
|
+
// Agent run hook — live transcript + status for agent() runs
|
|
111
|
+
export { useAgentRun } from "./useAgentRun";
|
|
112
|
+
export type {
|
|
113
|
+
AgentRunRow,
|
|
114
|
+
AgentMessageRow,
|
|
115
|
+
UseAgentRunReturn,
|
|
116
|
+
} from "./useAgentRun";
|
|
117
|
+
|
|
110
118
|
// Room hook
|
|
111
119
|
export { useRoom, useRoomMessages } from "./useRoom";
|
|
112
120
|
export type {
|
|
@@ -517,9 +525,113 @@ export async function callFn<T = unknown>(
|
|
|
517
525
|
);
|
|
518
526
|
}
|
|
519
527
|
|
|
528
|
+
/** Options for {@link streamFn}. */
|
|
529
|
+
export interface StreamFnOptions {
|
|
530
|
+
token?: string;
|
|
531
|
+
/**
|
|
532
|
+
* Auto-resume on abrupt network failure (default true). The server
|
|
533
|
+
* buffers every fn stream server-side; when the connection drops
|
|
534
|
+
* mid-stream, the generator silently reconnects to
|
|
535
|
+
* `GET /api/fn-streams/<id>` from its last seen cursor and keeps
|
|
536
|
+
* yielding — no chunk is duplicated or lost, and the final result
|
|
537
|
+
* still arrives even if the reconnect lands after the handler
|
|
538
|
+
* finished. Set false to surface disconnects as errors instead.
|
|
539
|
+
*/
|
|
540
|
+
resume?: boolean;
|
|
541
|
+
/** Reconnect attempts per silent gap (default 3). The budget resets
|
|
542
|
+
* whenever a resumed connection makes progress. */
|
|
543
|
+
maxResumes?: number;
|
|
544
|
+
/**
|
|
545
|
+
* Called with the server-assigned stream id as soon as the response
|
|
546
|
+
* upgrades to SSE. Persist it (e.g. in your row for the agent run)
|
|
547
|
+
* to resume from a different tab or a full page reload via
|
|
548
|
+
* {@link resumeStream}.
|
|
549
|
+
*/
|
|
550
|
+
onStreamId?: (streamId: string) => void;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
interface SseCursor {
|
|
554
|
+
lastSeq: number;
|
|
555
|
+
/** Set once the terminal result/error frame was seen. */
|
|
556
|
+
terminal: boolean;
|
|
557
|
+
finalResult: unknown;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Parse one SSE response body, yielding data frames and tracking the
|
|
561
|
+
* cursor. Multi-line payloads rejoin `data:` lines with '\n' per the
|
|
562
|
+
* SSE spec (the server splits them the same way). */
|
|
563
|
+
async function* consumeSseBody(
|
|
564
|
+
res: Response,
|
|
565
|
+
cursor: SseCursor,
|
|
566
|
+
): AsyncGenerator<string, void, unknown> {
|
|
567
|
+
if (!res.body) throw new Error(`Stream failed: HTTP ${res.status}`);
|
|
568
|
+
const reader = res.body.getReader();
|
|
569
|
+
const decoder = new TextDecoder();
|
|
570
|
+
let buffer = "";
|
|
571
|
+
try {
|
|
572
|
+
while (true) {
|
|
573
|
+
const { done, value } = await reader.read();
|
|
574
|
+
if (done) break;
|
|
575
|
+
buffer += decoder.decode(value, { stream: true });
|
|
576
|
+
const events = buffer.split("\n\n");
|
|
577
|
+
buffer = events.pop() || "";
|
|
578
|
+
|
|
579
|
+
for (const evt of events) {
|
|
580
|
+
if (!evt.trim() || evt.startsWith(":")) continue; // heartbeat
|
|
581
|
+
let eventType = "message";
|
|
582
|
+
const dataLines: string[] = [];
|
|
583
|
+
for (const line of evt.split("\n")) {
|
|
584
|
+
if (line.startsWith("event: ")) eventType = line.slice(7);
|
|
585
|
+
else if (line.startsWith("data: ")) dataLines.push(line.slice(6));
|
|
586
|
+
else if (line.startsWith("data:")) dataLines.push(line.slice(5));
|
|
587
|
+
else if (line.startsWith("id: ")) {
|
|
588
|
+
const seq = Number.parseInt(line.slice(4), 10);
|
|
589
|
+
if (Number.isFinite(seq)) cursor.lastSeq = seq;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
// Frames without any data line (the `retry:` prelude) carry
|
|
593
|
+
// nothing to deliver.
|
|
594
|
+
if (dataLines.length === 0) continue;
|
|
595
|
+
const data = dataLines.join("\n");
|
|
596
|
+
if (eventType === "result") {
|
|
597
|
+
cursor.terminal = true;
|
|
598
|
+
try {
|
|
599
|
+
cursor.finalResult = JSON.parse(data);
|
|
600
|
+
} catch {
|
|
601
|
+
cursor.finalResult = data;
|
|
602
|
+
}
|
|
603
|
+
} else if (eventType === "error") {
|
|
604
|
+
cursor.terminal = true;
|
|
605
|
+
let message = "Function error";
|
|
606
|
+
try {
|
|
607
|
+
const err = JSON.parse(data) as { message?: string };
|
|
608
|
+
if (err.message) message = err.message;
|
|
609
|
+
} catch {
|
|
610
|
+
// non-JSON error payload — keep the generic message
|
|
611
|
+
}
|
|
612
|
+
throw new Error(message);
|
|
613
|
+
} else {
|
|
614
|
+
yield data;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
} finally {
|
|
619
|
+
// Runs on normal completion AND when the consumer breaks out of
|
|
620
|
+
// its for-await (generator return). Without the cancel, the fetch
|
|
621
|
+
// body stays locked and the connection pins a browser socket (and
|
|
622
|
+
// a server stream slot) until page unload.
|
|
623
|
+
reader.cancel().catch(() => {});
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
520
627
|
/**
|
|
521
628
|
* Stream a server-side function's output as Server-Sent Events.
|
|
522
629
|
*
|
|
630
|
+
* Streams are resumable: the server buffers every frame under a stream
|
|
631
|
+
* id (see `X-Pylon-Stream-Id`), so a dropped connection reconnects and
|
|
632
|
+
* catches up transparently — including receiving the final result
|
|
633
|
+
* after the handler already finished. Disable with `resume: false`.
|
|
634
|
+
*
|
|
523
635
|
* @example
|
|
524
636
|
* ```ts
|
|
525
637
|
* for await (const chunk of streamFn("chat", { message: "hello" })) {
|
|
@@ -530,23 +642,20 @@ export async function callFn<T = unknown>(
|
|
|
530
642
|
export async function* streamFn(
|
|
531
643
|
name: string,
|
|
532
644
|
args: Record<string, unknown> = {},
|
|
533
|
-
options:
|
|
645
|
+
options: StreamFnOptions = {}
|
|
534
646
|
): AsyncGenerator<string, unknown, unknown> {
|
|
647
|
+
const transport = {
|
|
648
|
+
baseUrl: getBaseUrl(),
|
|
649
|
+
getToken: () => options.token ?? currentAuthToken() ?? undefined,
|
|
650
|
+
};
|
|
535
651
|
// Streaming response — use pylonFetchRaw so we can read .body
|
|
536
652
|
// ourselves. URL + auth + credentials are centralized in the
|
|
537
653
|
// transport.
|
|
538
|
-
const res = await pylonFetchRaw(
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
`/api/fn/${name}`,
|
|
544
|
-
{
|
|
545
|
-
method: "POST",
|
|
546
|
-
json: args,
|
|
547
|
-
accept: "text/event-stream",
|
|
548
|
-
},
|
|
549
|
-
);
|
|
654
|
+
const res = await pylonFetchRaw(transport, `/api/fn/${name}`, {
|
|
655
|
+
method: "POST",
|
|
656
|
+
json: args,
|
|
657
|
+
accept: "text/event-stream",
|
|
658
|
+
});
|
|
550
659
|
// The server only upgrades to SSE once the handler actually calls
|
|
551
660
|
// ctx.stream. A function that completes without streaming answers
|
|
552
661
|
// with plain JSON (Content-Type: application/json) — the raw return
|
|
@@ -571,50 +680,123 @@ export async function* streamFn(
|
|
|
571
680
|
return text;
|
|
572
681
|
}
|
|
573
682
|
}
|
|
574
|
-
if (!res.ok
|
|
683
|
+
if (!res.ok) {
|
|
575
684
|
throw new Error(`Stream failed: HTTP ${res.status}`);
|
|
576
685
|
}
|
|
577
686
|
|
|
578
|
-
const
|
|
579
|
-
|
|
580
|
-
let buffer = "";
|
|
581
|
-
let finalResult: unknown = undefined;
|
|
687
|
+
const streamId = res.headers.get("x-pylon-stream-id");
|
|
688
|
+
if (streamId && options.onStreamId) options.onStreamId(streamId);
|
|
582
689
|
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
buffer += decoder.decode(value, { stream: true });
|
|
587
|
-
const events = buffer.split("\n\n");
|
|
588
|
-
buffer = events.pop() || "";
|
|
690
|
+
const cursor: SseCursor = { lastSeq: 0, terminal: false, finalResult: undefined };
|
|
691
|
+
const resume = options.resume !== false && Boolean(streamId);
|
|
692
|
+
let attemptsLeft = options.maxResumes ?? 3;
|
|
589
693
|
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
try {
|
|
600
|
-
finalResult = JSON.parse(data);
|
|
601
|
-
} catch {
|
|
602
|
-
finalResult = data;
|
|
603
|
-
}
|
|
604
|
-
} else if (eventType === "error") {
|
|
605
|
-
try {
|
|
606
|
-
const err = JSON.parse(data) as { message?: string };
|
|
607
|
-
throw new Error(err.message || "Function error");
|
|
608
|
-
} catch (e) {
|
|
609
|
-
throw e instanceof Error ? e : new Error(String(e));
|
|
610
|
-
}
|
|
611
|
-
} else {
|
|
612
|
-
yield data;
|
|
694
|
+
// First pass over the live connection, then (on silent disconnect)
|
|
695
|
+
// resume passes over /api/fn-streams until the terminal frame.
|
|
696
|
+
let body: AsyncGenerator<string, void, unknown> = consumeSseBody(res, cursor);
|
|
697
|
+
for (;;) {
|
|
698
|
+
let progressed = false;
|
|
699
|
+
try {
|
|
700
|
+
for await (const chunk of body) {
|
|
701
|
+
progressed = true;
|
|
702
|
+
yield chunk;
|
|
613
703
|
}
|
|
704
|
+
} catch (e) {
|
|
705
|
+
if (cursor.terminal) throw e; // server-sent error frame — real
|
|
706
|
+
if (!resume || attemptsLeft <= 0) throw e; // network error, no budget
|
|
707
|
+
}
|
|
708
|
+
if (cursor.terminal) return cursor.finalResult;
|
|
709
|
+
// Connection ended without a terminal frame — silent disconnect.
|
|
710
|
+
if (!resume || attemptsLeft <= 0) {
|
|
711
|
+
throw new Error("Stream disconnected before completing");
|
|
614
712
|
}
|
|
713
|
+
if (progressed) attemptsLeft = options.maxResumes ?? 3;
|
|
714
|
+
attemptsLeft -= 1;
|
|
715
|
+
const resumed = await pylonFetchRaw(
|
|
716
|
+
transport,
|
|
717
|
+
`/api/fn-streams/${streamId}?since=${cursor.lastSeq}`,
|
|
718
|
+
{ method: "GET", accept: "text/event-stream" },
|
|
719
|
+
);
|
|
720
|
+
if (resumed.status === 503 && attemptsLeft > 0) {
|
|
721
|
+
// STREAM_OVERLOADED is transient by definition (Retry-After: 1) —
|
|
722
|
+
// failing here would kill exactly the stream resume exists to save.
|
|
723
|
+
await resumed.text().catch(() => {});
|
|
724
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
if (!resumed.ok) {
|
|
728
|
+
const text = await resumed.text().catch(() => "");
|
|
729
|
+
throw new Error(
|
|
730
|
+
`Stream resume failed: HTTP ${resumed.status}${text ? ` — ${text}` : ""}`,
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
body = consumeSseBody(resumed, cursor);
|
|
615
734
|
}
|
|
735
|
+
}
|
|
616
736
|
|
|
617
|
-
|
|
737
|
+
/**
|
|
738
|
+
* Attach to a buffered fn stream by id — from another tab, a page
|
|
739
|
+
* reload, or any time after the original `streamFn` call went away.
|
|
740
|
+
* Replays from `since` (default 0 = everything, including the final
|
|
741
|
+
* result if the handler already finished), then live-tails.
|
|
742
|
+
*
|
|
743
|
+
* Get the id from `streamFn`'s `onStreamId` callback and persist it
|
|
744
|
+
* wherever the run's state lives. Completed streams stay resumable for
|
|
745
|
+
* an hour (PYLON_STREAM_RETAIN_SECS).
|
|
746
|
+
*/
|
|
747
|
+
export async function* resumeStream(
|
|
748
|
+
streamId: string,
|
|
749
|
+
options: { token?: string; since?: number } = {}
|
|
750
|
+
): AsyncGenerator<string, unknown, unknown> {
|
|
751
|
+
const transport = {
|
|
752
|
+
baseUrl: getBaseUrl(),
|
|
753
|
+
getToken: () => options.token ?? currentAuthToken() ?? undefined,
|
|
754
|
+
};
|
|
755
|
+
const cursor: SseCursor = {
|
|
756
|
+
lastSeq: options.since ?? 0,
|
|
757
|
+
terminal: false,
|
|
758
|
+
finalResult: undefined,
|
|
759
|
+
};
|
|
760
|
+
let attemptsLeft = 3;
|
|
761
|
+
for (;;) {
|
|
762
|
+
const res = await pylonFetchRaw(
|
|
763
|
+
transport,
|
|
764
|
+
`/api/fn-streams/${streamId}?since=${cursor.lastSeq}`,
|
|
765
|
+
{ method: "GET", accept: "text/event-stream" },
|
|
766
|
+
);
|
|
767
|
+
if (res.status === 503 && attemptsLeft > 0) {
|
|
768
|
+
attemptsLeft -= 1;
|
|
769
|
+
await res.text().catch(() => {});
|
|
770
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
if (!res.ok) {
|
|
774
|
+
const text = await res.text().catch(() => "");
|
|
775
|
+
let message = `Stream resume failed: HTTP ${res.status}`;
|
|
776
|
+
try {
|
|
777
|
+
const err = JSON.parse(text) as { error?: { message?: string } };
|
|
778
|
+
if (err.error?.message) message = err.error.message;
|
|
779
|
+
} catch {
|
|
780
|
+
// keep the HTTP message
|
|
781
|
+
}
|
|
782
|
+
throw new Error(message);
|
|
783
|
+
}
|
|
784
|
+
let progressed = false;
|
|
785
|
+
try {
|
|
786
|
+
for await (const chunk of consumeSseBody(res, cursor)) {
|
|
787
|
+
progressed = true;
|
|
788
|
+
yield chunk;
|
|
789
|
+
}
|
|
790
|
+
} catch (e) {
|
|
791
|
+
if (cursor.terminal || attemptsLeft <= 0) throw e;
|
|
792
|
+
}
|
|
793
|
+
if (cursor.terminal) return cursor.finalResult;
|
|
794
|
+
if (progressed) attemptsLeft = 3;
|
|
795
|
+
attemptsLeft -= 1;
|
|
796
|
+
if (attemptsLeft < 0) {
|
|
797
|
+
throw new Error("Stream disconnected before completing");
|
|
798
|
+
}
|
|
799
|
+
}
|
|
618
800
|
}
|
|
619
801
|
|
|
620
802
|
/**
|
|
Binary file
|