@goodandready/dsh-key-rotation 0.8.16 → 0.8.18
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 +16 -0
- package/lib/client.js +61 -5
- package/lib/index.js +4 -8
- package/lib/ops-keys.js +5 -2
- package/lib/pool.js +3 -1
- package/lib/probe-endpoint.js +99 -0
- package/lib/quota-window.js +4 -3
- package/lib/rotate.js +27 -0
- package/lib/sandbox-service.js +7 -30
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,22 @@
|
|
|
3
3
|
All notable changes to `@goodandready/dsh-key-rotation` are documented here.
|
|
4
4
|
User-facing feature notes also appear in README (en is source of truth).
|
|
5
5
|
|
|
6
|
+
## 0.8.18 - 2026-09-21
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
- **Stream success failCount reset (#336)**: `rotate()` now invokes `recordSuccess(pool, activeRef, now())` upon successful stream completion and clean EOF, clearing accumulated `failCounts`, removing stale `failedUntil`, and updating `lastSuccessAt`.
|
|
10
|
+
- **Calendar-based quota reset connection (#337)**: connected `quota-window.js` (`nextQuotaReset`) to `penalizeRef` in `lib/rotate.js` when `QUOTA` / `RESOURCE_EXHAUSTED` / quota errors occur and `quotaResetWindow` is configured. Ensures exhausted keys remain penalized until calendar reset window instead of rapidly failing over back into penalty. Enhanced `nextQuotaReset` to safely handle both object and string window configurations.
|
|
11
|
+
- **Penalty decay during maintenance sweeps (#338)**: `sweepExpired` in `lib/pool.js` now invokes `decayPenalties({ state: st }, now)`, allowing keys with historic transient failures that have run stably to gradually recover their base backoff levels. Also cleans up expired `quotaWindows`. Removed redundant duplicate check in `isSwitchableError`.
|
|
12
|
+
- **State persistence during failover (#339)**: `schedulePersist()` is now passed into `createRotate()` and invoked whenever a key failover occurs, ensuring key penalty and switch state is promptly written to disk across restarts.
|
|
13
|
+
- **Dead imports cleanup (#340)**: removed 16 unused named imports from `lib/index.js` (`pickCascadeFallback`, `nextQuotaReset`, `usageRows`, `usageCsv`, `findSecrets`, `readJson`, `writeSection`, etc.) ensuring lean bundle evaluation.
|
|
14
|
+
- **Remote configuration import timeout (#341)**: added 10-second timeout via `AbortSignal.timeout(10000)` to remote `fetch(url)` in `/dsh-key-rotation/import` route (`lib/ops-keys.js`), returning HTTP 504 Gateway Timeout with structured error code `'timeout'` on network stalls.
|
|
15
|
+
|
|
16
|
+
## 0.8.17 - 2026-09-21
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- **Provider catalog decoupling in settings UI (#333, GitHub #11 / PR #12)**: `useProviderCatalog` in `lib/client.js` now loads the active provider catalog (`/dsh-key-rotation/config`) independently of `settingsScope` readiness, using `AbortController` and `window.focus` refresh. Eliminates false-negative `(not registered)` badge on existing routes when the settings snapshot was already ready on initial render. Fallback configuration loading is protected against overwriting newer settings values.
|
|
20
|
+
- **Modern provider probe endpoint discovery (#333, GitHub #11 / PR #12)**: extracted dedicated resolver `resolveProbeBaseUrl(ctx, provider)` in `lib/probe-endpoint.js`. Inspects `llm.listConfigurableProviders()` and resolves connection URLs from `settingsNs` + `settingsPath` in effective settings (`settings.describe()`). Supports `baseURL`, `baseUrl`, `apiBase`, `endpoint`, and `url`, eliminating false `no-baseurl` errors when probing SenseNova and modern dictionary-configured pi-ai / standalone adapters. Implements safe path traversal and fail-closed validation.
|
|
21
|
+
|
|
6
22
|
## 0.8.16 - 2026-09-20
|
|
7
23
|
|
|
8
24
|
### Fixed
|
package/lib/client.js
CHANGED
|
@@ -72,6 +72,7 @@ window.__ModuleLoader__.load({
|
|
|
72
72
|
cardDesc: 'Per-provider API key rotation: pools of keys, automatic failover on quota/rate-limit errors, cooldown and recovery.',
|
|
73
73
|
loading: 'Loading…',
|
|
74
74
|
notRegistered: 'not registered',
|
|
75
|
+
providerCatalogUnavailable: 'Provider list unavailable',
|
|
75
76
|
removeKey: 'Remove key',
|
|
76
77
|
removeProvider: 'Remove provider',
|
|
77
78
|
addKey: '+ Add key',
|
|
@@ -211,6 +212,7 @@ window.__ModuleLoader__.load({
|
|
|
211
212
|
cardDesc: '按提供商 API 密钥轮换:密钥池、配额/限流错误自动故障转移、冷却与恢复。',
|
|
212
213
|
loading: '加载中…',
|
|
213
214
|
notRegistered: '未注册',
|
|
215
|
+
providerCatalogUnavailable: '提供商列表暂不可用',
|
|
214
216
|
removeKey: '移除密钥',
|
|
215
217
|
removeProvider: '移除提供商',
|
|
216
218
|
addKey: '+ 添加密钥',
|
|
@@ -642,6 +644,47 @@ window.__ModuleLoader__.load({
|
|
|
642
644
|
);
|
|
643
645
|
}
|
|
644
646
|
|
|
647
|
+
// Provider registration is not part of settingsScope's value. Fetch it
|
|
648
|
+
// independently, and never turn an unavailable catalog into proof of absence.
|
|
649
|
+
function useProviderCatalog() {
|
|
650
|
+
const [catalog, setCatalog] = React.useState({ status: 'loading', providers: [] });
|
|
651
|
+
const requestRef = React.useRef(null);
|
|
652
|
+
const reload = React.useCallback(() => {
|
|
653
|
+
requestRef.current?.abort();
|
|
654
|
+
const controller = new AbortController();
|
|
655
|
+
requestRef.current = controller;
|
|
656
|
+
setCatalog((s) => ({ ...s, status: 'loading' }));
|
|
657
|
+
return fetch(CONFIG_PATH, { headers: { accept: 'application/json' }, signal: controller.signal })
|
|
658
|
+
.then(async (response) => {
|
|
659
|
+
if (!response.ok) throw new Error('provider catalog unavailable');
|
|
660
|
+
const data = await response.json();
|
|
661
|
+
if (!Array.isArray(data?.providers) || !data.providers.every((p) =>
|
|
662
|
+
p && typeof p.id === 'string' && p.id.length > 0)) {
|
|
663
|
+
throw new Error('invalid provider catalog');
|
|
664
|
+
}
|
|
665
|
+
if (controller.signal.aborted || requestRef.current !== controller) return;
|
|
666
|
+
setCatalog({ status: 'ready', providers: data.providers.map((p) => ({
|
|
667
|
+
id: p.id, name: typeof p.name === 'string' ? p.name : p.id,
|
|
668
|
+
})) });
|
|
669
|
+
})
|
|
670
|
+
.catch(() => {
|
|
671
|
+
if (!controller.signal.aborted && requestRef.current === controller) {
|
|
672
|
+
setCatalog((s) => ({ ...s, status: 'error' }));
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
}, []);
|
|
676
|
+
React.useEffect(() => {
|
|
677
|
+
reload();
|
|
678
|
+
// A provider may have been installed/enabled while this page was open.
|
|
679
|
+
window.addEventListener?.('focus', reload);
|
|
680
|
+
return () => {
|
|
681
|
+
requestRef.current?.abort();
|
|
682
|
+
window.removeEventListener?.('focus', reload);
|
|
683
|
+
};
|
|
684
|
+
}, [reload]);
|
|
685
|
+
return { ...catalog, reload };
|
|
686
|
+
}
|
|
687
|
+
|
|
645
688
|
function KeyRotationSection(props) {
|
|
646
689
|
const t = resolveT(props);
|
|
647
690
|
const settingsScope = React.useMemo(() => {
|
|
@@ -681,6 +724,9 @@ window.__ModuleLoader__.load({
|
|
|
681
724
|
getScopeSnapshot
|
|
682
725
|
);
|
|
683
726
|
const [state, setState] = React.useState({ status: 'loading', value: null, revision: 0, error: '', providers: [] });
|
|
727
|
+
const catalog = useProviderCatalog();
|
|
728
|
+
const latestScopeSnapshot = React.useRef(scopeSnapshot);
|
|
729
|
+
latestScopeSnapshot.current = scopeSnapshot;
|
|
684
730
|
// #290: surface settingsScope availability as card state
|
|
685
731
|
React.useEffect(() => {
|
|
686
732
|
if (!settingsScope) {
|
|
@@ -807,10 +853,15 @@ window.__ModuleLoader__.load({
|
|
|
807
853
|
};
|
|
808
854
|
|
|
809
855
|
const load = React.useCallback(() => {
|
|
856
|
+
const scopeIsReady = () => latestScopeSnapshot.current?.status === 'ready'
|
|
857
|
+
&& latestScopeSnapshot.current.value;
|
|
810
858
|
setState((s) => ({ ...s, status: 'loading', error: '' }));
|
|
811
859
|
fetch(CONFIG_PATH, { headers: { accept: 'application/json' } })
|
|
812
860
|
.then((r) => r.json())
|
|
813
861
|
.then((data) => {
|
|
862
|
+
// The scope may have become ready while this fallback was in flight.
|
|
863
|
+
// A bridge snapshot must not replace that newer value or its revision.
|
|
864
|
+
if (scopeIsReady()) return;
|
|
814
865
|
setState({
|
|
815
866
|
status: 'ready',
|
|
816
867
|
value: data.value ?? null,
|
|
@@ -818,9 +869,10 @@ window.__ModuleLoader__.load({
|
|
|
818
869
|
providers: Array.isArray(data.providers) ? data.providers : [],
|
|
819
870
|
error: data.error ? data.error.message : '',
|
|
820
871
|
});
|
|
821
|
-
setDraft(null);
|
|
822
872
|
})
|
|
823
|
-
.catch((e) =>
|
|
873
|
+
.catch((e) => {
|
|
874
|
+
if (!scopeIsReady()) setState((s) => ({ ...s, status: 'error', error: String(e) }));
|
|
875
|
+
});
|
|
824
876
|
}, []);
|
|
825
877
|
|
|
826
878
|
React.useEffect(() => {
|
|
@@ -962,7 +1014,7 @@ window.__ModuleLoader__.load({
|
|
|
962
1014
|
);
|
|
963
1015
|
}
|
|
964
1016
|
|
|
965
|
-
const providers =
|
|
1017
|
+
const providers = catalog.providers;
|
|
966
1018
|
const providerById = new Map(providers.map((p) => [p.id, p.name]));
|
|
967
1019
|
|
|
968
1020
|
const setField = (fn) => setDraft(fn(val));
|
|
@@ -1131,7 +1183,8 @@ window.__ModuleLoader__.load({
|
|
|
1131
1183
|
const providerRows = providerList.map((entry, pIndex) => {
|
|
1132
1184
|
const options = [];
|
|
1133
1185
|
if (entry.provider && !providerById.has(entry.provider)) {
|
|
1134
|
-
options.push(h('option', { key: entry.provider, value: entry.provider }, entry.provider + ' (' +
|
|
1186
|
+
options.push(h('option', { key: entry.provider, value: entry.provider }, entry.provider + ' (' + (catalog.status === 'ready' ? t('notRegistered')
|
|
1187
|
+
: catalog.status === 'loading' ? t('loading') : t('providerCatalogUnavailable')) + ')'));
|
|
1135
1188
|
}
|
|
1136
1189
|
options.push(...providers.map((prov) =>
|
|
1137
1190
|
h('option', { key: prov.id, value: prov.id }, prov.name + (prov.id !== prov.name ? ' — ' + prov.id : ''))));
|
|
@@ -1466,7 +1519,10 @@ window.__ModuleLoader__.load({
|
|
|
1466
1519
|
);
|
|
1467
1520
|
});
|
|
1468
1521
|
|
|
1469
|
-
const noProviders =
|
|
1522
|
+
const noProviders = catalog.status === 'error'
|
|
1523
|
+
? h('div', { className: 'krot-hint', role: 'status' }, t('providerCatalogUnavailable'),
|
|
1524
|
+
btn(t('retry'), catalog.reload))
|
|
1525
|
+
: catalog.status === 'ready' && providers.length === 0
|
|
1470
1526
|
? h('div', { className: 'krot-empty', role: 'status', style: { display: 'flex', flexDirection: 'column', gap: '6px', padding: '16px 14px', border: '1px dashed var(--dsw-alias-border-l2)', borderRadius: 10, background: 'var(--dsw-alias-bg-layer-2)' } },
|
|
1471
1527
|
h('p', { style: { margin: 0, fontWeight: 600, fontSize: 14, color: 'var(--dsw-alias-label-primary)' } }, t('emptyTitle')),
|
|
1472
1528
|
h('p', { className: 'krot-hint', style: { margin: 0 } }, t('emptyDesc')),
|
package/lib/index.js
CHANGED
|
@@ -15,22 +15,17 @@ import {
|
|
|
15
15
|
budgetVerdict, sortAttemptList,
|
|
16
16
|
} from './pool.js';
|
|
17
17
|
import { LatencyHistogram } from './histogram.js';
|
|
18
|
-
import { pickCascadeFallback } from './cascade.js';
|
|
19
18
|
import { ConcurrencyTracker } from './concurrency.js';
|
|
20
|
-
import { nextQuotaReset } from './quota-window.js';
|
|
21
19
|
import { QuotaStore } from './quota.js';
|
|
22
20
|
import { WebhookSender } from './webhook.js';
|
|
23
|
-
import { bucketAllow, bucketRetryMs
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import { json, readJson, descriptorOf, viewOf, writeSection, providerCatalog, handleConfigBridge, NS as BRIDGE_NS } from './http-bridge.js';
|
|
21
|
+
import { bucketAllow, bucketRetryMs } from './bucket.js';
|
|
22
|
+
import { compactUsage } from './usage-report.js';
|
|
23
|
+
import { json, handleConfigBridge } from './http-bridge.js';
|
|
27
24
|
import { createRotate } from './rotate.js';
|
|
28
25
|
import { nowMono } from './clock.js';
|
|
29
26
|
import { CircuitBreaker } from './circuit-breaker.js';
|
|
30
27
|
import { NotifyQueue } from './notify-queue.js';
|
|
31
|
-
import { BoundedMap } from './bounded-map.js';
|
|
32
28
|
import { classifyFailure } from './error-taxonomy.js';
|
|
33
|
-
import { safeParseJson } from './atomic-io.js';
|
|
34
29
|
import { registerOpsRoutes } from './routes-ops.js';
|
|
35
30
|
import { registerPluginUpdater } from './plugin-updater.js';
|
|
36
31
|
import { notifySwitch, notifyExhaustion, pushEvent } from './notify-events.js';
|
|
@@ -472,6 +467,7 @@ export function apply(ctx, config = {}) {
|
|
|
472
467
|
ctx,
|
|
473
468
|
dispatchStorage,
|
|
474
469
|
buildRuntime,
|
|
470
|
+
schedulePersist,
|
|
475
471
|
pushEvent,
|
|
476
472
|
notifySwitch: (runtime, pool, info) => notifySwitch(runtime, pool, info, { webhookSender, notifyQueue: moduleNotifyQueue, now: () => Date.now() }),
|
|
477
473
|
notifyExhaustion,
|
package/lib/ops-keys.js
CHANGED
|
@@ -167,7 +167,7 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
167
167
|
const url = typeof body?.url === 'string' ? body.url.trim() : '';
|
|
168
168
|
if (!url || !url.startsWith('https://')) { json(res, 400, { error: { code: 'bad-url', message: 'dsh-key-rotation: only HTTPS URLs are allowed' } }); return; }
|
|
169
169
|
try {
|
|
170
|
-
const resp = await fetch(url);
|
|
170
|
+
const resp = await fetch(url, { signal: AbortSignal.timeout(10000) });
|
|
171
171
|
if (!resp.ok) { json(res, 400, { error: { code: 'fetch-failed', message: 'dsh-key-rotation: fetch returned ' + resp.status } }); return; }
|
|
172
172
|
const data = await resp.json();
|
|
173
173
|
if (!Array.isArray(data)) { json(res, 400, { error: { code: 'bad-format', message: 'dsh-key-rotation: expected JSON array of providers' } }); return; }
|
|
@@ -181,7 +181,10 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
181
181
|
const mergedArr = [...merged.values()];
|
|
182
182
|
await settings.replace(NS, { ...(desc?.value ?? {}), providers: mergedArr }, desc?.revision);
|
|
183
183
|
json(res, 200, { ok: true, providersImported: data.length, total: mergedArr.length });
|
|
184
|
-
} catch (e) {
|
|
184
|
+
} catch (e) {
|
|
185
|
+
const isTimeout = e?.name === 'TimeoutError' || e?.name === 'AbortError';
|
|
186
|
+
json(res, isTimeout ? 504 : 400, { error: { code: isTimeout ? 'timeout' : 'import-failed', message: String(e?.message ?? e) } });
|
|
187
|
+
}
|
|
185
188
|
},
|
|
186
189
|
}), 'dsh-key-rotation: import route');
|
|
187
190
|
|
package/lib/pool.js
CHANGED
|
@@ -185,10 +185,13 @@ export function sweepExpired(poolState, now = Date.now(), activeRefs = null) {
|
|
|
185
185
|
let cleared = 0;
|
|
186
186
|
const activeSet = activeRefs ? new Set(activeRefs) : null;
|
|
187
187
|
for (const st of poolState.values()) {
|
|
188
|
+
// #338: decay penalty failCounts for stable keys in maintenance sweep
|
|
189
|
+
decayPenalties({ state: st }, now);
|
|
188
190
|
for (const [ref, until] of [...st.failedUntil.entries()]) {
|
|
189
191
|
if (until <= now) {
|
|
190
192
|
st.failedUntil.delete(ref);
|
|
191
193
|
st.failCounts?.delete(ref);
|
|
194
|
+
st.quotaWindows?.delete(ref);
|
|
192
195
|
cleared++;
|
|
193
196
|
}
|
|
194
197
|
}
|
|
@@ -372,7 +375,6 @@ export function isSwitchableError(failureOrPayload, switchCodes = new Set(DEFAUL
|
|
|
372
375
|
|
|
373
376
|
// 1. Direct HTTP status codes (#267: also 408/425 transient)
|
|
374
377
|
if ((status === 429 || status === 408 || status === 425) && (switchCodes.has('RATE_LIMIT') || switchCodes.has('QUOTA') || switchCodes.has('429') || switchCodes.has('TIMEOUT') || switchCodes.has(String(status)))) return true;
|
|
375
|
-
if (status === 429 && (switchCodes.has('RATE_LIMIT') || switchCodes.has('QUOTA') || switchCodes.has('429'))) return true;
|
|
376
378
|
if ((status === 401 || status === 403) && (switchCodes.has('AUTH') || switchCodes.has('401'))) return true;
|
|
377
379
|
if ((status >= 500 && status <= 504) && (switchCodes.has('SERVER') || switchCodes.has(String(status)))) return true;
|
|
378
380
|
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Resolve only the selected provider's connection; never infer a vendor URL.
|
|
2
|
+
import { bestEffort } from './best-effort.js';
|
|
3
|
+
|
|
4
|
+
const own = (object, key) => object != null && typeof object === 'object'
|
|
5
|
+
&& Object.prototype.hasOwnProperty.call(object, key);
|
|
6
|
+
|
|
7
|
+
function endpointOf(profile) {
|
|
8
|
+
for (const field of ['baseURL', 'baseUrl', 'apiBase', 'endpoint', 'url']) {
|
|
9
|
+
if (!own(profile, field) || profile[field] == null || profile[field] === '') continue;
|
|
10
|
+
const value = profile[field];
|
|
11
|
+
if (typeof value !== 'string' || !value.trim()) return null;
|
|
12
|
+
try {
|
|
13
|
+
const url = new URL(value.trim());
|
|
14
|
+
// /models is appended by the runner. Query/fragment/userinfo bases do
|
|
15
|
+
// not compose safely; HTTP loopback remains valid for local providers.
|
|
16
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password
|
|
17
|
+
|| url.search || url.hash) return null;
|
|
18
|
+
return value.trim();
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function atPath(value, path) {
|
|
27
|
+
if (!Array.isArray(path)) return undefined;
|
|
28
|
+
for (const segment of path) {
|
|
29
|
+
if (typeof segment !== 'string' || !segment
|
|
30
|
+
|| ['__proto__', 'constructor', 'prototype'].includes(segment) || !own(value, segment)) return undefined;
|
|
31
|
+
value = value[segment];
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function legacyProfile(providers, provider) {
|
|
37
|
+
if (Array.isArray(providers)) {
|
|
38
|
+
// A declared alias is routing metadata; a display name is not an identity.
|
|
39
|
+
const matches = providers.filter((p) => p && (p.id === provider || p.provider === provider
|
|
40
|
+
|| (Array.isArray(p.aliases) && p.aliases.includes(provider))));
|
|
41
|
+
return matches.length === 1 ? matches[0] : undefined;
|
|
42
|
+
}
|
|
43
|
+
return own(providers, provider) ? providers[provider] : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Resolve current effective settings on every probe (including after edits).
|
|
47
|
+
* LlmProviderInfo is display-only. Modern hosts declare the exact settings
|
|
48
|
+
* address via listConfigurableProviders(); no adapter/model call is necessary.
|
|
49
|
+
*/
|
|
50
|
+
export async function resolveProbeBaseUrl(ctx, provider) {
|
|
51
|
+
if (typeof provider !== 'string' || !provider) return null;
|
|
52
|
+
const llm = await bestEffort('probe.llm', () => ctx?.get?.('llm') ?? ctx?.llm);
|
|
53
|
+
const readSections = async () => {
|
|
54
|
+
const settings = await bestEffort('probe.settings', () => ctx?.get?.('settings'));
|
|
55
|
+
const sections = await bestEffort('probe.settings.describe', () => settings?.describe?.({ redactSecrets: true }));
|
|
56
|
+
return Array.isArray(sections) ? sections : [];
|
|
57
|
+
};
|
|
58
|
+
if (typeof llm?.listConfigurableProviders === 'function') {
|
|
59
|
+
const directory = await bestEffort('probe.directory', () => llm.listConfigurableProviders());
|
|
60
|
+
// Do not fall through to a stale endpoint if the authoritative directory
|
|
61
|
+
// is unavailable or ambiguous. No exception/connection detail is logged.
|
|
62
|
+
if (!Array.isArray(directory)) return null;
|
|
63
|
+
const matches = directory.filter((entry) => entry?.provider === provider);
|
|
64
|
+
if (matches.length > 1) return null;
|
|
65
|
+
if (matches.length === 1) {
|
|
66
|
+
const entry = matches[0];
|
|
67
|
+
if (typeof entry.settingsNs !== 'string' || !entry.settingsNs) return null;
|
|
68
|
+
const sections = (await readSections()).filter((section) => section?.ns === entry.settingsNs);
|
|
69
|
+
if (sections.length !== 1) return null;
|
|
70
|
+
return endpointOf(atPath(sections[0].value, entry.settingsPath));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Older integrations may still expose endpoints directly. Only exact route
|
|
75
|
+
// identities are accepted; never match a provider by its human-readable name.
|
|
76
|
+
const info = await bestEffort('probe.provider', () => llm?.getProvider?.(provider));
|
|
77
|
+
if (info && (info.id == null || info.id === provider)) {
|
|
78
|
+
const url = endpointOf(info);
|
|
79
|
+
if (url) return url;
|
|
80
|
+
}
|
|
81
|
+
const providers = await bestEffort('probe.providers', () => llm?.listProviders?.());
|
|
82
|
+
const match = Array.isArray(providers) ? providers.filter((p) => p?.id === provider) : [];
|
|
83
|
+
if (match.length > 1) return null;
|
|
84
|
+
if (match.length === 1) {
|
|
85
|
+
const url = endpointOf(match[0]);
|
|
86
|
+
if (url) return url;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Compatibility for hosts predating the configurable-provider directory.
|
|
90
|
+
// llm-pi-ai is a known settings namespace, not necessarily a Cordis service.
|
|
91
|
+
const sections = (await readSections()).filter((section) => section?.ns === 'llm-pi-ai');
|
|
92
|
+
if (sections.length > 1) return null;
|
|
93
|
+
if (sections.length === 1) {
|
|
94
|
+
const profile = legacyProfile(sections[0].value?.providers, provider);
|
|
95
|
+
if (profile !== undefined) return endpointOf(profile);
|
|
96
|
+
}
|
|
97
|
+
const legacy = await bestEffort('probe.legacy', () => ctx?.get?.('llm-pi-ai'));
|
|
98
|
+
return endpointOf(legacyProfile(legacy?.providers ?? legacy?.config?.providers, provider));
|
|
99
|
+
}
|
package/lib/quota-window.js
CHANGED
|
@@ -4,9 +4,10 @@ export const QUOTA_WINDOW_TYPES = ['midnight_utc', 'midnight_pst', 'rolling_24h'
|
|
|
4
4
|
|
|
5
5
|
export function nextQuotaReset(quotaResetWindow, now) {
|
|
6
6
|
now = now || Date.now();
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const
|
|
7
|
+
const win = typeof quotaResetWindow === 'string' ? { type: quotaResetWindow === 'daily' ? 'midnight_utc' : quotaResetWindow } : quotaResetWindow;
|
|
8
|
+
if (!win || typeof win !== 'object') return null;
|
|
9
|
+
const type = win.type === 'daily' ? 'midnight_utc' : win.type;
|
|
10
|
+
const hour = Number.isFinite(win.hour) ? win.hour : 0;
|
|
10
11
|
|
|
11
12
|
if (type === 'rolling_24h') {
|
|
12
13
|
const d = new Date(now);
|
package/lib/rotate.js
CHANGED
|
@@ -5,6 +5,7 @@ import { classifyFailure } from './error-taxonomy.js';
|
|
|
5
5
|
import {
|
|
6
6
|
isSwitchableError,
|
|
7
7
|
recordFailure,
|
|
8
|
+
recordSuccess,
|
|
8
9
|
parseRetryAfter,
|
|
9
10
|
extractRateLimit,
|
|
10
11
|
isRateLimited,
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
sortAttemptList,
|
|
13
14
|
} from './pool.js';
|
|
14
15
|
import { pickCascadeFallback } from './cascade.js';
|
|
16
|
+
import { nextQuotaReset } from './quota-window.js';
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
19
|
* Create the rotate(options, pool) async generator used by llm/stream.
|
|
@@ -34,6 +36,7 @@ export function createRotate(deps) {
|
|
|
34
36
|
quotaStore,
|
|
35
37
|
circuitBreaker,
|
|
36
38
|
logger,
|
|
39
|
+
schedulePersist,
|
|
37
40
|
now = nowMono,
|
|
38
41
|
} = deps;
|
|
39
42
|
|
|
@@ -77,8 +80,24 @@ export function createRotate(deps) {
|
|
|
77
80
|
const _effBase = _retry !== undefined ? Math.max(_base, Math.min(_retry, _max ?? _base * 8)) : _base;
|
|
78
81
|
const cls = classifyFailure({ code: errCode, message: errMsg });
|
|
79
82
|
const _b = recordFailure(pool, targetRef, now(), _effBase, _max, cls.soft, true);
|
|
83
|
+
|
|
84
|
+
// #337: connect quota-window calendar reset for quota exhaustion
|
|
85
|
+
const isQuota = errCode === 'QUOTA' || cls.code === 'QUOTA' || /quota|exceeded/i.test(errMsg);
|
|
86
|
+
const qWindow = pool.quotaResetWindow ?? runtime0.quotaResetWindow;
|
|
87
|
+
if (isQuota && qWindow) {
|
|
88
|
+
const quotaResetAt = nextQuotaReset(qWindow, now());
|
|
89
|
+
if (quotaResetAt && quotaResetAt > now()) {
|
|
90
|
+
if (!pool.state.quotaWindows) pool.state.quotaWindows = new Map();
|
|
91
|
+
pool.state.quotaWindows.set(targetRef, quotaResetAt);
|
|
92
|
+
pool.state.failedUntil.set(targetRef, quotaResetAt);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
80
96
|
if (circuitBreaker) circuitBreaker.onFailure(pool.base ?? options?.provider);
|
|
81
97
|
pushEvent(pool, targetRef, errCode ?? 'UNKNOWN', _b);
|
|
98
|
+
if (typeof schedulePersist === 'function') {
|
|
99
|
+
try { schedulePersist(); } catch (_) { /* best-effort persistence */ }
|
|
100
|
+
}
|
|
82
101
|
if (!pool.state.authFailCounts) pool.state.authFailCounts = new Map();
|
|
83
102
|
if (!pool.state.brokenUntil) pool.state.brokenUntil = new Map();
|
|
84
103
|
const _cStr = String(errCode ?? '');
|
|
@@ -190,6 +209,11 @@ export function createRotate(deps) {
|
|
|
190
209
|
if (!byRef) { byRef = new Map(); pool.state.byModel.set(activeRef, byRef); }
|
|
191
210
|
byRef.set(options.model, (byRef.get(options.model) ?? 0) + 1);
|
|
192
211
|
}
|
|
212
|
+
// #336: record success for the active key upon successful stream finish
|
|
213
|
+
if (activeRef) {
|
|
214
|
+
recordSuccess(pool, activeRef, now());
|
|
215
|
+
}
|
|
216
|
+
|
|
193
217
|
// Proactive rate-limit (#115, #303): if response headers say this key is near
|
|
194
218
|
// its quota or has retry-after, cool it down so the NEXT request starts on a different key.
|
|
195
219
|
// We do NOT re-run this (already successful) request — that would double-send.
|
|
@@ -248,6 +272,9 @@ export function createRotate(deps) {
|
|
|
248
272
|
}
|
|
249
273
|
|
|
250
274
|
if (switching) continue; // try the next key
|
|
275
|
+
if (_pickedRef && yielded) {
|
|
276
|
+
recordSuccess(pool, _pickedRef, now());
|
|
277
|
+
}
|
|
251
278
|
return; // clean end — served
|
|
252
279
|
} finally {
|
|
253
280
|
if (acquired && _pickedRef) {
|
package/lib/sandbox-service.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// lib/sandbox-service.js — sandbox runner and cache service
|
|
2
2
|
import { LastTestCache, SandboxRunner } from './sandbox.js';
|
|
3
|
+
import { bestEffort } from './best-effort.js';
|
|
4
|
+
import { resolveProbeBaseUrl } from './probe-endpoint.js';
|
|
3
5
|
|
|
4
6
|
export function createSandboxService({ getRuntime }) {
|
|
5
7
|
let sandboxRunner = null;
|
|
@@ -9,36 +11,11 @@ export function createSandboxService({ getRuntime }) {
|
|
|
9
11
|
function ensureSandboxRunner(ctx) {
|
|
10
12
|
if (ctx) defaultCtx = ctx;
|
|
11
13
|
if (sandboxRunner) return sandboxRunner;
|
|
12
|
-
function resolveBaseUrl(providerOrRef) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
if (pool?.base) provider = pool.base;
|
|
18
|
-
else if (pool?.provider) provider = pool.provider;
|
|
19
|
-
|
|
20
|
-
const c = ctx || defaultCtx;
|
|
21
|
-
const pInfo = c?.llm?.getProvider?.(provider);
|
|
22
|
-
if (pInfo && (pInfo.baseUrl || pInfo.endpoint || pInfo.url)) {
|
|
23
|
-
return String(pInfo.baseUrl || pInfo.endpoint || pInfo.url);
|
|
24
|
-
}
|
|
25
|
-
for (const info of (c?.llm?.listProviders?.() || [])) {
|
|
26
|
-
if (info && (info.id === provider || info.name === provider)) {
|
|
27
|
-
const u = info.baseUrl || info.endpoint || info.url;
|
|
28
|
-
if (u) return String(u);
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
const ns = c?.get ? c.get('llm-pi-ai') : null;
|
|
32
|
-
const list = ns && (ns.providers || (ns.config && ns.config.providers) || []);
|
|
33
|
-
if (Array.isArray(list)) {
|
|
34
|
-
const hit = list.find((p) => p && (p.id === provider || p.name === provider || (Array.isArray(p.aliases) && p.aliases.includes(provider))));
|
|
35
|
-
const base = hit && (hit.baseUrl || hit.endpoint || hit.url);
|
|
36
|
-
if (base) return String(base);
|
|
37
|
-
}
|
|
38
|
-
return null;
|
|
39
|
-
} catch (_) {
|
|
40
|
-
return null;
|
|
41
|
-
}
|
|
14
|
+
async function resolveBaseUrl(providerOrRef) {
|
|
15
|
+
const rt = await bestEffort('probe.runtime', () => typeof getRuntime === 'function' ? getRuntime() : null);
|
|
16
|
+
const pool = rt?.poolByRef?.get(providerOrRef);
|
|
17
|
+
const provider = pool?.base || pool?.provider || providerOrRef;
|
|
18
|
+
return resolveProbeBaseUrl(defaultCtx, provider);
|
|
42
19
|
}
|
|
43
20
|
sandboxRunner = new SandboxRunner({ fetchImpl: globalThis.fetch, resolveBaseUrl });
|
|
44
21
|
return sandboxRunner;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-key-rotation",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.18",
|
|
4
4
|
"packageManager": "pnpm@10.33.2",
|
|
5
5
|
"description": "Per-provider API key rotation for DeepSeek Harness: a key pool per provider, auto-created clone routes, and switching to the next key on quota/rate-limit errors. Includes a Settings section (Key Rotation) to edit the key pools, cooldown and switch codes.",
|
|
6
6
|
"keywords": [
|