@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.
@@ -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
+ });
@@ -1,13 +1,35 @@
1
- import { SOFT_FAILURE_CODES } from './pool.js';
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 { action: 'switch', code: status ? String(status) : (code || 'AUTH'), soft: false, reason: 'auth' };
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 { action: 'switch', code: status ? '429' : (code || 'RATE_LIMIT'), soft: false, reason: 'quota' };
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 { action: 'switch', code: effCode, soft, reason: 'http' };
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