@goodandready/dsh-key-rotation 0.8.76 → 0.8.78
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/CHANGELOG.md +14 -0
- package/lib/client-src/00-header.js +40 -0
- package/lib/client-src/10-locales.js +585 -0
- package/lib/client-src/20-hooks.js +55 -0
- package/lib/client-src/30-utils.js +185 -0
- package/lib/client-src/40-components.js +346 -0
- package/lib/client-src/50-card-main.js +647 -0
- package/lib/client-src/51-card-pool-mutators.js +501 -0
- package/lib/client-src/52-card-pool-rows.js +590 -0
- package/lib/client-src/53-card-dialogs-and-render.js +568 -0
- package/lib/client-src/60-header-chip.js +137 -0
- package/lib/client-src/90-apply.js +69 -0
- package/lib/error-taxonomy.js +59 -10
- package/lib/errors.js +302 -0
- package/lib/index.js +6 -0
- package/lib/model-catalog.js +296 -0
- package/lib/pool-selection.js +126 -28
- package/lib/pool.js +19 -0
- package/lib/quota-runway.js +222 -0
- package/lib/rotate.js +61 -11
- package/lib/session-router.js +265 -0
- package/lib/stream-metrics.js +13 -5
- package/lib/transports/anthropic.js +125 -0
- package/lib/transports/index.js +23 -0
- package/lib/transports/openai.js +93 -0
- package/lib/transports/sse.js +160 -0
- package/lib/transports/tokens.js +124 -0
- package/package.json +5 -1
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// (issue 201, #499) header chip: one dot + counts for all pools. Green = all healthy,
|
|
2
|
+
// amber = some keys cooling, red = a pool fully exhausted. Click opens quick actions popover.
|
|
3
|
+
function KeyRotationHeaderChip(props) {
|
|
4
|
+
const t = resolveT(props);
|
|
5
|
+
const [snap, setSnap] = React.useState(null);
|
|
6
|
+
const [open, setOpen] = React.useState(false);
|
|
7
|
+
const [, setTick] = React.useState(0);
|
|
8
|
+
const [busy, setBusy] = React.useState(false);
|
|
9
|
+
const ref = React.useRef(null);
|
|
10
|
+
|
|
11
|
+
const load = () => {
|
|
12
|
+
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return;
|
|
13
|
+
fetch('/dsh-key-rotation/health', {
|
|
14
|
+
headers: { accept: 'application/json' },
|
|
15
|
+
credentials: 'same-origin',
|
|
16
|
+
signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(8000) : undefined
|
|
17
|
+
})
|
|
18
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
19
|
+
.then((d) => { if (d) setSnap(d); })
|
|
20
|
+
.catch((e) => { void e; });
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
React.useEffect(() => {
|
|
24
|
+
load();
|
|
25
|
+
const id = setInterval(load, 5000);
|
|
26
|
+
const onVis = () => { if (typeof document !== 'undefined' && document.visibilityState === 'visible') load(); };
|
|
27
|
+
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVis);
|
|
28
|
+
return () => {
|
|
29
|
+
clearInterval(id);
|
|
30
|
+
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVis);
|
|
31
|
+
};
|
|
32
|
+
}, []);
|
|
33
|
+
|
|
34
|
+
React.useEffect(() => {
|
|
35
|
+
if (!props?.settingsSource?.subscribe) return;
|
|
36
|
+
return props.settingsSource.subscribe(() => {
|
|
37
|
+
setTick((c) => c + 1);
|
|
38
|
+
});
|
|
39
|
+
}, [props?.settingsSource]);
|
|
40
|
+
|
|
41
|
+
React.useEffect(() => {
|
|
42
|
+
if (!open) return;
|
|
43
|
+
const onDocClick = (e) => {
|
|
44
|
+
if (ref.current && !ref.current.contains(e.target)) setOpen(false);
|
|
45
|
+
};
|
|
46
|
+
document.addEventListener('click', onDocClick);
|
|
47
|
+
return () => document.removeEventListener('click', onDocClick);
|
|
48
|
+
}, [open]);
|
|
49
|
+
|
|
50
|
+
const runAction = (action, provider, keyRef) => {
|
|
51
|
+
setBusy(true);
|
|
52
|
+
fetch('/dsh-key-rotation/quick-action', {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
55
|
+
body: JSON.stringify({ action, provider, ref: keyRef }),
|
|
56
|
+
credentials: 'same-origin',
|
|
57
|
+
signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(5000) : undefined,
|
|
58
|
+
})
|
|
59
|
+
.then((r) => r.json())
|
|
60
|
+
.then(() => { load(); })
|
|
61
|
+
.catch((err) => { void err; })
|
|
62
|
+
.finally(() => { setBusy(false); });
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
let settingsVal = null;
|
|
66
|
+
try {
|
|
67
|
+
const sSnap = props?.settingsSource?.getSnapshot?.()?.service?.getSnapshot?.();
|
|
68
|
+
settingsVal = sSnap?.value;
|
|
69
|
+
} catch (e) {
|
|
70
|
+
void e;
|
|
71
|
+
}
|
|
72
|
+
if (settingsVal && settingsVal.showChatHeaderChip === false) return null;
|
|
73
|
+
if (snap && snap.showChatHeaderChip === false) return null;
|
|
74
|
+
|
|
75
|
+
const poolsObj = (snap && snap.pools) || {};
|
|
76
|
+
const poolEntries = Object.entries(poolsObj);
|
|
77
|
+
const total = poolEntries.reduce((a, [, p]) => a + (p.total || 0), 0);
|
|
78
|
+
const healthy = poolEntries.reduce((a, [, p]) => a + (p.healthy || 0), 0);
|
|
79
|
+
const anyExhausted = poolEntries.some(([, p]) => p.exhausted);
|
|
80
|
+
const color = !poolEntries.length ? 'var(--dsw-alias-label-tertiary)' : anyExhausted ? 'var(--dsw-alias-state-error-primary)' : healthy < total ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-state-success-primary)';
|
|
81
|
+
const label = poolEntries.length ? `${healthy}/${total} rot` : 'rot';
|
|
82
|
+
|
|
83
|
+
return h('div', { ref, style: { position: 'relative', display: 'inline-flex' } },
|
|
84
|
+
h('button', {
|
|
85
|
+
type: 'button',
|
|
86
|
+
className: 'krot-header-chip',
|
|
87
|
+
title: t('title'),
|
|
88
|
+
onClick: () => setOpen((v) => !v),
|
|
89
|
+
},
|
|
90
|
+
h('span', { style: { width: '8px', height: '8px', borderRadius: '50%', background: color, flex: 'none', boxShadow: `0 0 6px ${color}` } }),
|
|
91
|
+
label,
|
|
92
|
+
h('span', { style: { fontSize: '9px', opacity: 0.6 } }, open ? '▲' : '▼')
|
|
93
|
+
),
|
|
94
|
+
open ? h('div', { className: 'krot-popover', style: { minWidth: '220px' } },
|
|
95
|
+
h('div', { className: 'krot-pop-title' }, t('headerPoolsTitle')),
|
|
96
|
+
!poolEntries.length ? h('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-tertiary)' } }, t('noActivePools')) : null,
|
|
97
|
+
poolEntries.map(([name, p]) => {
|
|
98
|
+
const c = p.exhausted ? 'var(--dsw-alias-state-error-primary)' : (p.healthy < p.total ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-state-success-primary)');
|
|
99
|
+
const provName = p.providerName || name;
|
|
100
|
+
const tail = p.lastUsedTail;
|
|
101
|
+
return h('div', { key: name, className: 'krot-pop-row', style: { display: 'flex', flexDirection: 'column', gap: '4px', padding: '6px 0', borderBottom: '1px solid var(--dsw-alias-border-subtle)' } },
|
|
102
|
+
h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
|
|
103
|
+
h('div', { className: 'krot-pop-name' },
|
|
104
|
+
h('span', { style: { width: '7px', height: '7px', borderRadius: '50%', background: c, flex: 'none' } }),
|
|
105
|
+
name,
|
|
106
|
+
tail ? h('span', { style: { marginLeft: '6px', fontSize: '11px', color: 'var(--dsw-alias-label-tertiary)', fontFeatureSettings: '"tnum"', fontFamily: 'monospace' } }, `...${tail}`) : null
|
|
107
|
+
),
|
|
108
|
+
h('div', { className: 'krot-pop-count', style: { color: c, fontWeight: 600 } }, `${p.healthy}/${p.total}`)
|
|
109
|
+
),
|
|
110
|
+
h('div', { style: { display: 'flex', gap: '4px', marginTop: '2px' } },
|
|
111
|
+
h('button', {
|
|
112
|
+
type: 'button',
|
|
113
|
+
disabled: busy,
|
|
114
|
+
onClick: (e) => { e.stopPropagation(); runAction('skip', provName, p.lastUsed); },
|
|
115
|
+
title: t('quickSkipTitle'),
|
|
116
|
+
style: { padding: '2px 6px', fontSize: '11px', borderRadius: '4px', border: '1px solid var(--dsw-alias-border-subtle)', background: 'transparent', cursor: 'pointer', color: 'inherit' }
|
|
117
|
+
}, `⏭️ ${t('quickSkip')}`),
|
|
118
|
+
h('button', {
|
|
119
|
+
type: 'button',
|
|
120
|
+
disabled: busy,
|
|
121
|
+
onClick: (e) => { e.stopPropagation(); runAction('reset', provName, p.lastUsed); },
|
|
122
|
+
title: t('quickResetTitle'),
|
|
123
|
+
style: { padding: '2px 6px', fontSize: '11px', borderRadius: '4px', border: '1px solid var(--dsw-alias-border-subtle)', background: 'transparent', cursor: 'pointer', color: 'inherit' }
|
|
124
|
+
}, `🔄 ${t('quickReset')}`),
|
|
125
|
+
h('button', {
|
|
126
|
+
type: 'button',
|
|
127
|
+
disabled: busy || !p.lastUsed,
|
|
128
|
+
onClick: (e) => { e.stopPropagation(); runAction('pause', provName, p.lastUsed); },
|
|
129
|
+
title: t('quickPauseTitle'),
|
|
130
|
+
style: { padding: '2px 6px', fontSize: '11px', borderRadius: '4px', border: '1px solid var(--dsw-alias-border-subtle)', background: 'transparent', cursor: 'pointer', color: 'inherit' }
|
|
131
|
+
}, `⏸️ ${t('quickPause')}`)
|
|
132
|
+
)
|
|
133
|
+
);
|
|
134
|
+
})
|
|
135
|
+
) : null
|
|
136
|
+
);
|
|
137
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
function apply(ctx) {
|
|
2
|
+
const settingsSource = createSettingsSource(ctx);
|
|
3
|
+
ctx.effect(() => ctx.locale.register(NS, { en, zh }), 'dsh-key-rotation: dictionaries');
|
|
4
|
+
// Header chip (#201): status dot in the session header utilities slot,
|
|
5
|
+
// same slot dsh-gitea / dsh-subscriptions use for their header widgets.
|
|
6
|
+
ctx.effect(() => {
|
|
7
|
+
if (!ctx.slots) return;
|
|
8
|
+
try {
|
|
9
|
+
ctx.slots.inject('conversation.session.header.utilities', () =>
|
|
10
|
+
ctx.slots.register(
|
|
11
|
+
{ name: 'conversation.session.header.utilities', id: 'dsh-key-rotation-header-chip', order: 6, locale: NS, inject: () => ({ ctx, settingsSource }) },
|
|
12
|
+
KeyRotationHeaderChip,
|
|
13
|
+
));
|
|
14
|
+
} catch { /* slot not available in this build */ }
|
|
15
|
+
}, 'dsh-key-rotation: header chip');
|
|
16
|
+
|
|
17
|
+
function useLocale() {
|
|
18
|
+
return useActiveLocale(ctx);
|
|
19
|
+
}
|
|
20
|
+
// Collapsible card in Settings -> Plugins -> Plugin settings
|
|
21
|
+
// (settings.plugin.item), matching Model Sync / Spendmeter / Vision Bridge.
|
|
22
|
+
// key MUST equal the settings namespace (NS), else the tab silently skips it.
|
|
23
|
+
|
|
24
|
+
function KeyRotationCard(props) {
|
|
25
|
+
const locale = useLocale();
|
|
26
|
+
const t = resolveT(props);
|
|
27
|
+
const page = !!(props && props.view === 'page');
|
|
28
|
+
const [open, setOpen] = React.useState(!!page);
|
|
29
|
+
// Row seat (plugins.row.config): the host page draws title/icon/crumb and the
|
|
30
|
+
// padding, so the summary is a one-liner and the page drops our card chrome.
|
|
31
|
+
if (props && props.view === 'summary') {
|
|
32
|
+
return h('span', { className: 'krot-card-description' }, t('subtitle'));
|
|
33
|
+
}
|
|
34
|
+
return h(page ? 'div' : 'div', { className: page ? 'krot-page' : 'krot-card' + (open ? ' krot-card-open' : '') },
|
|
35
|
+
h('button', { type: 'button', className: 'krot-card-header', style: page ? { display: 'none' } : undefined, 'aria-expanded': page ? true : open, onClick: () => setOpen((v) => !v) },
|
|
36
|
+
h('span', { className: 'krot-card-head-text' },
|
|
37
|
+
h('span', { className: 'krot-card-name' }, t('title')),
|
|
38
|
+
h('span', { className: 'krot-card-description' }, t('subtitle'))),
|
|
39
|
+
h('span', { className: 'krot-card-chevron' + (open ? ' krot-card-chevron-open' : ''), 'aria-hidden': 'true' },
|
|
40
|
+
h('svg', { width: 14, height: 14, viewBox: '0 0 14 14', fill: 'none', stroke: 'currentColor', strokeWidth: 1.5, style: { display: 'block' } },
|
|
41
|
+
h('path', { d: 'M3.5 5.25L7 8.75L10.5 5.25' })))),
|
|
42
|
+
(page || open) ? h('div', { className: 'krot-card-body' }, h(KeyRotationErrorBoundary, null, h(KeyRotationSection, { ...props, locale }))) : null);
|
|
43
|
+
}
|
|
44
|
+
// Register each available seat independently. A missing legacy seat must
|
|
45
|
+
// not prevent the current bundle or row seat from registering.
|
|
46
|
+
for (const [name, key] of [
|
|
47
|
+
['plugins.bundle.config', PKG],
|
|
48
|
+
['plugins.row.config', ROW_CONFIG_KEY],
|
|
49
|
+
['settings.plugin.item', NS],
|
|
50
|
+
]) {
|
|
51
|
+
try {
|
|
52
|
+
ctx.slots.inject(name, () => ctx.slots.register(
|
|
53
|
+
{ name, key, locale: NS, inject: () => ({ ctx, settingsSource }) }, KeyRotationCard,
|
|
54
|
+
));
|
|
55
|
+
} catch (error) {
|
|
56
|
+
bestEffort('slots.register.log', () => { console.error('[dsh-key-rotation] settings seat unavailable', name, error); });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { apply, inject: ['slots', 'locale'] };
|
|
62
|
+
if (typeof globalThis !== 'undefined') {
|
|
63
|
+
globalThis.__dshKeyRotationClientInternals = {
|
|
64
|
+
quotaMapOf, withQuotas, modelEntriesOf, isValidModelId, formatCountdown, validatePoolDraft,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return module.exports;
|
|
68
|
+
},
|
|
69
|
+
});
|
package/lib/error-taxonomy.js
CHANGED
|
@@ -1,13 +1,35 @@
|
|
|
1
|
-
|
|
2
|
-
// lib/error-taxonomy.js — complete failure classification (#267).
|
|
1
|
+
// lib/error-taxonomy.js — complete failure classification (#267, #547).
|
|
3
2
|
// action: 'switch' | 'surface' | 'cooldown'
|
|
4
3
|
|
|
4
|
+
import { SOFT_FAILURE_CODES } from './pool.js';
|
|
5
|
+
import {
|
|
6
|
+
classifyResponseError,
|
|
7
|
+
parseErrorBody,
|
|
8
|
+
retryAfterMs,
|
|
9
|
+
CONTEXT_WINDOW_EXCEEDED_CODE,
|
|
10
|
+
QUOTA_EXCEEDED_CODE,
|
|
11
|
+
ACCOUNT_QUOTA_EXCEEDED_CODE,
|
|
12
|
+
EMPTY_RESPONSE_CODE,
|
|
13
|
+
INVALID_CREDENTIAL_CODE,
|
|
14
|
+
} from './errors.js';
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
classifyResponseError,
|
|
18
|
+
parseErrorBody,
|
|
19
|
+
retryAfterMs,
|
|
20
|
+
CONTEXT_WINDOW_EXCEEDED_CODE,
|
|
21
|
+
QUOTA_EXCEEDED_CODE,
|
|
22
|
+
ACCOUNT_QUOTA_EXCEEDED_CODE,
|
|
23
|
+
EMPTY_RESPONSE_CODE,
|
|
24
|
+
INVALID_CREDENTIAL_CODE,
|
|
25
|
+
};
|
|
26
|
+
|
|
5
27
|
const SWITCH_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
6
28
|
const SURFACE_STATUS = new Set([400, 404, 422]);
|
|
7
29
|
const AUTH_STATUS = new Set([401, 403]);
|
|
8
30
|
|
|
9
31
|
const SWITCH_CODES = new Set([
|
|
10
|
-
'QUOTA', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT', 'EMPTY_RESPONSE',
|
|
32
|
+
'QUOTA', 'ACCOUNT_QUOTA', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT', 'EMPTY_RESPONSE',
|
|
11
33
|
'UNKNOWN_MODEL', 'AUTH', 'RESOURCE_EXHAUSTED', 'UNAVAILABLE', 'INTERNAL',
|
|
12
34
|
'DEADLINE_EXCEEDED', 'UNAUTHENTICATED', 'PERMISSION_DENIED', 'ABORTED',
|
|
13
35
|
]);
|
|
@@ -19,7 +41,7 @@ const SOCKET_SWITCH = new Set([
|
|
|
19
41
|
/**
|
|
20
42
|
* Classify a failure for rotation.
|
|
21
43
|
* @param {any} failureOrPayload
|
|
22
|
-
* @returns {{ action: 'switch'|'surface'|'cooldown', code: string, soft: boolean, reason: string }}
|
|
44
|
+
* @returns {{ action: 'switch'|'surface'|'cooldown', code: string, soft: boolean, reason: string, retryAfterMs?: number }}
|
|
23
45
|
*/
|
|
24
46
|
export function classifyFailure(failureOrPayload) {
|
|
25
47
|
if (!failureOrPayload) {
|
|
@@ -29,18 +51,46 @@ export function classifyFailure(failureOrPayload) {
|
|
|
29
51
|
const status = Number(failure.status ?? failure.statusCode ?? failure.httpStatus ?? 0);
|
|
30
52
|
const code = String(failure.code ?? failure.reason ?? failure.name ?? '').toUpperCase();
|
|
31
53
|
const message = String(failure.message ?? failureOrPayload.message ?? '');
|
|
54
|
+
const headers = failure.headers ?? failureOrPayload.headers ?? failure.response?.headers;
|
|
55
|
+
const retryMs = retryAfterMs(headers);
|
|
56
|
+
|
|
57
|
+
// Check structured body/error through classifyResponseError if available
|
|
58
|
+
if (status || failure.body || (code && code !== 'ERROR')) {
|
|
59
|
+
const typed = classifyResponseError(status, failure.body ?? message, headers);
|
|
60
|
+
if (typed.code === CONTEXT_WINDOW_EXCEEDED_CODE) {
|
|
61
|
+
return { action: 'surface', code: CONTEXT_WINDOW_EXCEEDED_CODE, soft: false, reason: 'context_window' };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
32
64
|
|
|
33
|
-
if (AUTH_STATUS.has(status) || code === 'AUTH' || code === 'UNAUTHENTICATED' || code === 'PERMISSION_DENIED') {
|
|
34
|
-
return {
|
|
65
|
+
if (AUTH_STATUS.has(status) || code === 'AUTH' || code === 'UNAUTHENTICATED' || code === 'PERMISSION_DENIED' || code === INVALID_CREDENTIAL_CODE) {
|
|
66
|
+
return {
|
|
67
|
+
action: 'switch',
|
|
68
|
+
code: status ? String(status) : (code || 'AUTH'),
|
|
69
|
+
soft: false,
|
|
70
|
+
reason: 'auth',
|
|
71
|
+
...(retryMs !== undefined ? { retryAfterMs: retryMs } : {}),
|
|
72
|
+
};
|
|
35
73
|
}
|
|
36
|
-
if (status === 429 || code === 'RATE_LIMIT' || code === 'RESOURCE_EXHAUSTED' || code === 'QUOTA') {
|
|
37
|
-
return {
|
|
74
|
+
if (status === 429 || code === 'RATE_LIMIT' || code === 'RESOURCE_EXHAUSTED' || code === 'QUOTA' || code === 'ACCOUNT_QUOTA' || code === QUOTA_EXCEEDED_CODE) {
|
|
75
|
+
return {
|
|
76
|
+
action: 'switch',
|
|
77
|
+
code: status ? '429' : (code || 'RATE_LIMIT'),
|
|
78
|
+
soft: false,
|
|
79
|
+
reason: 'quota',
|
|
80
|
+
...(retryMs !== undefined ? { retryAfterMs: retryMs } : {}),
|
|
81
|
+
};
|
|
38
82
|
}
|
|
39
83
|
const numCode = Number(code);
|
|
40
84
|
if (SWITCH_STATUS.has(status) || (numCode > 0 && SWITCH_STATUS.has(numCode)) || SOFT_FAILURE_CODES.has(code)) {
|
|
41
85
|
const effCode = status ? String(status) : (numCode > 0 ? String(numCode) : code);
|
|
42
86
|
const soft = SOFT_FAILURE_CODES.has(effCode);
|
|
43
|
-
return {
|
|
87
|
+
return {
|
|
88
|
+
action: 'switch',
|
|
89
|
+
code: effCode,
|
|
90
|
+
soft,
|
|
91
|
+
reason: 'http',
|
|
92
|
+
...(retryMs !== undefined ? { retryAfterMs: retryMs } : {}),
|
|
93
|
+
};
|
|
44
94
|
}
|
|
45
95
|
if (SURFACE_STATUS.has(status)) {
|
|
46
96
|
return { action: 'surface', code: String(status), soft: false, reason: 'client' };
|
|
@@ -63,4 +113,3 @@ export function classifyFailure(failureOrPayload) {
|
|
|
63
113
|
return { action: 'surface', code: code || 'UNKNOWN', soft: false, reason: 'unclassified' };
|
|
64
114
|
}
|
|
65
115
|
|
|
66
|
-
/** True when classification says switch to next key. */
|
package/lib/errors.js
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// lib/errors.js — Canonical error classification and taxonomy for DeepSeek Harness (#547).
|
|
2
|
+
// Unifies error shapes across OpenAI, Anthropic, Gemini, Ollama, Groq and OpenCode into standard LlmError.
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
HarnessError as DshHarnessError,
|
|
6
|
+
LlmError as DshLlmError,
|
|
7
|
+
CONTEXT_WINDOW_EXCEEDED_CODE,
|
|
8
|
+
QUOTA_EXCEEDED_CODE,
|
|
9
|
+
ACCOUNT_QUOTA_EXCEEDED_CODE,
|
|
10
|
+
EMPTY_RESPONSE_CODE,
|
|
11
|
+
INVALID_CREDENTIAL_CODE,
|
|
12
|
+
isContextWindowExceededError,
|
|
13
|
+
isQuotaExceededError,
|
|
14
|
+
} from '@deepseek-ai/dsh-llm';
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
CONTEXT_WINDOW_EXCEEDED_CODE,
|
|
18
|
+
QUOTA_EXCEEDED_CODE,
|
|
19
|
+
ACCOUNT_QUOTA_EXCEEDED_CODE,
|
|
20
|
+
EMPTY_RESPONSE_CODE,
|
|
21
|
+
INVALID_CREDENTIAL_CODE,
|
|
22
|
+
isContextWindowExceededError,
|
|
23
|
+
isQuotaExceededError,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Fallback HarnessError and LlmError if ever decoupled from runtime
|
|
27
|
+
export class HarnessError extends (DshHarnessError || Error) {
|
|
28
|
+
constructor(message, code, options) {
|
|
29
|
+
super(message, code, options);
|
|
30
|
+
this.code = code;
|
|
31
|
+
this.name = this.constructor.name;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class LlmError extends (DshLlmError || HarnessError) {
|
|
36
|
+
constructor(message, code, options) {
|
|
37
|
+
super(message, code, options);
|
|
38
|
+
this.status = options?.status;
|
|
39
|
+
this.providerRetryAfterMs = options?.providerRetryAfterMs;
|
|
40
|
+
this.code = code;
|
|
41
|
+
this.name = this.constructor.name;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Robust parser for structured error bodies (JSON, object, text).
|
|
47
|
+
* Handles OpenAI, Anthropic, Gemini, Groq, Ollama envelopes.
|
|
48
|
+
*
|
|
49
|
+
* @param {unknown} body
|
|
50
|
+
* @returns {{ message?: string, type?: string, code?: string, status?: number, raw?: unknown }}
|
|
51
|
+
*/
|
|
52
|
+
export function parseErrorBody(body) {
|
|
53
|
+
if (!body) return {};
|
|
54
|
+
if (typeof body === 'object') {
|
|
55
|
+
return extractErrorFields(body);
|
|
56
|
+
}
|
|
57
|
+
if (typeof body === 'string') {
|
|
58
|
+
const trimmed = body.trim();
|
|
59
|
+
if (!trimmed) return {};
|
|
60
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
61
|
+
try {
|
|
62
|
+
const parsed = JSON.parse(trimmed);
|
|
63
|
+
return extractErrorFields(parsed);
|
|
64
|
+
} catch {
|
|
65
|
+
// Not valid JSON, return as plain message
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { message: trimmed.slice(0, 1000) };
|
|
69
|
+
}
|
|
70
|
+
return { message: String(body).slice(0, 1000) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function extractErrorFields(obj) {
|
|
74
|
+
if (!obj || typeof obj !== 'object') return {};
|
|
75
|
+
const outer = obj;
|
|
76
|
+
const nested = outer.error;
|
|
77
|
+
|
|
78
|
+
let message = outer.message;
|
|
79
|
+
let type = outer.type;
|
|
80
|
+
let code = outer.code;
|
|
81
|
+
let status = outer.status;
|
|
82
|
+
|
|
83
|
+
if (typeof nested === 'string') {
|
|
84
|
+
message = nested;
|
|
85
|
+
} else if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
|
|
86
|
+
if (typeof nested.message === 'string') message = nested.message;
|
|
87
|
+
if (typeof nested.type === 'string') type = nested.type;
|
|
88
|
+
if (nested.code !== undefined && nested.code !== null) code = String(nested.code);
|
|
89
|
+
if (typeof nested.status === 'number' || typeof nested.status === 'string') {
|
|
90
|
+
const s = Number(nested.status);
|
|
91
|
+
if (Number.isFinite(s)) status = s;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Handle gRPC style status object: { code: 8, message: "Resource exhausted", details: [...] }
|
|
96
|
+
if (outer.details && Array.isArray(outer.details) && outer.details.length > 0) {
|
|
97
|
+
const firstDetail = outer.details[0];
|
|
98
|
+
if (firstDetail && typeof firstDetail === 'object') {
|
|
99
|
+
if (!message && typeof firstDetail.message === 'string') message = firstDetail.message;
|
|
100
|
+
if (!type && typeof firstDetail.reason === 'string') type = firstDetail.reason;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
...(message ? { message: String(message) } : {}),
|
|
106
|
+
...(type ? { type: String(type) } : {}),
|
|
107
|
+
...(code !== undefined && code !== null ? { code: String(code) } : {}),
|
|
108
|
+
...(typeof status === 'number' ? { status } : {}),
|
|
109
|
+
raw: obj,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Parse Retry-After value from Headers, headers object, or string into milliseconds.
|
|
115
|
+
*
|
|
116
|
+
* @param {Headers | Record<string, string | string[]> | string | null | undefined} headers
|
|
117
|
+
* @param {number} [now=Date.now()]
|
|
118
|
+
* @returns {number | undefined}
|
|
119
|
+
*/
|
|
120
|
+
export function retryAfterMs(headers, now = Date.now()) {
|
|
121
|
+
if (!headers) return undefined;
|
|
122
|
+
let raw = '';
|
|
123
|
+
if (typeof headers === 'string') {
|
|
124
|
+
const m = headers.match(/retry-after\s*[:=]\s*(.+)/i);
|
|
125
|
+
raw = m ? m[1].trim().split(/[\n\r;]/)[0].trim() : headers.trim();
|
|
126
|
+
} else if (typeof headers.get === 'function') {
|
|
127
|
+
raw = headers.get('retry-after') || headers.get('x-ratelimit-reset-after') || '';
|
|
128
|
+
} else if (typeof headers === 'object') {
|
|
129
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
130
|
+
if (k.toLowerCase() === 'retry-after' || k.toLowerCase() === 'x-ratelimit-reset-after') {
|
|
131
|
+
raw = Array.isArray(v) ? v[0] : String(v || '');
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
raw = String(raw).trim();
|
|
138
|
+
if (!raw) return undefined;
|
|
139
|
+
|
|
140
|
+
// 1. Seconds format: integer or float
|
|
141
|
+
const seconds = Number(raw);
|
|
142
|
+
if (Number.isFinite(seconds) && seconds >= 0 && seconds <= 86400 * 7) {
|
|
143
|
+
return Math.trunc(seconds * 1000);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 2. HTTP-Date format: RFC 1123 / RFC 850
|
|
147
|
+
const parsedTs = Date.parse(raw);
|
|
148
|
+
if (Number.isFinite(parsedTs)) {
|
|
149
|
+
const diff = parsedTs - now;
|
|
150
|
+
return diff > 0 && diff < 86400 * 7 * 1000 ? Math.trunc(diff) : 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Determine if text/detail indicates quota or balance exhaustion.
|
|
158
|
+
*
|
|
159
|
+
* @param {string} detail
|
|
160
|
+
* @returns {boolean}
|
|
161
|
+
*/
|
|
162
|
+
export function isQuotaDetail(detail) {
|
|
163
|
+
if (!detail) return false;
|
|
164
|
+
if (isQuotaExceededError(detail)) return true;
|
|
165
|
+
const d = detail.toLowerCase();
|
|
166
|
+
return (
|
|
167
|
+
d.includes('insufficient_quota') ||
|
|
168
|
+
d.includes('insufficient balance') ||
|
|
169
|
+
d.includes('insufficient credits') ||
|
|
170
|
+
d.includes('out of credits') ||
|
|
171
|
+
d.includes('out of budget') ||
|
|
172
|
+
d.includes('quota exceeded') ||
|
|
173
|
+
d.includes('usage limit exceeded') ||
|
|
174
|
+
d.includes('resource_exhausted') ||
|
|
175
|
+
d.includes('credit balance') ||
|
|
176
|
+
d.includes('monthly limit') ||
|
|
177
|
+
d.includes('weekly limit') ||
|
|
178
|
+
d.includes('payment required')
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Canonical classifier translating status, body and headers into typed LlmError (#547).
|
|
184
|
+
*
|
|
185
|
+
* @param {number} status HTTP status code
|
|
186
|
+
* @param {unknown} body Response body (string, object, buffer)
|
|
187
|
+
* @param {Headers | Record<string, unknown> | null | undefined} headers Response headers
|
|
188
|
+
* @param {{ now?: number }} [options]
|
|
189
|
+
* @returns {LlmError}
|
|
190
|
+
*/
|
|
191
|
+
export function classifyResponseError(status, body, headers, options = {}) {
|
|
192
|
+
const now = options.now ?? Date.now();
|
|
193
|
+
const parsed = parseErrorBody(body);
|
|
194
|
+
const type = parsed.type ?? '';
|
|
195
|
+
const message = parsed.message ?? (status ? `HTTP ${status}` : 'Unknown upstream error');
|
|
196
|
+
const code = (parsed.code ?? '').toUpperCase();
|
|
197
|
+
const detail = `${type} ${code} ${message}`.trim();
|
|
198
|
+
const retryMs = retryAfterMs(headers, now);
|
|
199
|
+
|
|
200
|
+
const validStatus = Number.isInteger(status) && status >= 100 && status <= 599 ? status : undefined;
|
|
201
|
+
const errorOptions = {
|
|
202
|
+
...(validStatus !== undefined ? { status: validStatus } : {}),
|
|
203
|
+
...(retryMs !== undefined ? { providerRetryAfterMs: retryMs } : {}),
|
|
204
|
+
...(parsed.raw ? { cause: parsed.raw } : {}),
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// 1. Context window exceeded
|
|
208
|
+
if (code === CONTEXT_WINDOW_EXCEEDED_CODE || isContextWindowExceededError(detail)) {
|
|
209
|
+
return new LlmError(message, CONTEXT_WINDOW_EXCEEDED_CODE, errorOptions);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 2. Authentication & Credentials (401, 403, invalid_api_key)
|
|
213
|
+
if (
|
|
214
|
+
status === 401 ||
|
|
215
|
+
status === 403 ||
|
|
216
|
+
code === 'INVALID_API_KEY' ||
|
|
217
|
+
code === 'UNAUTHENTICATED' ||
|
|
218
|
+
code === 'PERMISSION_DENIED' ||
|
|
219
|
+
type === 'authentication_error' ||
|
|
220
|
+
type === 'permission_denied'
|
|
221
|
+
) {
|
|
222
|
+
const isMalformed = /malformed|invalid|bad|incorrect/i.test(detail);
|
|
223
|
+
return new LlmError(
|
|
224
|
+
message,
|
|
225
|
+
isMalformed ? INVALID_CREDENTIAL_CODE : 'AUTH',
|
|
226
|
+
errorOptions
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// 3. Quota & Balance exhaustion (429 or 402 with quota phrasing)
|
|
231
|
+
if (
|
|
232
|
+
status === 402 ||
|
|
233
|
+
code === 'QUOTA' ||
|
|
234
|
+
code === 'RESOURCE_EXHAUSTED' ||
|
|
235
|
+
code === 'INSUFFICIENT_QUOTA' ||
|
|
236
|
+
isQuotaDetail(detail)
|
|
237
|
+
) {
|
|
238
|
+
const isAccount = /account|billing|subscription|replenish/i.test(detail);
|
|
239
|
+
return new LlmError(
|
|
240
|
+
message,
|
|
241
|
+
isAccount ? ACCOUNT_QUOTA_EXCEEDED_CODE : QUOTA_EXCEEDED_CODE,
|
|
242
|
+
errorOptions
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// 4. Rate Limiting (429 without quota exhaustion, concurrency limits)
|
|
247
|
+
if (
|
|
248
|
+
status === 429 ||
|
|
249
|
+
code === 'RATE_LIMIT' ||
|
|
250
|
+
code === 'RATE_LIMIT_EXCEEDED' ||
|
|
251
|
+
type === 'rate_limit_error' ||
|
|
252
|
+
/rate[\s_-]?limit|too many requests|concurrency[\s_-]+limit|rpm|tpm/i.test(detail)
|
|
253
|
+
) {
|
|
254
|
+
return new LlmError(message, 'RATE_LIMIT', errorOptions);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// 5. Unknown Model (explicit model_not_found or invalid_model)
|
|
258
|
+
if (
|
|
259
|
+
code === 'MODEL_NOT_FOUND' ||
|
|
260
|
+
code === 'UNKNOWN_MODEL' ||
|
|
261
|
+
type === 'invalid_model' ||
|
|
262
|
+
/model[_\s]+not[_\s]+found|unknown[_\s]+model/i.test(detail)
|
|
263
|
+
) {
|
|
264
|
+
return new LlmError(message, 'UNKNOWN_MODEL', errorOptions);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// 6. Timeouts (408, 504, DEADLINE_EXCEEDED)
|
|
268
|
+
if (
|
|
269
|
+
status === 408 ||
|
|
270
|
+
status === 504 ||
|
|
271
|
+
code === 'TIMEOUT' ||
|
|
272
|
+
code === 'DEADLINE_EXCEEDED' ||
|
|
273
|
+
/timeout|timed[\s_-]?out/i.test(detail)
|
|
274
|
+
) {
|
|
275
|
+
return new LlmError(message, 'TIMEOUT', errorOptions);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// 7. Server Errors (500, 502, 503)
|
|
279
|
+
if (
|
|
280
|
+
(status >= 500 && status <= 503) ||
|
|
281
|
+
code === 'SERVER' ||
|
|
282
|
+
code === 'INTERNAL' ||
|
|
283
|
+
code === 'UNAVAILABLE' ||
|
|
284
|
+
type === 'server_error' ||
|
|
285
|
+
type === 'api_error'
|
|
286
|
+
) {
|
|
287
|
+
return new LlmError(message, 'SERVER', errorOptions);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// 8. Empty response
|
|
291
|
+
if (code === EMPTY_RESPONSE_CODE || (!message && status === 200)) {
|
|
292
|
+
return new LlmError(message || 'Upstream returned empty completion', EMPTY_RESPONSE_CODE, errorOptions);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// 9. Default client error (400, 422) or generic provider error
|
|
296
|
+
if (status >= 400 && status < 500) {
|
|
297
|
+
return new LlmError(message, code || String(status), errorOptions);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return new LlmError(message, code || 'PROVIDER_ERROR', errorOptions);
|
|
301
|
+
}
|
|
302
|
+
|
package/lib/index.js
CHANGED
|
@@ -57,6 +57,12 @@ export const name = '@goodandready/dsh-key-rotation';
|
|
|
57
57
|
export const inject = ['llm', 'webServer', 'settings', 'credentials'];
|
|
58
58
|
export { keyTail, KEY_TAIL_CHARS, KEY_TAIL_PLACEHOLDER, isLoopbackAddress, isTrustedBridgeRequest, isDesktopShellRequest, DEFAULT_SWITCH_CODES, isSwitchableError, formatExhaustionMessage, getRuntime };
|
|
59
59
|
export { notifySwitch, notifyExhaustion, notifyEarlyWarning };
|
|
60
|
+
export { classifyResponseError } from './errors.js';
|
|
61
|
+
export { SessionRouter, defaultSessionRouter, unstickFailedKey } from './session-router.js';
|
|
62
|
+
export { isContentChunk } from './rotate.js';
|
|
63
|
+
export { ModelCatalog, defaultModelCatalog } from './model-catalog.js';
|
|
64
|
+
export { QuotaRunway, defaultQuotaRunway, estimateRunway } from './quota-runway.js';
|
|
65
|
+
export * as transports from './transports/index.js';
|
|
60
66
|
|
|
61
67
|
const NS = 'dsh-key-rotation';
|
|
62
68
|
|