@nexrall/code-core 1.4.14 → 1.4.16

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.
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/api/client.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,UAAU,EAA8B,UAAU,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAiErH,eAAO,MAAM,QAAQ,QAAmB,CAAC;AAIzC;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,kBAAkB,CAAC,CAAC,SAAS;IAAE,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,EAC5D,OAAO,EAAE,CAAC,EAAE,EACZ,UAAU,EAAE,KAAK,CAAC;IAAE,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,GAAG,IAAI,GAAG,SAAS,GACtD,CAAC,EAAE,CAeL;AAeD,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,UAAU,CAAC;IACjB,aAAa,CAAC,EAAE,aAAa,GAAG,IAAI,CAAC;IACrC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC;IACnC,oFAAoF;IACpF,UAAU,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;IACjG,6EAA6E;IAC7E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,6IAA6I;IAC7I,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;;;;;;;;;;OAeG;IACH,uBAAuB,CAAC,EAAE,OAAO,CAAC;CACnC;AAiDD,wBAAsB,UAAU,CAC9B,QAAQ,EAAE,OAAO,EAAE,EACnB,OAAO,EAAE,iBAAiB,EAC1B,OAAO,EAAE,CAAC,CAAC,EAAE,QAAQ,KAAK,IAAI,GAC7B,OAAO,CAAC,OAAO,CAAC,CA80BlB;AAID;;;;;;;;;;;GAWG;AACH,wBAAsB,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAU9D;AAID,wBAAsB,UAAU,IAAI,OAAO,CAAC,MAAM,CAAC,CAalD;AAID,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAgB1E;AAID,wBAAsB,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAkBhF"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/api/client.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,UAAU,EAA8B,UAAU,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAiErH,eAAO,MAAM,QAAQ,QAAmB,CAAC;AAIzC;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,kBAAkB,CAAC,CAAC,SAAS;IAAE,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,EAC5D,OAAO,EAAE,CAAC,EAAE,EACZ,UAAU,EAAE,KAAK,CAAC;IAAE,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,GAAG,IAAI,GAAG,SAAS,GACtD,CAAC,EAAE,CAeL;AAeD,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,UAAU,CAAC;IACjB,aAAa,CAAC,EAAE,aAAa,GAAG,IAAI,CAAC;IACrC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC;IACnC,oFAAoF;IACpF,UAAU,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;IACjG,6EAA6E;IAC7E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,6IAA6I;IAC7I,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;;;;;;;;;;OAeG;IACH,uBAAuB,CAAC,EAAE,OAAO,CAAC;CACnC;AAiDD,wBAAsB,UAAU,CAC9B,QAAQ,EAAE,OAAO,EAAE,EACnB,OAAO,EAAE,iBAAiB,EAC1B,OAAO,EAAE,CAAC,CAAC,EAAE,QAAQ,KAAK,IAAI,GAC7B,OAAO,CAAC,OAAO,CAAC,CAi7BlB;AAID;;;;;;;;;;;GAWG;AACH,wBAAsB,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAU9D;AAID,wBAAsB,UAAU,IAAI,OAAO,CAAC,MAAM,CAAC,CAalD;AAID,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAgB1E;AAID,wBAAsB,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAkBhF"}
@@ -314,6 +314,10 @@ async function streamChat(messages, options, onEvent) {
314
314
  // A 409's body, already consumed to classify it. node-fetch bodies are single-use, so
315
315
  // the !response.ok handler below reads this instead of calling response.text() again.
316
316
  let errorBodyOverride = null;
317
+ // Set when a 403 upstream-forbidden (Anthropic passthrough, see below) used up its
318
+ // short in-loop retry budget — the final throw tags `retryable: true` so it still
319
+ // reaches the outer 5-minute stream-retry loop instead of dying as a terminal error.
320
+ let forbiddenExhausted = false;
317
321
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
318
322
  try {
319
323
  response = await (0, node_fetch_1.default)(...buildFetchArgs());
@@ -328,6 +332,48 @@ async function streamChat(messages, options, onEvent) {
328
332
  await sleepWithinBudget(backoffMs(attempt));
329
333
  continue;
330
334
  }
335
+ // 403 `{"error":{"type":"forbidden","message":"Request not allowed"}}` is
336
+ // Anthropic's own upstream response shape (reproduced verbatim through the AI
337
+ // Gateway passthrough — see services/aiGatewayClient.js), not something Nexrall's
338
+ // auth layer produces (an expired/invalid Nexrall JWT is a 401/403 with a
339
+ // completely different body — see middleware/auth.js). Reports of this exact
340
+ // error on Anthropic's own CLI describe it as intermittent and gone on retry, not
341
+ // a real permissions failure — so unlike every OTHER 4xx below (which are
342
+ // deterministic given the same body and must not be retried), this one gets a
343
+ // short, capped number of attempts before being treated as terminal. Capped well
344
+ // below MAX_RETRIES so a genuinely revoked/banned key still fails fast instead of
345
+ // burning the whole reconnect budget on something that will never succeed.
346
+ const FORBIDDEN_RETRY_LIMIT = 2;
347
+ if (response.status === 403) {
348
+ const bodyText = await response.text().catch(() => '');
349
+ let isUpstreamForbidden = false;
350
+ try {
351
+ const parsed = JSON.parse(bodyText);
352
+ isUpstreamForbidden = parsed?.error?.type === 'forbidden';
353
+ }
354
+ catch { /* not JSON — not this shape, fall through as terminal below */ }
355
+ if (isUpstreamForbidden) {
356
+ if (attempt < FORBIDDEN_RETRY_LIMIT && canRetry()) {
357
+ reportRetry('Upstream rejected the request — retrying');
358
+ await sleepWithinBudget(backoffMs(attempt));
359
+ continue;
360
+ }
361
+ // Exhausted the short in-connect-loop attempt budget, but this is still the
362
+ // SAME transient upstream shape (not a real permissions failure) — it must not
363
+ // die here after only ~2-4s of backoff. `forbiddenExhausted` makes the final
364
+ // throw below tag `retryable: true` so the OUTER stream-retry loop (which owns
365
+ // the shared 5-minute reconnect budget — see MAX_TOTAL_RETRY_MS) picks it back
366
+ // up and keeps trying, instead of surfacing a bare terminal "API error 403"
367
+ // while minutes of retry budget were still available.
368
+ forbiddenExhausted = true;
369
+ errorBodyOverride = bodyText;
370
+ break;
371
+ }
372
+ // Not the known transient shape — treat as terminal, but reuse the body we
373
+ // already consumed (node-fetch bodies are single-use) instead of losing it.
374
+ errorBodyOverride = bodyText;
375
+ break;
376
+ }
331
377
  // 410 Gone — a resume was refused because the buffered window no longer contains
332
378
  // our position (we were away too long, or the turn produced a huge amount in the
333
379
  // meantime). Continuity can't be proven, and silently skipping frames would corrupt
@@ -468,7 +514,11 @@ async function streamChat(messages, options, onEvent) {
468
514
  // Tag a 402 (pre-flight "wallet already empty" gate — see routes/code.js) so the
469
515
  // agent loop can distinguish it from a generic error and surface the low/zero-
470
516
  // balance topup nudge immediately, instead of a plain "API error 402" message.
471
- throw Object.assign(new Error(errMsg), { status: response.status, balance });
517
+ //
518
+ // `retryable: true` on `forbiddenExhausted` hands the transient 403-upstream-forbidden
519
+ // case (see above) to the outer stream-retry loop instead of letting it die as a
520
+ // terminal error the moment the short in-connect-loop budget runs out.
521
+ throw Object.assign(new Error(errMsg), { status: response.status, balance, ...(forbiddenExhausted ? { retryable: true } : {}) });
472
522
  }
473
523
  if (!response.body) {
474
524
  throw new Error('Response body is null');
@@ -841,6 +891,16 @@ async function streamChat(messages, options, onEvent) {
841
891
  case 'error': {
842
892
  const message = typeof evt.message === 'string' ? evt.message :
843
893
  typeof evt.error === 'string' ? evt.error : 'Unknown SSE error';
894
+ // The GET /chat/resume tail loop sends this when the turn's Redis frame
895
+ // buffer developed a gap (trimmed window, a swallowed flush error, or the
896
+ // owning pod died) or the turn is simply gone — see routes/code.js. Unlike
897
+ // `isRetryableStreamMsg` below, this is NOT a fuzzy text match: the backend
898
+ // sets the flag explicitly, so it can never silently stop matching if either
899
+ // side rewrites the English message. There is no cursor left to resume
900
+ // from — a plain "retryable" resume-with-Last-Event-ID would just 410 again
901
+ // — so this always needs a fresh turnId, which the outer loop mints via
902
+ // `forceRestart`.
903
+ const notResumable = evt.notResumable === true;
844
904
  // Transient upstream failure before any output → let the outer loop retry it
845
905
  // transparently instead of killing the turn (this is the "Overloaded" case).
846
906
  // But never retry once the turn's message has already been delivered in
@@ -852,6 +912,11 @@ async function streamChat(messages, options, onEvent) {
852
912
  onEvent({ type: 'error', message });
853
913
  resolve();
854
914
  }
915
+ else if (notResumable && (!emittedToCaller || allowRestartAfterRender)) {
916
+ clearInterval(heartbeatWatchdog);
917
+ stream.destroy?.();
918
+ reject(Object.assign(tagTransient(new Error(message)), { forceRestart: true }));
919
+ }
855
920
  else if (isRetryableStreamMsg(message) && (!emittedToCaller || allowRestartAfterRender)) {
856
921
  clearInterval(heartbeatWatchdog);
857
922
  stream.destroy?.();
@@ -939,6 +1004,38 @@ async function streamChat(messages, options, onEvent) {
939
1004
  throw err;
940
1005
  const e = err;
941
1006
  if (e.retryable && sAttempt < MAX_RETRIES && canRetry()) {
1007
+ // The backend explicitly said this turnId's buffer is unrecoverable (gap in the
1008
+ // Redis frame stream, or the owning pod is gone — see the `notResumable` case in
1009
+ // client.ts's SSE parser). Resuming with the same `lastEventId` would just hit
1010
+ // the same wall again, so — exactly like the inline 410-while-resuming handling
1011
+ // above — release the old claim, mint a fresh turnId, and restart clean instead
1012
+ // of falling into the "prefer resuming" branch below.
1013
+ if (e.forceRestart) {
1014
+ await cancelTurn(turnId);
1015
+ turnId = (0, crypto_1.randomUUID)();
1016
+ resuming = false;
1017
+ serverResumable = false;
1018
+ lastEventId = 0;
1019
+ carryText = [];
1020
+ carryToolUse = [];
1021
+ const hadRendered = emittedAnythingAcrossAttempts;
1022
+ const renderedChars = emittedCharsAcrossAttempts;
1023
+ emittedAnythingAcrossAttempts = false;
1024
+ emittedCharsAcrossAttempts = 0;
1025
+ if (hadRendered && !allowRestartAfterRender) {
1026
+ throw new Error('Connection lost and this turn could no longer be resumed. Send your message again.');
1027
+ }
1028
+ if (hadRendered) {
1029
+ onEvent({
1030
+ type: 'stream_restart',
1031
+ reason: err.message || 'Could not resume — restarting this turn',
1032
+ discardedChars: renderedChars,
1033
+ });
1034
+ }
1035
+ reportRetry('Could not resume — restarting this turn');
1036
+ await sleepWithinBudget(backoffMs(sAttempt));
1037
+ continue;
1038
+ }
942
1039
  // PREFER RESUMING over restarting.
943
1040
  //
944
1041
  // The turn is still generating on the server (a dropped socket no longer aborts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nexrall/code-core",
3
- "version": "1.4.14",
3
+ "version": "1.4.16",
4
4
  "description": "Core agent loop, tools, and extension primitives for Nexrall Code — embed an AI coding agent in any Node.js application.",
5
5
  "license": "MIT",
6
6
  "author": "Nexrall <support@nexrall.com> (https://nexrall.com)",