bunnyquery 1.3.2 → 1.3.5
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/bunnyquery.js +73 -3
- package/dist/engine.cjs +74 -3
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +4 -1
- package/dist/engine.d.ts +4 -1
- package/dist/engine.mjs +74 -4
- package/dist/engine.mjs.map +1 -1
- package/package.json +1 -1
- package/src/engine/errors.ts +49 -0
- package/src/engine/index.ts +1 -1
- package/src/engine/session.ts +62 -4
package/package.json
CHANGED
package/src/engine/errors.ts
CHANGED
|
@@ -34,6 +34,55 @@ export function isErrorResponseBody(response: any): boolean {
|
|
|
34
34
|
return false;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
// A request that failed because the REQUEST ITSELF is malformed — an unknown or
|
|
38
|
+
// rejected parameter on a 400/422 — will deterministically re-fail if resent
|
|
39
|
+
// unchanged; no session refresh or retry can fix it. Detecting it lets the retry
|
|
40
|
+
// gates refuse to auto-resend (otherwise a 400 whose param name merely contains
|
|
41
|
+
// "token", e.g. `max_output_tokens`, trips the `invalid_request + token` branch
|
|
42
|
+
// of isAuthExpiredError below and loops refresh->resend forever, burning tokens).
|
|
43
|
+
// This is the `_skapi_extract` 400 class. Distinct from auth-expiry, which IS
|
|
44
|
+
// retryable after a token refresh — so 401 (auth) and 429/5xx (transient) are
|
|
45
|
+
// intentionally NOT treated as non-retryable here.
|
|
46
|
+
export function isNonRetryableRequestError(input: any): boolean {
|
|
47
|
+
if (!input || typeof input !== 'object') return false;
|
|
48
|
+
|
|
49
|
+
var status = typeof input.status_code === 'number' ? input.status_code
|
|
50
|
+
: typeof input.status === 'number' ? input.status : undefined;
|
|
51
|
+
|
|
52
|
+
// Collect the error envelope wherever it lives (response, response.body, or a
|
|
53
|
+
// thrown error), mirroring isErrorResponseBody / isAuthExpiredError.
|
|
54
|
+
var param: any = undefined;
|
|
55
|
+
var blobs: string[] = [];
|
|
56
|
+
var sources = [input.error, input.body && input.body.error, input.body, input];
|
|
57
|
+
for (var i = 0; i < sources.length; i++) {
|
|
58
|
+
var e = sources[i];
|
|
59
|
+
if (!e) continue;
|
|
60
|
+
if (typeof e === 'string') { blobs.push(e); continue; }
|
|
61
|
+
if (typeof e !== 'object') continue;
|
|
62
|
+
if (param === undefined && e.param != null) param = e.param;
|
|
63
|
+
if (typeof e.code === 'string') blobs.push(e.code);
|
|
64
|
+
if (typeof e.type === 'string') blobs.push(e.type);
|
|
65
|
+
if (typeof e.message === 'string') blobs.push(e.message);
|
|
66
|
+
}
|
|
67
|
+
var hay = blobs.join(' | ').toLowerCase();
|
|
68
|
+
|
|
69
|
+
// Explicit "unknown / unsupported parameter" is always a malformed request.
|
|
70
|
+
if (hay.indexOf('unknown_parameter') !== -1 || hay.indexOf('unknown parameter') !== -1 ||
|
|
71
|
+
hay.indexOf('unsupported_parameter') !== -1 || hay.indexOf('unsupported parameter') !== -1) {
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// A 400/422 invalid_request that points at a specific rejected field: resending
|
|
76
|
+
// the identical body re-fails. (401 -> isAuthExpiredError; 429/5xx -> transient.)
|
|
77
|
+
var isClientReqStatus = status === 400 || status === 422;
|
|
78
|
+
if (isClientReqStatus && param != null && param !== '') return true;
|
|
79
|
+
if (isClientReqStatus && hay.indexOf('invalid_request') !== -1 &&
|
|
80
|
+
(hay.indexOf('parameter') !== -1 || hay.indexOf('param') !== -1)) {
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
37
86
|
export function isAuthExpiredError(input: any): boolean {
|
|
38
87
|
if (!input) return false;
|
|
39
88
|
var blobs: string[] = [];
|
package/src/engine/index.ts
CHANGED
|
@@ -30,7 +30,7 @@ export * from './prompts';
|
|
|
30
30
|
|
|
31
31
|
// Pure helpers (Tier-1.5): error detection, token budgeting, link/path
|
|
32
32
|
// normalization, and history mapping — shared so both consumers stay identical.
|
|
33
|
-
export { getErrorMessage, isErrorResponseBody, isAuthExpiredError } from './errors';
|
|
33
|
+
export { getErrorMessage, isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError } from './errors';
|
|
34
34
|
export * from './budget';
|
|
35
35
|
export * from './links';
|
|
36
36
|
export {
|
package/src/engine/session.ts
CHANGED
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
OPENAI_RESPONSES_API_URL,
|
|
31
31
|
type BgTaskEntry,
|
|
32
32
|
} from './requests';
|
|
33
|
-
import { isErrorResponseBody, isAuthExpiredError, getErrorMessage } from './errors';
|
|
33
|
+
import { isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError, getErrorMessage } from './errors';
|
|
34
34
|
import { buildBoundedChatMessages } from './budget';
|
|
35
35
|
import { createInlineLinkRegex } from './links';
|
|
36
36
|
import { mapHistoryListToMessages, extractLastUserTextFromRequest } from './history';
|
|
@@ -116,11 +116,15 @@ export class ChatSession {
|
|
|
116
116
|
};
|
|
117
117
|
var run = sendAndPoll()
|
|
118
118
|
.catch(function (err: any) {
|
|
119
|
-
|
|
119
|
+
// Only auth-expiry is worth a refresh+resend; a malformed-request 400
|
|
120
|
+
// (e.g. the `_skapi_extract`/unknown-parameter class) re-fails identically,
|
|
121
|
+
// so never loop on it — guard against isAuthExpiredError's heuristic
|
|
122
|
+
// misfiring on a param name that merely contains "token".
|
|
123
|
+
if (isAuthExpiredError(err) && !isNonRetryableRequestError(err)) return self.host.refreshSession().then(sendAndPoll);
|
|
120
124
|
throw err;
|
|
121
125
|
})
|
|
122
126
|
.then(function (response: any) {
|
|
123
|
-
if (isErrorResponseBody(response) && isAuthExpiredError(response)) {
|
|
127
|
+
if (isErrorResponseBody(response) && isAuthExpiredError(response) && !isNonRetryableRequestError(response)) {
|
|
124
128
|
return self.host.refreshSession().then(sendAndPoll);
|
|
125
129
|
}
|
|
126
130
|
return response;
|
|
@@ -348,6 +352,7 @@ export class ChatSession {
|
|
|
348
352
|
this.host.notify(); this.enqueueTypewrite(aiIdx, answer, lid);
|
|
349
353
|
}
|
|
350
354
|
}
|
|
355
|
+
this._removeStrayPendingAssistants();
|
|
351
356
|
this.promoteNextQueuedToRunning();
|
|
352
357
|
this.updateHistoryCache();
|
|
353
358
|
this.host.notify();
|
|
@@ -382,12 +387,14 @@ export class ChatSession {
|
|
|
382
387
|
if (thPos2 !== -1) this.state.messages.splice(thPos2, 1);
|
|
383
388
|
}
|
|
384
389
|
if (serverId) this.cancelledServerIds.delete(serverId);
|
|
390
|
+
this._removeStrayPendingAssistants();
|
|
385
391
|
this.promoteNextQueuedToRunning(); this.updateHistoryCache(); this.host.notify(); this.host.scrollToBottom(true);
|
|
386
392
|
return;
|
|
387
393
|
}
|
|
388
394
|
var targetIdx = this.resolveQueuedUserBubble(serverId);
|
|
389
395
|
if (targetIdx === undefined) { this.host.notify(); this.updateHistoryCache(); return; }
|
|
390
396
|
this.insertAtTarget({ role: 'assistant', content: getErrorMessage(err), isError: true }, targetIdx);
|
|
397
|
+
this._removeStrayPendingAssistants();
|
|
391
398
|
this.promoteNextQueuedToRunning(); this.updateHistoryCache(); this.host.notify(); this.host.scrollToBottom(true);
|
|
392
399
|
}
|
|
393
400
|
|
|
@@ -512,17 +519,52 @@ export class ChatSession {
|
|
|
512
519
|
if (pendingIdx === -1) return Promise.resolve();
|
|
513
520
|
if (latest.isError || !latest.content) {
|
|
514
521
|
this.state.messages[pendingIdx] = { role: 'assistant', content: latest.content || '', isError: !!latest.isError };
|
|
522
|
+
this._removeStrayPendingAssistants();
|
|
515
523
|
this.host.notify();
|
|
516
524
|
this.promoteNextQueuedToRunning();
|
|
517
525
|
return Promise.resolve();
|
|
518
526
|
}
|
|
519
527
|
var lid = this._newLocalId();
|
|
520
528
|
this.state.messages[pendingIdx] = { role: 'assistant', content: '', isPending: false, _localId: lid };
|
|
529
|
+
this._removeStrayPendingAssistants();
|
|
521
530
|
this.host.notify();
|
|
522
531
|
this.promoteNextQueuedToRunning();
|
|
523
532
|
return this.enqueueTypewrite(pendingIdx, latest.content, lid);
|
|
524
533
|
}
|
|
525
534
|
|
|
535
|
+
// Remove any leftover non-background pending ("Thinking…") assistant bubbles.
|
|
536
|
+
// There is normally at most ONE such bubble at a time (promoteNext* refuses to
|
|
537
|
+
// add a second), so any extra is a duplicate — it appears when a concurrent
|
|
538
|
+
// history refetch re-maps the still-"running" turn into a pending placeholder
|
|
539
|
+
// (with a real _serverItemId) while the local pending bubble (no _serverItemId)
|
|
540
|
+
// is rescued and re-appended (see loadHistory rescue below). Each resolve path
|
|
541
|
+
// only replaces the FIRST pending bubble, so without this a stray "Thinking…"
|
|
542
|
+
// survives next to the reply/error. MUST run AFTER the resolved bubble has been
|
|
543
|
+
// made non-pending and BEFORE promoteNext*() (so a freshly-promoted Thinking,
|
|
544
|
+
// which is added only once no pending assistant remains, is preserved).
|
|
545
|
+
_removeStrayPendingAssistants(): void {
|
|
546
|
+
for (var k = this.state.messages.length - 1; k >= 0; k--) {
|
|
547
|
+
var m = this.state.messages[k];
|
|
548
|
+
if (m.isPending && m.role === 'assistant' && !m.isBackgroundTask) this.state.messages.splice(k, 1);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Drop the pending flags on the resolved turn's USER bubble (preserving its
|
|
553
|
+
// content + background-task marker). Needed because a bg "Indexing:" turn's user
|
|
554
|
+
// bubble carries isPendingInProcess; leaving it set keeps the bubble visually
|
|
555
|
+
// stuck and keeps its bgTaskQueue entry alive forever.
|
|
556
|
+
_clearPendingUserBubble(itemId: string): void {
|
|
557
|
+
var uIdx = this.state.messages.findIndex(function (m) {
|
|
558
|
+
return m.role === 'user' && m._serverItemId === itemId &&
|
|
559
|
+
(m.isPendingInProcess || m.isPendingQueued || m.isSendingToServer);
|
|
560
|
+
});
|
|
561
|
+
if (uIdx === -1) return;
|
|
562
|
+
var u = this.state.messages[uIdx];
|
|
563
|
+
var cleaned: ChatMessage = { role: 'user', content: u.content, _serverItemId: itemId };
|
|
564
|
+
if (u.isBackgroundTask) cleaned.isBackgroundTask = true;
|
|
565
|
+
this.state.messages[uIdx] = cleaned;
|
|
566
|
+
}
|
|
567
|
+
|
|
526
568
|
// If an immediate-send request for the current cache key is still in flight
|
|
527
569
|
// (e.g. the view unmounted then remounted mid-request), show the sending
|
|
528
570
|
// state, await it, then render the reply from the cache. Skipped when the
|
|
@@ -557,6 +599,11 @@ export class ChatSession {
|
|
|
557
599
|
: ((platform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response)) || '').trim();
|
|
558
600
|
var idx = this.state.messages.findIndex(function (m) { return m.isPending && m._serverItemId === itemId; });
|
|
559
601
|
if (idx !== -1) {
|
|
602
|
+
// A bg "Indexing:" turn pushes a user bubble (isPendingInProcess) ALONGSIDE
|
|
603
|
+
// the assistant Thinking; replacing only the assistant leaves that user
|
|
604
|
+
// bubble stuck pending — and drainBgTaskQueue then never clears its queue
|
|
605
|
+
// entry (its stillPending check stays true). Un-pend it here too.
|
|
606
|
+
this._clearPendingUserBubble(itemId);
|
|
560
607
|
if (isErr) {
|
|
561
608
|
this.state.messages[idx] = { role: 'assistant', content: answer, isError: true, _serverItemId: itemId };
|
|
562
609
|
this.host.notify(); this.updateHistoryCache(); return;
|
|
@@ -656,7 +703,7 @@ export class ChatSession {
|
|
|
656
703
|
var fetchHistory = function () { return getChatHistory({ service: serviceId, owner: owner, platform: platform }, options); };
|
|
657
704
|
|
|
658
705
|
return Promise.resolve().then(fetchHistory).catch(function (err: any) {
|
|
659
|
-
if (isAuthExpiredError(err)) return self.host.refreshSession().then(fetchHistory);
|
|
706
|
+
if (isAuthExpiredError(err) && !isNonRetryableRequestError(err)) return self.host.refreshSession().then(fetchHistory);
|
|
660
707
|
throw err;
|
|
661
708
|
}).then(function (history: any) {
|
|
662
709
|
if (token !== self.state.gateRefreshToken) return;
|
|
@@ -686,12 +733,23 @@ export class ChatSession {
|
|
|
686
733
|
mapped.forEach(function (m: any) { if (m._serverItemId) serverIds[m._serverItemId] = 1; });
|
|
687
734
|
var locallyCancelled: any = {};
|
|
688
735
|
self.state.messages.forEach(function (m) { if (m.isCancelled && m._serverItemId) locallyCancelled[m._serverItemId] = 1; });
|
|
736
|
+
// If the freshly-mapped server list ALREADY shows this in-flight turn
|
|
737
|
+
// as a pending placeholder (a non-bg pending assistant, which carries
|
|
738
|
+
// a real _serverItemId), re-pushing the local no-_serverItemId
|
|
739
|
+
// user+Thinking would DUPLICATE it. Each resolve path only clears the
|
|
740
|
+
// first pending bubble, so the duplicate strands a "Thinking…" beside
|
|
741
|
+
// the reply/error. There is at most one in-flight regular turn, so a
|
|
742
|
+
// mapped pending assistant IS this turn — skip the redundant local copy.
|
|
743
|
+
var mappedHasPendingAssistant = mapped.some(function (m: any) {
|
|
744
|
+
return m.isPending && m.role === 'assistant' && !m.isBackgroundTask;
|
|
745
|
+
});
|
|
689
746
|
var rescued: ChatMessage[] = [];
|
|
690
747
|
for (var ri = 0; ri < self.state.messages.length; ri++) {
|
|
691
748
|
var mm = self.state.messages[ri];
|
|
692
749
|
if (mm.isBackgroundTask) continue;
|
|
693
750
|
if (mm._serverItemId && serverIds[mm._serverItemId]) continue;
|
|
694
751
|
if (!mm._serverItemId) {
|
|
752
|
+
if (mappedHasPendingAssistant) continue;
|
|
695
753
|
if (mm.isSendingToServer || mm.isPendingQueued || mm.isPendingInProcess || mm.isPending) rescued.push(mm);
|
|
696
754
|
else if (self.state.sending && mm.role === 'user') {
|
|
697
755
|
var next = self.state.messages[ri + 1];
|