@nexrall/code-core 1.4.13 → 1.4.14
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/agent/loop.d.ts +13 -0
- package/dist/agent/loop.d.ts.map +1 -1
- package/dist/agent/loop.js +150 -16
- package/dist/api/client.d.ts +31 -1
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +559 -57
- package/dist/types.d.ts +111 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +44 -0
- package/package.json +1 -1
package/dist/api/client.js
CHANGED
|
@@ -6,14 +6,69 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.API_BASE = void 0;
|
|
7
7
|
exports.chooseFinalContent = chooseFinalContent;
|
|
8
8
|
exports.streamChat = streamChat;
|
|
9
|
+
exports.cancelTurn = cancelTurn;
|
|
9
10
|
exports.getBalance = getBalance;
|
|
10
11
|
exports.exchangeVscodeCode = exchangeVscodeCode;
|
|
11
12
|
exports.login = login;
|
|
12
13
|
const eventsource_parser_1 = require("eventsource-parser");
|
|
13
14
|
const node_fetch_1 = __importDefault(require("node-fetch"));
|
|
15
|
+
const crypto_1 = require("crypto");
|
|
14
16
|
const index_1 = require("../auth/index");
|
|
15
17
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
16
|
-
|
|
18
|
+
const DEFAULT_API_BASE = 'https://api.nexrall.com';
|
|
19
|
+
/**
|
|
20
|
+
* Resolve the backend origin, honouring NEXRALL_API_BASE.
|
|
21
|
+
*
|
|
22
|
+
* Everything sensitive in this module goes to API_BASE: the bearer token on
|
|
23
|
+
* `/api/code/chat`, the user's plaintext password on `/api/auth/login/email`, and the
|
|
24
|
+
* full source-code context of every turn.
|
|
25
|
+
*
|
|
26
|
+
* Loopback is allowed freely (it cannot exfiltrate, and the transport-failure tests need
|
|
27
|
+
* it). A REMOTE host requires NEXRALL_ALLOW_API_OVERRIDE=1 in addition, so redirecting a
|
|
28
|
+
* user's credentials is a deliberate act rather than something a stray env var in a CI
|
|
29
|
+
* config or an npm lifecycle script can do silently. Every override is announced on stderr,
|
|
30
|
+
* so it can never be in effect unnoticed. This is still not a hard security boundary —
|
|
31
|
+
* something that can set two env vars can set one — but it removes the ambient-risk case,
|
|
32
|
+
* which is the one that actually happens.
|
|
33
|
+
*
|
|
34
|
+
* An invalid value falls back to production rather than throwing: this runs at module
|
|
35
|
+
* load, and `@nexrall/code-core` is imported statically by the VS Code extension, so a
|
|
36
|
+
* throw here would abort activation and bury the reason in the extension-host log.
|
|
37
|
+
*/
|
|
38
|
+
function resolveApiBase() {
|
|
39
|
+
const raw = process.env.NEXRALL_API_BASE?.trim();
|
|
40
|
+
if (!raw)
|
|
41
|
+
return DEFAULT_API_BASE;
|
|
42
|
+
const isLoopback = /^http:\/\/(127\.0\.0\.1|\[::1\]|localhost)(:\d+)?(\/|$)/i.test(raw);
|
|
43
|
+
if (isLoopback) {
|
|
44
|
+
console.error(`⚠️ Nexrall API override in effect (loopback): ${raw.replace(/\/+$/, '')}`);
|
|
45
|
+
return raw.replace(/\/+$/, '');
|
|
46
|
+
}
|
|
47
|
+
// A REMOTE override additionally requires an explicit opt-in flag.
|
|
48
|
+
//
|
|
49
|
+
// Loopback can't exfiltrate anything, so it stays freely available (it is what makes the
|
|
50
|
+
// transport-failure tests possible). A remote host is different: this variable receives the
|
|
51
|
+
// bearer token, the plaintext login password, and the full source context of every turn. In
|
|
52
|
+
// a published package, honouring it from a bare env var means anything that can set one
|
|
53
|
+
// variable — a CI config, a shell profile, an npm lifecycle script that spawns `nex` — can
|
|
54
|
+
// silently redirect all of that, with nothing visible in the UI. Requiring a second,
|
|
55
|
+
// purpose-named variable makes redirection a deliberate act rather than an ambient one.
|
|
56
|
+
if (process.env.NEXRALL_ALLOW_API_OVERRIDE !== '1') {
|
|
57
|
+
console.error(`⚠️ Ignoring NEXRALL_API_BASE="${raw}": redirecting to a remote host also requires ` +
|
|
58
|
+
`NEXRALL_ALLOW_API_OVERRIDE=1, because this variable receives your auth token and ` +
|
|
59
|
+
`source code. Falling back to ${DEFAULT_API_BASE}.`);
|
|
60
|
+
return DEFAULT_API_BASE;
|
|
61
|
+
}
|
|
62
|
+
if (!/^https:\/\//i.test(raw)) {
|
|
63
|
+
console.error(`⚠️ Ignoring NEXRALL_API_BASE="${raw}": a remote override must be https:// so your ` +
|
|
64
|
+
`token is never sent in plaintext. Falling back to ${DEFAULT_API_BASE}.`);
|
|
65
|
+
return DEFAULT_API_BASE;
|
|
66
|
+
}
|
|
67
|
+
const base = raw.replace(/\/+$/, '');
|
|
68
|
+
console.error(`⚠️ Nexrall API override in effect: ${base}`);
|
|
69
|
+
return base;
|
|
70
|
+
}
|
|
71
|
+
exports.API_BASE = resolveApiBase();
|
|
17
72
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
18
73
|
/**
|
|
19
74
|
* Decide the final assistant `content` array to store in history for a completed
|
|
@@ -37,7 +92,18 @@ function chooseFinalContent(rebuilt, rawContent) {
|
|
|
37
92
|
if (!Array.isArray(rawContent))
|
|
38
93
|
return rebuilt;
|
|
39
94
|
const hasServerSideBlocks = rawContent.some((b) => b && b.type !== 'text' && b.type !== 'tool_use');
|
|
40
|
-
|
|
95
|
+
if (hasServerSideBlocks)
|
|
96
|
+
return rawContent;
|
|
97
|
+
// The rebuild came out EMPTY even though the authoritative message has content. That
|
|
98
|
+
// means the incremental events this reconstruction depends on never landed — a `text`
|
|
99
|
+
// frame split across a chunk boundary and dropped as malformed, or a mid-stream
|
|
100
|
+
// reconnect that resumed past them. Preferring `rebuilt` here would silently return an
|
|
101
|
+
// empty assistant turn and the agent loop would treat the whole turn as a no-op, losing
|
|
102
|
+
// an answer the server had already delivered in full. The raw array is authoritative,
|
|
103
|
+
// so fall back to it.
|
|
104
|
+
if (rebuilt.length === 0 && rawContent.length > 0)
|
|
105
|
+
return rawContent;
|
|
106
|
+
return rebuilt;
|
|
41
107
|
}
|
|
42
108
|
function authHeaders() {
|
|
43
109
|
const token = (0, index_1.getToken)();
|
|
@@ -53,6 +119,27 @@ function authHeaders() {
|
|
|
53
119
|
const MAX_RETRIES = 5;
|
|
54
120
|
const RETRY_BASE_MS = 1000;
|
|
55
121
|
const RETRY_MAX_MS = 30000;
|
|
122
|
+
/**
|
|
123
|
+
* Wall-clock ceiling on ALL reconnect effort for a single streamChat() call.
|
|
124
|
+
*
|
|
125
|
+
* The retry loops are nested — a connect-time loop inside an outer stream loop —
|
|
126
|
+
* so their attempt counts MULTIPLY: 6 × 6 = 36 network attempts, each able to
|
|
127
|
+
* sleep up to 30 s of backoff. In a genuine outage that meant the UI could sit
|
|
128
|
+
* "reconnecting…" for the better part of an hour, silently resending the full
|
|
129
|
+
* conversation (and paying cache-write on it) every single time, before finally
|
|
130
|
+
* admitting failure.
|
|
131
|
+
*
|
|
132
|
+
* A time budget is the honest limit — the user cares about "how long until you
|
|
133
|
+
* tell me it's broken", not how many sockets were opened. Once it's spent, the
|
|
134
|
+
* next failure surfaces immediately. Attempt caps stay as a secondary bound.
|
|
135
|
+
*/
|
|
136
|
+
const MAX_TOTAL_RETRY_MS = (() => {
|
|
137
|
+
// Overridable so tests can exercise budget EXHAUSTION, which is otherwise unreachable
|
|
138
|
+
// in under five minutes and is exactly where the tricky control flow lives (the
|
|
139
|
+
// connect-time catch must break rather than fall back into its loop unthrottled).
|
|
140
|
+
const raw = Number(process.env.NEXRALL_MAX_RETRY_MS);
|
|
141
|
+
return Number.isFinite(raw) && raw > 0 ? raw : 5 * 60000;
|
|
142
|
+
})();
|
|
56
143
|
/** Exponential backoff with full jitter, capped at RETRY_MAX_MS. The jitter spreads
|
|
57
144
|
* retries out so a fleet of clients hitting the SAME overloaded API (529) don't all
|
|
58
145
|
* reconnect in lockstep and re-trigger the overload (thundering herd). Returns a delay
|
|
@@ -64,30 +151,105 @@ function backoffMs(attempt) {
|
|
|
64
151
|
function sleep(ms) {
|
|
65
152
|
return new Promise((r) => setTimeout(r, ms));
|
|
66
153
|
}
|
|
67
|
-
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
154
|
+
/** Upper bound on network-level attempts (connect-time retries × outer stream-retries),
|
|
155
|
+
* reported to the UI as the "of M" in "attempt N of M". It is NOT the real stopping
|
|
156
|
+
* condition — MAX_TOTAL_RETRY_MS almost always runs out first, and deliberately so:
|
|
157
|
+
* the nested product (36) exists to bound pathological cases, not to be reached. */
|
|
70
158
|
const MAX_TOTAL_ATTEMPTS = (MAX_RETRIES + 1) * (MAX_RETRIES + 1);
|
|
71
159
|
async function streamChat(messages, options, onEvent) {
|
|
72
|
-
const { model, env, editorContext, nexrallMd, mode, effort, clientType, abortSignal, extraTools, agents, skills } = options;
|
|
160
|
+
const { model, env, editorContext, nexrallMd, mode, effort, clientType, abortSignal, extraTools, agents, skills, allowRestartAfterRender } = options;
|
|
161
|
+
// IDEMPOTENCY KEY for this logical turn, generated ONCE and reused by every retry
|
|
162
|
+
// below (that reuse is the entire point — a fresh id per attempt would be useless).
|
|
163
|
+
//
|
|
164
|
+
// The failure it closes is expensive and invisible: the backend finishes the model
|
|
165
|
+
// call, bills it, and writes `done` — but the socket dies before the client reads it.
|
|
166
|
+
// The client sees a dropped connection and retries, so the backend runs the SAME turn
|
|
167
|
+
// through the model a SECOND time and bills it again. The first, perfectly good answer
|
|
168
|
+
// is discarded. The user paid twice for one question and nothing on either side could
|
|
169
|
+
// tell. With a stable key the backend recognises the replay and returns the stored
|
|
170
|
+
// result instead of re-invoking the model.
|
|
171
|
+
let turnId = (0, crypto_1.randomUUID)();
|
|
73
172
|
// Bridge custom abort signal → native AbortController so fetch is cancelled immediately
|
|
74
173
|
const controller = new AbortController();
|
|
75
174
|
if (abortSignal?.aborted)
|
|
76
175
|
throw Object.assign(new Error('Aborted'), { name: 'AbortError' });
|
|
77
176
|
let abortPoll;
|
|
177
|
+
// Guards against the 50ms poll firing a fresh cancel request on every tick between the
|
|
178
|
+
// signal flipping and streamChat returning — each one a real POST doing Redis work, i.e.
|
|
179
|
+
// a self-inflicted burst on the exact path a user hits when things are already wrong.
|
|
180
|
+
let cancelSent = false;
|
|
78
181
|
if (abortSignal) {
|
|
79
|
-
abortPoll = setInterval(() => {
|
|
80
|
-
|
|
182
|
+
abortPoll = setInterval(() => {
|
|
183
|
+
if (!abortSignal.aborted)
|
|
184
|
+
return;
|
|
185
|
+
if (cancelSent)
|
|
186
|
+
return; // the poll keeps ticking until the outer finally clears it
|
|
187
|
+
cancelSent = true;
|
|
188
|
+
// Tell the SERVER to stop, not just this socket.
|
|
189
|
+
//
|
|
190
|
+
// Closing the socket used to be sufficient — the backend aborted the model when the
|
|
191
|
+
// request closed. That is exactly what changed to make streams resumable: a dropped
|
|
192
|
+
// connection now DETACHES and keeps generating, because a blip must not throw the
|
|
193
|
+
// turn away. But a deliberate Stop arrives as the very same dead socket. Without an
|
|
194
|
+
// explicit cancel the model would run to completion and bill in full while the user
|
|
195
|
+
// believed they had stopped it.
|
|
196
|
+
//
|
|
197
|
+
// Fire-and-forget, before aborting: we don't want Stop to feel slow, and if the
|
|
198
|
+
// request fails the server's detach grace still bounds the damage.
|
|
199
|
+
void cancelTurn(turnId);
|
|
200
|
+
controller.abort();
|
|
201
|
+
}, 50);
|
|
81
202
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
203
|
+
// ── Resume state ───────────────────────────────────────────────────────────
|
|
204
|
+
// Set when the backend announces (via a `resumable` frame) that this turn's SSE frames
|
|
205
|
+
// are numbered and buffered server-side. Once true, a dropped connection can be RESUMED
|
|
206
|
+
// — reattached mid-response — instead of restarting the turn.
|
|
207
|
+
//
|
|
208
|
+
// Why that matters: restarting re-sends the whole conversation, pays for prompt
|
|
209
|
+
// processing and time-to-first-token again, and re-bills every token already generated.
|
|
210
|
+
// Resuming costs one buffer read and wastes nothing. Older backends never send the frame,
|
|
211
|
+
// so this stays false and the existing restart path is used unchanged.
|
|
212
|
+
let serverResumable = false;
|
|
213
|
+
// Highest frame id received. The resume cursor handed back as Last-Event-ID.
|
|
214
|
+
let lastEventId = 0;
|
|
215
|
+
// Whether the CURRENT attempt is a resume (GET …/resume) rather than a fresh POST.
|
|
216
|
+
let resuming = false;
|
|
217
|
+
// Output received BEFORE the drop, carried across a resume. A resume delivers only the
|
|
218
|
+
// frames past `lastEventId`, so without this the rebuilt message would consist of just
|
|
219
|
+
// the tail and the start of the answer would be lost.
|
|
220
|
+
let carryText = [];
|
|
221
|
+
let carryToolUse = [];
|
|
222
|
+
// Whether ANY output has reached the caller across ALL attempts of this turn, and how
|
|
223
|
+
// much. `emittedToCaller` inside runOnce is per-attempt and resets, but a rollback
|
|
224
|
+
// decision has to consider everything the user can currently see — including output
|
|
225
|
+
// delivered by an earlier attempt before a resume.
|
|
226
|
+
let emittedAnythingAcrossAttempts = false;
|
|
227
|
+
let emittedCharsAcrossAttempts = 0;
|
|
228
|
+
// Built per attempt, because an attempt is EITHER a fresh turn (POST /chat) or a resume
|
|
229
|
+
// of the one already generating server-side (GET /chat/resume + Last-Event-ID). The
|
|
230
|
+
// resume carries no body at all — the conversation is already being processed; re-sending
|
|
231
|
+
// it is exactly the waste this avoids.
|
|
232
|
+
const buildFetchArgs = () => {
|
|
233
|
+
if (resuming) {
|
|
234
|
+
return [
|
|
235
|
+
`${exports.API_BASE}/api/code/chat/resume?turnId=${encodeURIComponent(turnId)}`,
|
|
236
|
+
{
|
|
237
|
+
method: 'GET',
|
|
238
|
+
headers: { ...authHeaders(), 'Last-Event-ID': String(lastEventId) },
|
|
239
|
+
signal: controller.signal,
|
|
240
|
+
},
|
|
241
|
+
];
|
|
242
|
+
}
|
|
243
|
+
return [
|
|
244
|
+
`${exports.API_BASE}/api/code/chat`,
|
|
245
|
+
{
|
|
246
|
+
method: 'POST',
|
|
247
|
+
headers: authHeaders(),
|
|
248
|
+
body: JSON.stringify({ messages, model, env, editorContext, nexrallMd, mode, effort, clientType, extraTools, agents, skills, turnId }),
|
|
249
|
+
signal: controller.signal,
|
|
250
|
+
},
|
|
251
|
+
];
|
|
252
|
+
};
|
|
91
253
|
// A transient mid-stream failure (Anthropic "overloaded_error") arrives as an SSE error
|
|
92
254
|
// event over an ALREADY-200 response, so the connect-time HTTP-status retry below can't
|
|
93
255
|
// see it. We detect it in the parser and retry the WHOLE attempt with exponential backoff
|
|
@@ -102,7 +264,33 @@ async function streamChat(messages, options, onEvent) {
|
|
|
102
264
|
// happened (this is the fix for that gap).
|
|
103
265
|
let didRetry = false;
|
|
104
266
|
let totalAttemptsMade = 0;
|
|
267
|
+
// Shared wall-clock budget for every reconnect across BOTH nested retry loops.
|
|
268
|
+
// `retryBudgetLeft()` gates each retry decision and also clamps the backoff sleep,
|
|
269
|
+
// so we never wait longer than the budget we have left.
|
|
270
|
+
//
|
|
271
|
+
// The clock starts at the FIRST retry, not at streamChat() entry. This matters: a
|
|
272
|
+
// healthy first attempt on a huge context can legitimately spend minutes in
|
|
273
|
+
// prompt-processing before the first token (hence FIRST_EVENT_TIMEOUT_MS = 300 s).
|
|
274
|
+
// Anchoring the deadline at entry would let that normal, successful work consume the
|
|
275
|
+
// entire reconnect budget, so the first real network blip would get zero retries —
|
|
276
|
+
// precisely backwards. Measuring only time spent RECONNECTING keeps the guarantee
|
|
277
|
+
// meaningful: "at most 5 minutes of retrying before we tell you it's broken".
|
|
278
|
+
let retryDeadline = 0;
|
|
279
|
+
const retryBudgetLeft = () => retryDeadline === 0 ? MAX_TOTAL_RETRY_MS : retryDeadline - Date.now();
|
|
280
|
+
// Require a MEANINGFUL remainder, not just "> 0". With a bare positive check, 1 ms of
|
|
281
|
+
// budget was enough to launch a whole fresh attempt — which can then sit in
|
|
282
|
+
// FIRST_EVENT_TIMEOUT_MS (300 s) of prompt-processing wait, blowing past the very
|
|
283
|
+
// deadline it was checked against. It also turned a fast-failing connect error
|
|
284
|
+
// (ENOTFOUND) into a tight loop of near-zero sleeps. One base backoff of headroom
|
|
285
|
+
// makes the budget an approximate ceiling instead of a suggestion.
|
|
286
|
+
const canRetry = () => retryBudgetLeft() > RETRY_BASE_MS;
|
|
287
|
+
/** Sleep for `ms`, but never past the shared retry deadline. */
|
|
288
|
+
const sleepWithinBudget = (ms) => sleep(Math.max(0, Math.min(ms, retryBudgetLeft())));
|
|
105
289
|
const reportRetry = (reason) => {
|
|
290
|
+
// Start the reconnect budget on the first retry (see retryDeadline above). Every
|
|
291
|
+
// retry path funnels through here, so this is the one place it needs to happen.
|
|
292
|
+
if (retryDeadline === 0)
|
|
293
|
+
retryDeadline = Date.now() + MAX_TOTAL_RETRY_MS;
|
|
106
294
|
totalAttemptsMade++;
|
|
107
295
|
didRetry = true;
|
|
108
296
|
onEvent({ type: 'retry', attempt: totalAttemptsMade, maxAttempts: MAX_TOTAL_ATTEMPTS, reason });
|
|
@@ -120,24 +308,119 @@ async function streamChat(messages, options, onEvent) {
|
|
|
120
308
|
// Retry loop — handles connect-time 429 (rate limit) and 5xx (server errors, incl. 529)
|
|
121
309
|
let response;
|
|
122
310
|
let lastErr;
|
|
311
|
+
// How many times we've waited on a `turnInFlight` 409. Drives that path's own backoff,
|
|
312
|
+
// separately from `attempt` (a wait is not a failed attempt — see the 409 branch).
|
|
313
|
+
let inFlightWaits = 0;
|
|
314
|
+
// A 409's body, already consumed to classify it. node-fetch bodies are single-use, so
|
|
315
|
+
// the !response.ok handler below reads this instead of calling response.text() again.
|
|
316
|
+
let errorBodyOverride = null;
|
|
123
317
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
124
318
|
try {
|
|
125
|
-
response = await (0, node_fetch_1.default)(...
|
|
126
|
-
if (response.status === 429 && attempt < MAX_RETRIES) {
|
|
319
|
+
response = await (0, node_fetch_1.default)(...buildFetchArgs());
|
|
320
|
+
if (response.status === 429 && attempt < MAX_RETRIES && canRetry()) {
|
|
127
321
|
reportRetry('Rate limited by the API — retrying');
|
|
128
322
|
const retryAfter = parseInt(response.headers.get('retry-after') ?? '0', 10);
|
|
129
|
-
await
|
|
323
|
+
await sleepWithinBudget(retryAfter > 0 ? retryAfter * 1000 : backoffMs(attempt));
|
|
130
324
|
continue;
|
|
131
325
|
}
|
|
132
|
-
if (response.status >= 500 && response.status < 600 && attempt < MAX_RETRIES) {
|
|
326
|
+
if (response.status >= 500 && response.status < 600 && attempt < MAX_RETRIES && canRetry()) {
|
|
133
327
|
reportRetry(`Server error (${response.status}) — retrying`);
|
|
134
|
-
await
|
|
328
|
+
await sleepWithinBudget(backoffMs(attempt));
|
|
135
329
|
continue;
|
|
136
330
|
}
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
331
|
+
// 410 Gone — a resume was refused because the buffered window no longer contains
|
|
332
|
+
// our position (we were away too long, or the turn produced a huge amount in the
|
|
333
|
+
// meantime). Continuity can't be proven, and silently skipping frames would corrupt
|
|
334
|
+
// the message being rebuilt. So abandon resuming and fall back to restarting the
|
|
335
|
+
// turn from scratch: correct, just not free.
|
|
336
|
+
if (response.status === 410 && resuming) {
|
|
337
|
+
await response.text().catch(() => '');
|
|
338
|
+
// A 410 means the BUFFER WINDOW moved past our cursor — it does NOT mean the turn
|
|
339
|
+
// ended. It is almost certainly still generating and still holding its idempotency
|
|
340
|
+
// claim, so re-POSTing with the same turnId would be rejected as `turnInFlight`,
|
|
341
|
+
// and we would wait out the entire retry budget only to fail — while the orphaned
|
|
342
|
+
// turn kept generating and billing.
|
|
343
|
+
//
|
|
344
|
+
// So: stop the old turn (awaited — the claim must be released BEFORE we re-POST),
|
|
345
|
+
// and mint a fresh id, because a restart is semantically a new turn.
|
|
346
|
+
await cancelTurn(turnId);
|
|
347
|
+
turnId = (0, crypto_1.randomUUID)();
|
|
348
|
+
resuming = false;
|
|
349
|
+
serverResumable = false;
|
|
350
|
+
lastEventId = 0;
|
|
351
|
+
carryText = [];
|
|
352
|
+
carryToolUse = [];
|
|
353
|
+
// Snapshot BEFORE resetting: the rollback decision below needs to know what the
|
|
354
|
+
// user can currently see, but the counters must not carry into the restarted turn
|
|
355
|
+
// or a second restart would report characters this one already discarded.
|
|
356
|
+
const hadRendered = emittedAnythingAcrossAttempts;
|
|
357
|
+
const renderedChars = emittedCharsAcrossAttempts;
|
|
358
|
+
emittedAnythingAcrossAttempts = false;
|
|
359
|
+
emittedCharsAcrossAttempts = 0;
|
|
360
|
+
if (hadRendered && !allowRestartAfterRender) {
|
|
361
|
+
// Output is already on screen and the caller can't take it back, so a restart
|
|
362
|
+
// would duplicate it. Surface the failure instead of corrupting the transcript.
|
|
363
|
+
throw new Error('Connection lost and this turn could no longer be resumed. Send your message again.');
|
|
364
|
+
}
|
|
365
|
+
if (hadRendered) {
|
|
366
|
+
onEvent({
|
|
367
|
+
type: 'stream_restart',
|
|
368
|
+
reason: 'Connection lost too long to resume',
|
|
369
|
+
discardedChars: renderedChars,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
if (attempt < MAX_RETRIES && canRetry()) {
|
|
373
|
+
reportRetry('Could not resume — restarting this turn');
|
|
374
|
+
await sleepWithinBudget(backoffMs(attempt));
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
errorBodyOverride = JSON.stringify({ error: 'Could not resume this turn — send your message again.' });
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
// 409 from the backend's turn-idempotency gate. Two distinct meanings, and the
|
|
381
|
+
// body distinguishes them:
|
|
382
|
+
//
|
|
383
|
+
// turnInFlight — another attempt carrying the same turnId is STILL RUNNING
|
|
384
|
+
// (two sockets racing; typically a reconnect landing before the
|
|
385
|
+
// original finished tearing down). Running it anyway would
|
|
386
|
+
// double-charge one turn, which is what that gate prevents.
|
|
387
|
+
// WAITING is correct: the original either completes (and this
|
|
388
|
+
// attempt is then served the stored answer for free) or fails
|
|
389
|
+
// (and this attempt runs for real).
|
|
390
|
+
// turnCompleted — the turn finished and was billed, but its response is too
|
|
391
|
+
// large to resend. DETERMINISTIC; retrying can never help.
|
|
392
|
+
if (response.status === 409) {
|
|
393
|
+
const body = await response.text();
|
|
394
|
+
let inFlight = false;
|
|
395
|
+
try {
|
|
396
|
+
inFlight = JSON.parse(body)?.turnInFlight === true;
|
|
397
|
+
}
|
|
398
|
+
catch { /* treat as terminal */ }
|
|
399
|
+
// A wait is NOT a failed attempt, so it must not consume the network-error
|
|
400
|
+
// budget: a real coding turn runs for minutes, and spending `MAX_RETRIES` on
|
|
401
|
+
// fixed short sleeps burned the whole allowance in ~10 s and hard-failed while
|
|
402
|
+
// the original turn was perfectly healthy — leaving the user billed for an
|
|
403
|
+
// answer they never received. Only the wall-clock budget bounds this loop.
|
|
404
|
+
if (inFlight && canRetry()) {
|
|
405
|
+
inFlightWaits++;
|
|
406
|
+
reportRetry('This turn is already being processed — waiting for it');
|
|
407
|
+
const retryAfter = parseInt(response.headers.get('retry-after') ?? '0', 10);
|
|
408
|
+
// Honour the server's hint but keep growing our own backoff, so a wildly
|
|
409
|
+
// optimistic Retry-After can't turn this into a hot loop.
|
|
410
|
+
await sleepWithinBudget(Math.max(retryAfter * 1000, backoffMs(inFlightWaits)));
|
|
411
|
+
attempt--; // refund the attempt — this was a wait, not a failure
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
// Terminal 409 (or the wait budget is spent): rebuild a response-like shape so
|
|
415
|
+
// the shared !response.ok handler below can surface the server's own message.
|
|
416
|
+
errorBodyOverride = body;
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
// Every other 4xx (413 Payload Too Large, 400 Bad Request, 402 Insufficient
|
|
420
|
+
// balance) is DETERMINISTIC given the same body — retrying resends the identical
|
|
421
|
+
// oversized conversation and can never succeed. Fall through to the non-retryable
|
|
422
|
+
// !response.ok handler below instead of burning retries (this was the 16-request
|
|
423
|
+
// "retry storm").
|
|
141
424
|
break; // success or non-retryable error
|
|
142
425
|
}
|
|
143
426
|
catch (err) {
|
|
@@ -145,19 +428,31 @@ async function streamChat(messages, options, onEvent) {
|
|
|
145
428
|
if (err.name === 'AbortError')
|
|
146
429
|
throw err;
|
|
147
430
|
lastErr = err;
|
|
148
|
-
if (attempt < MAX_RETRIES) {
|
|
431
|
+
if (attempt < MAX_RETRIES && canRetry()) {
|
|
149
432
|
// Connection couldn't even be established — network drop / DNS blip / the
|
|
150
433
|
// machine just woke from sleep and Wi-Fi hasn't reconnected yet.
|
|
151
434
|
reportRetry('Connection lost — attempting to reconnect');
|
|
152
|
-
await
|
|
435
|
+
await sleepWithinBudget(backoffMs(attempt));
|
|
153
436
|
continue;
|
|
154
437
|
}
|
|
438
|
+
// Out of attempts, or out of reconnect budget. MUST break: falling off the end
|
|
439
|
+
// of the catch re-enters the for-loop with no backoff and no further budget
|
|
440
|
+
// check, firing the remaining attempts back-to-back in milliseconds — a burst
|
|
441
|
+
// of full-conversation POSTs, invisible to the UI. (The 429/5xx branches above
|
|
442
|
+
// already break for the same reason.) Tag the error so the outer loop can
|
|
443
|
+
// report budget exhaustion rather than a bare transport failure.
|
|
444
|
+
// Guard the shape: a patched fetch/undici that throws a string or null would turn
|
|
445
|
+
// a network failure into a TypeError raised by the error handler itself.
|
|
446
|
+
if (!canRetry() && lastErr && typeof lastErr === 'object') {
|
|
447
|
+
Object.assign(lastErr, { retryable: true });
|
|
448
|
+
}
|
|
449
|
+
break;
|
|
155
450
|
}
|
|
156
451
|
}
|
|
157
452
|
if (!response)
|
|
158
453
|
throw lastErr ?? new Error('Request failed after max retries');
|
|
159
454
|
if (!response.ok) {
|
|
160
|
-
const errText = await response.text();
|
|
455
|
+
const errText = errorBodyOverride ?? await response.text();
|
|
161
456
|
let errMsg = `API error ${response.status}`;
|
|
162
457
|
let balance;
|
|
163
458
|
try {
|
|
@@ -178,13 +473,60 @@ async function streamChat(messages, options, onEvent) {
|
|
|
178
473
|
if (!response.body) {
|
|
179
474
|
throw new Error('Response body is null');
|
|
180
475
|
}
|
|
181
|
-
// Accumulated state for building the assistant message
|
|
182
|
-
|
|
183
|
-
|
|
476
|
+
// Accumulated state for building the assistant message.
|
|
477
|
+
//
|
|
478
|
+
// Seeded from the previous attempt when RESUMING. This is essential and easy to miss:
|
|
479
|
+
// a resume delivers only the frames after `lastEventId`, so the text and tool calls
|
|
480
|
+
// received before the drop live in the outer carry-over. Starting empty here would
|
|
481
|
+
// rebuild the message from the tail alone and silently truncate the answer — the
|
|
482
|
+
// beginning of the response would simply vanish.
|
|
483
|
+
const textParts = resuming ? [...carryText] : [];
|
|
484
|
+
const toolUseBlocks = resuming ? [...carryToolUse] : [];
|
|
184
485
|
let completedMessage = null;
|
|
185
|
-
//
|
|
186
|
-
//
|
|
486
|
+
// Publish the accumulators outward continuously, so if THIS attempt dies the next one
|
|
487
|
+
// can resume on top of what it already received. Assigning the same array references is
|
|
488
|
+
// enough — the resume path copies them.
|
|
489
|
+
carryText = textParts;
|
|
490
|
+
carryToolUse = toolUseBlocks;
|
|
491
|
+
// Once anything has been shown to the caller (text / tool_use / thinking), a naive retry
|
|
492
|
+
// would duplicate it on screen. Two outcomes are possible from here:
|
|
493
|
+
// • caller CAN'T undo its rendering → the failure stays fatal (legacy behaviour);
|
|
494
|
+
// • caller CAN (allowRestartAfterRender) → we tag the error `needsRestart`, the outer
|
|
495
|
+
// loop emits `stream_restart` so the caller drops the partial render, and the turn
|
|
496
|
+
// is retried from scratch. Nothing else needs undoing: tool calls only execute after
|
|
497
|
+
// `message_complete`, which by definition never arrived on a failed attempt.
|
|
187
498
|
let emittedToCaller = false;
|
|
499
|
+
// Visible characters emitted this attempt — reported in `stream_restart` purely so the
|
|
500
|
+
// UI can say how much output is being discarded.
|
|
501
|
+
let emittedChars = 0;
|
|
502
|
+
/**
|
|
503
|
+
* Classify a transport/stream failure. `retryable` means "safe to retry with nothing
|
|
504
|
+
* shown yet"; `needsRestart` means "safe to retry ONLY if the caller rolls back what
|
|
505
|
+
* it already rendered". Anything else is terminal.
|
|
506
|
+
*/
|
|
507
|
+
const tagTransient = (err) => {
|
|
508
|
+
if (!emittedToCaller)
|
|
509
|
+
return Object.assign(err, { retryable: true });
|
|
510
|
+
if (allowRestartAfterRender) {
|
|
511
|
+
return Object.assign(err, { retryable: true, needsRestart: true, discardedChars: emittedChars });
|
|
512
|
+
}
|
|
513
|
+
return err;
|
|
514
|
+
};
|
|
515
|
+
/**
|
|
516
|
+
* True once the turn's complete assistant message has arrived.
|
|
517
|
+
*
|
|
518
|
+
* There is a real window between `message_complete` and `done`: the backend awaits
|
|
519
|
+
* its billing promise (a DB round-trip) before sending `done`, so a socket that
|
|
520
|
+
* dies in between produces a transport error on a turn that has ALREADY fully
|
|
521
|
+
* succeeded. Retrying there would be the worst possible outcome — it discards a
|
|
522
|
+
* complete, correct answer, re-runs the model, and bills the user twice.
|
|
523
|
+
*
|
|
524
|
+
* Every failure path therefore checks this and resolves instead of rejecting: we
|
|
525
|
+
* have the message, and `done` carries no information we need. This also restores
|
|
526
|
+
* the premise the restart design rests on — a restart now genuinely implies
|
|
527
|
+
* `message_complete` never arrived.
|
|
528
|
+
*/
|
|
529
|
+
const haveCompleteMessage = () => completedMessage !== null;
|
|
188
530
|
// Partial input accumulator keyed by tool_use id
|
|
189
531
|
const partialInputs = {};
|
|
190
532
|
await new Promise((resolve, reject) => {
|
|
@@ -192,11 +534,19 @@ async function streamChat(messages, options, onEvent) {
|
|
|
192
534
|
// Declared here so the heartbeat watchdog closure can reference it.
|
|
193
535
|
const stream = response.body;
|
|
194
536
|
// ── Heartbeat watchdog ────────────────────────────────────────────────────
|
|
195
|
-
// The backend sends ": ping" comments every
|
|
196
|
-
// all for
|
|
537
|
+
// The backend sends ": ping" comments every 10 s. If we receive no data at
|
|
538
|
+
// all for 90 s the connection has silently dropped (proxy timeout, network
|
|
197
539
|
// blip). Reject so the caller sees an actionable error instead of hanging.
|
|
198
|
-
|
|
199
|
-
//
|
|
540
|
+
//
|
|
541
|
+
// Sizing: this must tolerate a burst of consecutive LOST pings, not just one.
|
|
542
|
+
// The old pairing (20 s ping / 45 s timeout) survived exactly two misses —
|
|
543
|
+
// routinely exceeded on flaky Wi-Fi, tethered 4G, or the seconds right after a
|
|
544
|
+
// laptop wakes, so a perfectly healthy connection got torn down and the turn
|
|
545
|
+
// restarted for nothing. At 10 s / 90 s we ride out eight consecutive misses
|
|
546
|
+
// while still detecting a truly dead socket faster in absolute terms than the
|
|
547
|
+
// proxy's own idle timeout.
|
|
548
|
+
const HEARTBEAT_TIMEOUT_MS = 90000;
|
|
549
|
+
// Progress watchdog. The backend's ": ping" keepalive (every 10 s) resets lastDataAt,
|
|
200
550
|
// so the heartbeat above only catches a fully dead connection — NOT an upstream model
|
|
201
551
|
// stall, where Anthropic stops emitting tokens but the backend keeps pinging. That case
|
|
202
552
|
// would otherwise hang on "Thinking…" forever. lastProgressAt is bumped ONLY on real
|
|
@@ -210,13 +560,34 @@ async function streamChat(messages, options, onEvent) {
|
|
|
210
560
|
// attempt warmed the prompt cache, so the retry's TTFT was short. Allow 300 s.
|
|
211
561
|
// • AFTER the first model event, tokens are flowing; a 150 s silent gap mid-stream
|
|
212
562
|
// is a genuine stall.
|
|
213
|
-
|
|
214
|
-
|
|
563
|
+
// Overridable so tests can reach the stall path in seconds instead of minutes.
|
|
564
|
+
// That path is where the subtlest bug in this file lives: `lastProgressAt` stops
|
|
565
|
+
// advancing at `message_complete` while heartbeat pings keep `lastDataAt` fresh, so
|
|
566
|
+
// this watchdog — and only this one — can fire on a turn that already succeeded.
|
|
567
|
+
const FIRST_EVENT_TIMEOUT_MS = Number(process.env.NEXRALL_FIRST_EVENT_TIMEOUT_MS) || 300000;
|
|
568
|
+
const PROGRESS_TIMEOUT_MS = Number(process.env.NEXRALL_PROGRESS_TIMEOUT_MS) || 150000;
|
|
215
569
|
let sawModelEvent = false;
|
|
216
570
|
let lastDataAt = Date.now();
|
|
217
571
|
let lastProgressAt = Date.now();
|
|
218
572
|
const heartbeatWatchdog = setInterval(() => {
|
|
219
573
|
const now = Date.now();
|
|
574
|
+
// The turn already succeeded and we're only waiting on the trailing `done`.
|
|
575
|
+
// Finish with what we have rather than discarding a complete message and
|
|
576
|
+
// re-running the model.
|
|
577
|
+
//
|
|
578
|
+
// The silence condition is essential, not decorative: the backend AWAITS its
|
|
579
|
+
// billing write before emitting `balance_status` and `done`, so the gap after
|
|
580
|
+
// `message_complete` is normal, expected, and can run for seconds on a slow DB.
|
|
581
|
+
// Firing on the next tick regardless would tear down a perfectly healthy socket
|
|
582
|
+
// and swallow the low-balance nudge — trading the bug this guard was written to
|
|
583
|
+
// fix for a different one. Wait out the full heartbeat timeout first; only then
|
|
584
|
+
// is the trailing frame genuinely never coming.
|
|
585
|
+
if (haveCompleteMessage() && now - lastDataAt > HEARTBEAT_TIMEOUT_MS) {
|
|
586
|
+
clearInterval(heartbeatWatchdog);
|
|
587
|
+
stream.destroy?.();
|
|
588
|
+
resolve();
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
220
591
|
if (now - lastDataAt > HEARTBEAT_TIMEOUT_MS) {
|
|
221
592
|
clearInterval(heartbeatWatchdog);
|
|
222
593
|
stream.destroy?.();
|
|
@@ -227,24 +598,36 @@ async function streamChat(messages, options, onEvent) {
|
|
|
227
598
|
// `now - lastDataAt` immediately reads as a large gap even though nothing is
|
|
228
599
|
// actually wrong with the connection; without this it used to hard-fail
|
|
229
600
|
// instead of silently reconnecting.
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
601
|
+
const recoverable = !emittedToCaller || !!allowRestartAfterRender;
|
|
602
|
+
const secs = Math.round(HEARTBEAT_TIMEOUT_MS / 1000);
|
|
603
|
+
reject(tagTransient(new Error(recoverable
|
|
604
|
+
? `Connection lost — no data received for ${secs} s. Reconnecting…`
|
|
605
|
+
: `Connection lost — no data received for ${secs} s. Retry your message.`)));
|
|
234
606
|
return;
|
|
235
607
|
}
|
|
236
608
|
const stallLimitMs = sawModelEvent ? PROGRESS_TIMEOUT_MS : FIRST_EVENT_TIMEOUT_MS;
|
|
237
609
|
if (now - lastProgressAt > stallLimitMs) {
|
|
238
610
|
clearInterval(heartbeatWatchdog);
|
|
239
611
|
stream.destroy?.();
|
|
612
|
+
// Same rule as the heartbeat branch above, and MORE easily reached here: this
|
|
613
|
+
// watchdog reads `lastProgressAt`, which only real model events bump. Once
|
|
614
|
+
// `message_complete` lands, nothing bumps it again — so while the backend awaits
|
|
615
|
+
// its billing write, the heartbeat's `: ping` keeps `lastDataAt` fresh (both
|
|
616
|
+
// branches above correctly stay silent) but THIS clock runs out and reports a
|
|
617
|
+
// "stall" on a turn that already succeeded. Restarting there would discard a
|
|
618
|
+
// correct answer and re-bill the model call, repeatedly. The turn is complete;
|
|
619
|
+
// finish with it.
|
|
620
|
+
if (haveCompleteMessage()) {
|
|
621
|
+
resolve();
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
240
624
|
// If nothing has been shown to the caller yet, a fresh attempt can't duplicate
|
|
241
625
|
// output — tag it retryable so the outer loop transparently retries the turn
|
|
242
626
|
// instead of killing it. Once output has been emitted, a retry would duplicate
|
|
243
627
|
// rendered text/tool calls, so surface the stall as a terminal error.
|
|
244
|
-
|
|
245
|
-
? `The model stopped responding mid-stream (no output for ${Math.round(stallLimitMs / 1000)} s)
|
|
246
|
-
: `The model did not start responding within ${Math.round(stallLimitMs / 1000)} s (large context can take a while to process)
|
|
247
|
-
reject(emittedToCaller ? stallErr : Object.assign(stallErr, { retryable: true }));
|
|
628
|
+
reject(tagTransient(new Error(sawModelEvent
|
|
629
|
+
? `The model stopped responding mid-stream (no output for ${Math.round(stallLimitMs / 1000)} s).`
|
|
630
|
+
: `The model did not start responding within ${Math.round(stallLimitMs / 1000)} s (large context can take a while to process).`)));
|
|
248
631
|
return;
|
|
249
632
|
}
|
|
250
633
|
}, 5000);
|
|
@@ -255,6 +638,15 @@ async function streamChat(messages, options, onEvent) {
|
|
|
255
638
|
return;
|
|
256
639
|
}
|
|
257
640
|
{
|
|
641
|
+
// Track the server-assigned frame id. This is the resume cursor: on a drop we
|
|
642
|
+
// hand it back via Last-Event-ID and receive exactly the frames we missed,
|
|
643
|
+
// instead of re-running the whole turn. Only present on resumable turns.
|
|
644
|
+
const frameId = event.id;
|
|
645
|
+
if (frameId) {
|
|
646
|
+
const n = Number(frameId);
|
|
647
|
+
if (Number.isFinite(n) && n > lastEventId)
|
|
648
|
+
lastEventId = n;
|
|
649
|
+
}
|
|
258
650
|
const raw = event.data;
|
|
259
651
|
if (!raw || raw === '[DONE]') {
|
|
260
652
|
resolve();
|
|
@@ -283,6 +675,9 @@ async function streamChat(messages, options, onEvent) {
|
|
|
283
675
|
const text = typeof evt.text === 'string' ? evt.text : '';
|
|
284
676
|
textParts.push(text);
|
|
285
677
|
emittedToCaller = true;
|
|
678
|
+
emittedChars += text.length;
|
|
679
|
+
emittedAnythingAcrossAttempts = true;
|
|
680
|
+
emittedCharsAcrossAttempts += text.length;
|
|
286
681
|
onEvent({ type: 'text', text });
|
|
287
682
|
break;
|
|
288
683
|
}
|
|
@@ -305,7 +700,14 @@ async function streamChat(messages, options, onEvent) {
|
|
|
305
700
|
}
|
|
306
701
|
}
|
|
307
702
|
toolUseBlocks.push(block);
|
|
703
|
+
// Deliberately does NOT bump `emittedChars`: a streamed tool_use is a
|
|
704
|
+
// preview that no consumer renders. The agent loop's `tool_use` case is a
|
|
705
|
+
// no-op and the UI's tool row is only drawn later, from `options.onToolUse`
|
|
706
|
+
// during execution — which happens after streamChat() returns and so never
|
|
707
|
+
// runs on a failed attempt. `emittedToCaller` is still set, conservatively,
|
|
708
|
+
// so a restart continues to require an explicit rollback handler.
|
|
308
709
|
emittedToCaller = true;
|
|
710
|
+
emittedAnythingAcrossAttempts = true;
|
|
309
711
|
onEvent({ type: 'tool_use', id: block.id, name: block.name, input: block.input });
|
|
310
712
|
break;
|
|
311
713
|
}
|
|
@@ -360,6 +762,17 @@ async function streamChat(messages, options, onEvent) {
|
|
|
360
762
|
if (typeof evt.input_tokens === 'number' && typeof evt.output_tokens === 'number') {
|
|
361
763
|
onEvent({
|
|
362
764
|
type: 'usage',
|
|
765
|
+
// Forwarded, not dropped: the backend tags the usage of an attempt it
|
|
766
|
+
// billed but never completed (its stream `abort` path). Without this
|
|
767
|
+
// the flag dies here and every consumer that sums usage silently folds
|
|
768
|
+
// a discarded attempt's tokens into the successful turn's total.
|
|
769
|
+
...(evt.partial === true ? { partial: true } : {}),
|
|
770
|
+
// `replayed` means these tokens are being reported a SECOND time: the
|
|
771
|
+
// turn completed and was billed on an earlier attempt whose `done` never
|
|
772
|
+
// reached us, and the backend served this one from its idempotency cache
|
|
773
|
+
// instead of re-running the model. Nothing new was charged, so a cost
|
|
774
|
+
// display must not add them again.
|
|
775
|
+
...(evt.replayed === true ? { replayed: true } : {}),
|
|
363
776
|
usage: {
|
|
364
777
|
input_tokens: evt.input_tokens,
|
|
365
778
|
output_tokens: evt.output_tokens,
|
|
@@ -380,6 +793,9 @@ async function streamChat(messages, options, onEvent) {
|
|
|
380
793
|
const text = typeof evt.text === 'string' ? evt.text : '';
|
|
381
794
|
if (text) {
|
|
382
795
|
emittedToCaller = true;
|
|
796
|
+
emittedChars += text.length;
|
|
797
|
+
emittedAnythingAcrossAttempts = true;
|
|
798
|
+
emittedCharsAcrossAttempts += text.length;
|
|
383
799
|
onEvent({ type: 'thinking', text });
|
|
384
800
|
}
|
|
385
801
|
break;
|
|
@@ -397,10 +813,20 @@ async function streamChat(messages, options, onEvent) {
|
|
|
397
813
|
const text = typeof evt.text === 'string' ? evt.text : '';
|
|
398
814
|
if (text) {
|
|
399
815
|
emittedToCaller = true;
|
|
816
|
+
emittedChars += text.length;
|
|
817
|
+
emittedAnythingAcrossAttempts = true;
|
|
818
|
+
emittedCharsAcrossAttempts += text.length;
|
|
400
819
|
onEvent({ type: 'thinking_delta', text });
|
|
401
820
|
}
|
|
402
821
|
break;
|
|
403
822
|
}
|
|
823
|
+
case 'resumable': {
|
|
824
|
+
// The backend numbers this turn's frames and buffers them, so a dropped
|
|
825
|
+
// connection can reattach mid-response rather than re-running the turn.
|
|
826
|
+
// Purely a capability announcement — nothing to show the user.
|
|
827
|
+
serverResumable = true;
|
|
828
|
+
break;
|
|
829
|
+
}
|
|
404
830
|
case 'balance_status': {
|
|
405
831
|
const balance = typeof evt.balance === 'number' ? evt.balance : 0;
|
|
406
832
|
const zero = !!evt.zero;
|
|
@@ -417,10 +843,19 @@ async function streamChat(messages, options, onEvent) {
|
|
|
417
843
|
typeof evt.error === 'string' ? evt.error : 'Unknown SSE error';
|
|
418
844
|
// Transient upstream failure before any output → let the outer loop retry it
|
|
419
845
|
// transparently instead of killing the turn (this is the "Overloaded" case).
|
|
420
|
-
|
|
846
|
+
// But never retry once the turn's message has already been delivered in
|
|
847
|
+
// full: a late error frame (e.g. the backend's billing write failing after
|
|
848
|
+
// `message_complete`) must not discard a correct answer and re-bill the
|
|
849
|
+
// model call. Surface it as a notice and finish with what we have.
|
|
850
|
+
if (haveCompleteMessage()) {
|
|
851
|
+
clearInterval(heartbeatWatchdog);
|
|
852
|
+
onEvent({ type: 'error', message });
|
|
853
|
+
resolve();
|
|
854
|
+
}
|
|
855
|
+
else if (isRetryableStreamMsg(message) && (!emittedToCaller || allowRestartAfterRender)) {
|
|
421
856
|
clearInterval(heartbeatWatchdog);
|
|
422
857
|
stream.destroy?.();
|
|
423
|
-
reject(
|
|
858
|
+
reject(tagTransient(new Error(message)));
|
|
424
859
|
}
|
|
425
860
|
else {
|
|
426
861
|
onEvent({ type: 'error', message });
|
|
@@ -459,16 +894,21 @@ async function streamChat(messages, options, onEvent) {
|
|
|
459
894
|
resolve();
|
|
460
895
|
return;
|
|
461
896
|
}
|
|
897
|
+
// Socket died AFTER the turn was fully delivered — the only thing still
|
|
898
|
+
// outstanding was the trailing `done`. Treat as success: rejecting here would
|
|
899
|
+
// throw away a complete answer and (with restart enabled) re-run and re-bill
|
|
900
|
+
// the entire turn. This is the widest such window in practice, because the
|
|
901
|
+
// backend does a DB write between `message_complete` and `done`.
|
|
902
|
+
if (haveCompleteMessage()) {
|
|
903
|
+
resolve();
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
462
906
|
// Transport-level failures ("Premature close" / ECONNRESET / socket hang up)
|
|
463
907
|
// happen whenever the backend restarts mid-deploy or a proxy drops the socket.
|
|
464
908
|
// They are exactly as transient as an overloaded_error — if nothing has been
|
|
465
909
|
// emitted to the caller yet, retry the attempt transparently instead of
|
|
466
910
|
// surfacing "Stream failed: Premature close" and killing the whole turn.
|
|
467
|
-
|
|
468
|
-
reject(Object.assign(err, { retryable: true }));
|
|
469
|
-
return;
|
|
470
|
-
}
|
|
471
|
-
reject(err);
|
|
911
|
+
reject(tagTransient(err));
|
|
472
912
|
});
|
|
473
913
|
});
|
|
474
914
|
if (completedMessage) {
|
|
@@ -497,11 +937,48 @@ async function streamChat(messages, options, onEvent) {
|
|
|
497
937
|
catch (err) {
|
|
498
938
|
if (abortSignal?.aborted || controller.signal.aborted || err.name === 'AbortError')
|
|
499
939
|
throw err;
|
|
500
|
-
|
|
940
|
+
const e = err;
|
|
941
|
+
if (e.retryable && sAttempt < MAX_RETRIES && canRetry()) {
|
|
942
|
+
// PREFER RESUMING over restarting.
|
|
943
|
+
//
|
|
944
|
+
// The turn is still generating on the server (a dropped socket no longer aborts
|
|
945
|
+
// it), and every frame it emits is numbered and buffered. So instead of throwing
|
|
946
|
+
// the work away we reattach at `lastEventId` and receive only what we missed:
|
|
947
|
+
// nothing re-processed, nothing re-billed, nothing re-rendered — which also means
|
|
948
|
+
// NO `stream_restart` and no rollback for the caller, because the output already
|
|
949
|
+
// on screen remains valid.
|
|
950
|
+
//
|
|
951
|
+
// Requires having seen at least one numbered frame; without a cursor there is
|
|
952
|
+
// nothing to resume from and a fresh attempt is correct anyway.
|
|
953
|
+
if (serverResumable && lastEventId > 0) {
|
|
954
|
+
resuming = true;
|
|
955
|
+
reportRetry(err.message || 'Connection interrupted — resuming');
|
|
956
|
+
await sleepWithinBudget(backoffMs(sAttempt));
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
// Not resumable — fall back to restarting the whole turn. The dead attempt had
|
|
960
|
+
// already rendered output, so the caller must throw that partial render away
|
|
961
|
+
// BEFORE the replacement starts streaming or the re-sent text appears twice.
|
|
962
|
+
// Emitted first so the UI is clean by the time "reconnecting…" goes up.
|
|
963
|
+
if (e.needsRestart) {
|
|
964
|
+
onEvent({
|
|
965
|
+
type: 'stream_restart',
|
|
966
|
+
reason: err.message || 'Connection interrupted',
|
|
967
|
+
discardedChars: e.discardedChars ?? 0,
|
|
968
|
+
});
|
|
969
|
+
}
|
|
501
970
|
reportRetry(err.message || 'Connection interrupted — reconnecting');
|
|
502
|
-
await
|
|
971
|
+
await sleepWithinBudget(backoffMs(sAttempt));
|
|
503
972
|
continue;
|
|
504
973
|
}
|
|
974
|
+
// Out of retries (or out of time budget) — make the distinction explicit so
|
|
975
|
+
// "it just gave up" is never a mystery. A budget exhaustion means the network
|
|
976
|
+
// was down for the entire window, which is actionable in a way that a bare
|
|
977
|
+
// "Stream failed" is not.
|
|
978
|
+
if (e.retryable && !canRetry()) {
|
|
979
|
+
throw Object.assign(new Error(`${err.message} — gave up after ${Math.round(MAX_TOTAL_RETRY_MS / 60000)} minutes of reconnect attempts. ` +
|
|
980
|
+
`Check your connection and resend; completed work in this turn is preserved.`), { retryBudgetExhausted: true });
|
|
981
|
+
}
|
|
505
982
|
throw err;
|
|
506
983
|
}
|
|
507
984
|
}
|
|
@@ -512,6 +989,31 @@ async function streamChat(messages, options, onEvent) {
|
|
|
512
989
|
clearInterval(abortPoll);
|
|
513
990
|
}
|
|
514
991
|
}
|
|
992
|
+
// ─── Cancel a turn ────────────────────────────────────────────────────────────
|
|
993
|
+
/**
|
|
994
|
+
* Ask the server to stop generating a turn.
|
|
995
|
+
*
|
|
996
|
+
* This is REQUIRED for Stop to work, not an optimisation. A dropped socket used to abort
|
|
997
|
+
* the model; now it detaches and keeps generating so a network blip doesn't discard the
|
|
998
|
+
* turn (see the resume machinery above). Since a deliberate Stop looks identical to a
|
|
999
|
+
* blip from the server's side, it has to be signalled explicitly — otherwise the model
|
|
1000
|
+
* would run to completion and bill in full after the user pressed Stop.
|
|
1001
|
+
*
|
|
1002
|
+
* Best-effort and never throws: Stop must feel instant, and the server's detach grace
|
|
1003
|
+
* bounds the cost if this request never lands.
|
|
1004
|
+
*/
|
|
1005
|
+
async function cancelTurn(turnId) {
|
|
1006
|
+
try {
|
|
1007
|
+
await (0, node_fetch_1.default)(`${exports.API_BASE}/api/code/chat/cancel`, {
|
|
1008
|
+
method: 'POST',
|
|
1009
|
+
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
|
|
1010
|
+
body: JSON.stringify({ turnId }),
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
catch {
|
|
1014
|
+
// Ignored deliberately — see above.
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
515
1017
|
// ─── Get Balance ──────────────────────────────────────────────────────────────
|
|
516
1018
|
async function getBalance() {
|
|
517
1019
|
const response = await (0, node_fetch_1.default)(`${exports.API_BASE}/api/code/balance`, {
|