@goodandready/dsh-key-rotation 0.7.20 → 0.7.21
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/lib/heal.js +35 -0
- package/lib/histogram.js +66 -0
- package/lib/index.js +88 -3
- package/lib/sandbox.js +117 -0
- package/package.json +2 -2
package/lib/heal.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// heal.js — self-healing idle cooldowns.
|
|
2
|
+
// ponytail: pure function, easy to test, no side effects beyond mutation of passed-in state.
|
|
3
|
+
|
|
4
|
+
// Returns array of { ref, poolBase } entries that were healed in this tick.
|
|
5
|
+
// Mutates `pools` (removes from failedUntil, pushes heal event into events).
|
|
6
|
+
// `now` parameter is injectable for tests.
|
|
7
|
+
export function healIdleCooldowns(pools, idleMs, now = Date.now()) {
|
|
8
|
+
if (!Array.isArray(pools) || pools.length === 0) return [];
|
|
9
|
+
if (!Number.isFinite(idleMs) || idleMs <= 0) return [];
|
|
10
|
+
const healed = [];
|
|
11
|
+
for (const pool of pools) {
|
|
12
|
+
if (!pool || !pool.state || !pool.base) continue;
|
|
13
|
+
const fu = pool.state.failedUntil;
|
|
14
|
+
const lu = pool.state.lastUsed;
|
|
15
|
+
if (!fu || fu.size === 0) continue;
|
|
16
|
+
const expiredRefs = [];
|
|
17
|
+
for (const [ref, until] of fu.entries()) {
|
|
18
|
+
if (!Number.isFinite(until)) continue;
|
|
19
|
+
if (until > now) continue; // cooldown still active
|
|
20
|
+
const last = lu ? lu.get(ref) : undefined;
|
|
21
|
+
if (!Number.isFinite(last)) continue; // never used → no signal, skip
|
|
22
|
+
if (now - last < idleMs) continue; // used recently → don't heal
|
|
23
|
+
expiredRefs.push(ref);
|
|
24
|
+
}
|
|
25
|
+
for (const ref of expiredRefs) {
|
|
26
|
+
fu.delete(ref);
|
|
27
|
+
if (Array.isArray(pool.state.events)) {
|
|
28
|
+
pool.state.events.push({ at: now, ref, reason: 'self-heal', cooldownMs: 0, type: 'heal' });
|
|
29
|
+
if (pool.state.events.length > 50) pool.state.events.shift();
|
|
30
|
+
}
|
|
31
|
+
healed.push({ ref, poolBase: pool.base });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return healed;
|
|
35
|
+
}
|
package/lib/histogram.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// histogram.js — per-ref latency ring buffer + percentile.
|
|
2
|
+
// ponytail: ring buffer of fixed size, sort-on-read for percentile, no libraries.
|
|
3
|
+
|
|
4
|
+
export const LATENCY_DEFAULT_WINDOW = 200;
|
|
5
|
+
|
|
6
|
+
export class LatencyHistogram {
|
|
7
|
+
constructor({ window = LATENCY_DEFAULT_WINDOW } = {}) {
|
|
8
|
+
const w = Number.isFinite(window) && window > 0 ? Math.floor(window) : LATENCY_DEFAULT_WINDOW;
|
|
9
|
+
this._window = w;
|
|
10
|
+
this._buffers = new Map(); // ref -> Float64Array of size w, plus index/count
|
|
11
|
+
this._lastAt = new Map(); // ref -> epochMs of last sample
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
record(ref, ms) {
|
|
15
|
+
if (!ref || !Number.isFinite(ms) || ms < 0) return;
|
|
16
|
+
let entry = this._buffers.get(ref);
|
|
17
|
+
if (!entry) {
|
|
18
|
+
entry = { buf: new Float64Array(this._window), head: 0, count: 0 };
|
|
19
|
+
this._buffers.set(ref, entry);
|
|
20
|
+
}
|
|
21
|
+
entry.buf[entry.head] = ms;
|
|
22
|
+
entry.head = (entry.head + 1) % this._window;
|
|
23
|
+
if (entry.count < this._window) entry.count += 1;
|
|
24
|
+
this._lastAt.set(ref, Date.now());
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Returns p50/p95/p99 in milliseconds, plus count and lastAt. Sorted copy.
|
|
28
|
+
snapshot(ref) {
|
|
29
|
+
const entry = this._buffers.get(ref);
|
|
30
|
+
const lastAt = this._lastAt.get(ref);
|
|
31
|
+
if (!entry || entry.count === 0) {
|
|
32
|
+
return { count: 0, lastAt: lastAt || null };
|
|
33
|
+
}
|
|
34
|
+
const arr = entry.buf.subarray(0, entry.count);
|
|
35
|
+
const sorted = Array.from(arr).sort((a, b) => a - b);
|
|
36
|
+
const n = sorted.length;
|
|
37
|
+
return {
|
|
38
|
+
count: n,
|
|
39
|
+
lastAt: lastAt || null,
|
|
40
|
+
p50: sorted[Math.min(n - 1, Math.floor((n - 1) * 0.5))],
|
|
41
|
+
p95: sorted[Math.min(n - 1, Math.floor((n - 1) * 0.95))],
|
|
42
|
+
p99: sorted[Math.min(n - 1, Math.floor((n - 1) * 0.99))],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Returns { [ref]: snapshot }
|
|
47
|
+
snapshotAll() {
|
|
48
|
+
const out = {};
|
|
49
|
+
for (const ref of this._buffers.keys()) out[ref] = this.snapshot(ref);
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
clear(ref) {
|
|
54
|
+
if (ref) {
|
|
55
|
+
this._buffers.delete(ref);
|
|
56
|
+
this._lastAt.delete(ref);
|
|
57
|
+
} else {
|
|
58
|
+
this._buffers.clear();
|
|
59
|
+
this._lastAt.clear();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
get size() {
|
|
64
|
+
return this._buffers.size;
|
|
65
|
+
}
|
|
66
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -49,6 +49,10 @@ const RESET_PATH = '/dsh-key-rotation/reset';
|
|
|
49
49
|
const IMPORT_PATH = '/dsh-key-rotation/import';
|
|
50
50
|
const HEALTH_PATH = '/dsh-key-rotation/health';
|
|
51
51
|
const TEST_PATH = '/dsh-key-rotation/test';
|
|
52
|
+
const SANDBOX_CACHE_PATH = '/dsh-key-rotation/sandbox-cache';
|
|
53
|
+
import { LastTestCache, SandboxRunner } from './sandbox.js';
|
|
54
|
+
import { healIdleCooldowns } from './heal.js';
|
|
55
|
+
import { LatencyHistogram } from './histogram.js';
|
|
52
56
|
|
|
53
57
|
/** The llm-pi-ai namespace whose provider profiles map providers to pools. */
|
|
54
58
|
const PIAI_NS = 'llm-pi-ai';
|
|
@@ -70,7 +74,40 @@ function pushEvent(pool, ref, reason, cooldownMs, type) {
|
|
|
70
74
|
// treat pre-content failures whose message matches these patterns as
|
|
71
75
|
// switchable even when the code is not in `switchCodes`.
|
|
72
76
|
|
|
73
|
-
//
|
|
77
|
+
// Sandbox-test infrastructure (sandbox.js): in-memory cache + runner.
|
|
78
|
+
let lastTestCacheRunnerCtx = null;
|
|
79
|
+
const lastTestCache = new LastTestCache();
|
|
80
|
+
const latencyHistogram = new LatencyHistogram();
|
|
81
|
+
let sandboxRunner = null;
|
|
82
|
+
function ensureSandboxRunner(ctx) {
|
|
83
|
+
if (sandboxRunner) return sandboxRunner;
|
|
84
|
+
// provider id -> baseUrl (stripped of trailing /) for fetch /models probe
|
|
85
|
+
function resolveBaseUrl(provider) {
|
|
86
|
+
try {
|
|
87
|
+
const ns = ctx.get(PIAI_NS);
|
|
88
|
+
const list = ns && (ns.providers || (ns.config && ns.config.providers) || []);
|
|
89
|
+
if (!Array.isArray(list)) return null;
|
|
90
|
+
// ponytail: match by id OR name OR alias; pick first hit
|
|
91
|
+
const hit = list.find((p) => p && (p.id === provider || p.name === provider || (Array.isArray(p.aliases) && p.aliases.includes(provider))));
|
|
92
|
+
const base = hit && (hit.baseUrl || hit.endpoint || hit.url);
|
|
93
|
+
return base ? String(base) : null;
|
|
94
|
+
} catch (e) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
sandboxRunner = new SandboxRunner({ fetchImpl: globalThis.fetch, resolveBaseUrl });
|
|
99
|
+
return sandboxRunner;
|
|
100
|
+
}
|
|
101
|
+
async function probeRef(ref, key) {
|
|
102
|
+
// ref may be like "PROVIDER/KEY_NAME" — for sandbox we only care about the credential ref
|
|
103
|
+
// (the resolveBaseUrl uses the full provider id; ref can carry any string)
|
|
104
|
+
const runner = ensureSandboxRunner(lastTestCacheRunnerCtx);
|
|
105
|
+
const result = await runner.probeModels(ref, key);
|
|
106
|
+
lastTestCache.set(ref, { ...result, at: Date.now() });
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// // Bootstrap key pools. The user configures them in the Settings GUI or via
|
|
74
111
|
// the dsh profile bundle config; the plugin itself ships no provider defaults
|
|
75
112
|
// so it does not bind to any specific installation. Empty array means: until
|
|
76
113
|
// the user adds a pool, no rotation happens, and every provider falls back to
|
|
@@ -87,6 +124,10 @@ export const Config = Schema.object({
|
|
|
87
124
|
backupIntervalMs: Schema.number().default(86400000),
|
|
88
125
|
backupKeep: Schema.number().default(7),
|
|
89
126
|
rotationScheduleDays: Schema.number().default(0),
|
|
127
|
+
selfHealCooldown: Schema.boolean().default(true),
|
|
128
|
+
selfHealIdleMs: Schema.number().default(3600000),
|
|
129
|
+
latencyEnabled: Schema.boolean().default(true),
|
|
130
|
+
latencyWindow: Schema.number().default(200),
|
|
90
131
|
rateLimitThreshold: Schema.number().default(0.1),
|
|
91
132
|
providers: Schema.array(Schema.object({
|
|
92
133
|
provider: Schema.string().required(),
|
|
@@ -255,6 +296,30 @@ export function apply(ctx, config = {}) {
|
|
|
255
296
|
// profile does not need a second copy of that package.)
|
|
256
297
|
let getConfig = () => config;
|
|
257
298
|
registerConfigBridge(ctx, () => buildRuntime().cloneIds);
|
|
299
|
+
lastTestCacheRunnerCtx = ctx;
|
|
300
|
+
// Cache should not survive profile restarts (apply is called per reload).
|
|
301
|
+
// We deliberately do NOT clear on every apply — that would wipe badges when
|
|
302
|
+
// the user is just typing in the settings card. Re-init only on true reload.
|
|
303
|
+
ensureSandboxRunner(ctx);
|
|
304
|
+
|
|
305
|
+
// Self-healing idle cooldowns: every 60s, lift expired cooldowns for keys
|
|
306
|
+
// that have been idle for selfHealIdleMs (default 1h). ponytail: small
|
|
307
|
+
// interval, low cost; skipped when selfHealCooldown is disabled in config.
|
|
308
|
+
// ponytail: keep handle on the same ctx via closure so buildRuntime() reads
|
|
309
|
+
// fresh config on every tick. Naive but correct: 60s cadence is cheap.
|
|
310
|
+
const selfHealTimer = setInterval(() => {
|
|
311
|
+
const cfg = getConfig();
|
|
312
|
+
if (!cfg || cfg.selfHealCooldown === false) return;
|
|
313
|
+
try {
|
|
314
|
+
const idle = Number.isFinite(cfg.selfHealIdleMs) && cfg.selfHealIdleMs > 0 ? cfg.selfHealIdleMs : 3600000;
|
|
315
|
+
const providers = Array.isArray(cfg.providers) ? cfg.providers : [];
|
|
316
|
+
const pools = providers
|
|
317
|
+
.map((p) => buildRuntime().providerToPool.get(p.provider))
|
|
318
|
+
.filter(Boolean);
|
|
319
|
+
healIdleCooldowns(pools, idle);
|
|
320
|
+
} catch (_) { /* ponytail: never crash the timer */ }
|
|
321
|
+
}, 60000);
|
|
322
|
+
if (typeof selfHealTimer.unref === 'function') selfHealTimer.unref();
|
|
258
323
|
|
|
259
324
|
// Dashboard widget now lives in client.js (mountDashboard, see issue #152).
|
|
260
325
|
const DASH_HTML = '';
|
|
@@ -928,7 +993,7 @@ export function apply(ctx, config = {}) {
|
|
|
928
993
|
if (exhausted) exhaustedAny = true;
|
|
929
994
|
pools[pool.base] = { healthy, total, exhausted, healthScore: computeHealthScore(pool.state) };
|
|
930
995
|
}
|
|
931
|
-
json(res, 200, { status: exhaustedAny ? 'degraded' : 'ok', pools, exhaustedAny });
|
|
996
|
+
json(res, 200, { status: exhaustedAny ? 'degraded' : 'ok', pools, exhaustedAny, latency: latencyHistogram.snapshotAll() });
|
|
932
997
|
},
|
|
933
998
|
}), 'dsh-key-rotation: health');
|
|
934
999
|
|
|
@@ -944,11 +1009,11 @@ export function apply(ctx, config = {}) {
|
|
|
944
1009
|
if (!isValidRef(ref)) { json(res, 400, { error: { code: 'bad-ref', message: 'dsh-key-rotation: ref must be an environment variable name' } }); return; }
|
|
945
1010
|
// Optional value for pre-save validation (issue #118)
|
|
946
1011
|
const testValue = typeof body?.value === 'string' && body.value.length > 0 ? body.value : undefined;
|
|
1012
|
+
const probe = body?.probe === 'models' || body?.probe === 'chat' ? body.probe : undefined;
|
|
947
1013
|
const base = ctx.get('credentials');
|
|
948
1014
|
try {
|
|
949
1015
|
let hit = await (base?.__dshKeyRotationOriginalResolve ?? base?.resolve)?.call(base, ref);
|
|
950
1016
|
let present = Boolean(hit && typeof hit.value === 'string' && hit.value.length > 0);
|
|
951
|
-
// Pre-save validation: check the provided value directly (issue #118)
|
|
952
1017
|
const effectiveValue = testValue || hit?.value;
|
|
953
1018
|
const valid = present ? Boolean(effectiveValue && typeof effectiveValue === 'string' && effectiveValue.length > 0) : Boolean(testValue);
|
|
954
1019
|
const tail = valid ? keyTail(effectiveValue) : '';
|
|
@@ -960,6 +1025,16 @@ export function apply(ctx, config = {}) {
|
|
|
960
1025
|
const ev = envValue(ref);
|
|
961
1026
|
if (ev !== undefined) { present = true; json(res, 200, { ok: true, ref, tail: keyTail(ev), source: 'env' }); return; }
|
|
962
1027
|
}
|
|
1028
|
+
// sandbox probe (models is free; chat is hook-only, see sandbox.js)
|
|
1029
|
+
if (probe) {
|
|
1030
|
+
const keyForProbe = effectiveValue;
|
|
1031
|
+
const runner = ensureSandboxRunner(ctx);
|
|
1032
|
+
const result = probe === 'chat' ? await runner.probeChat(ref, keyForProbe) : await runner.probeModels(ref, keyForProbe);
|
|
1033
|
+
const cached = { ...result, at: Date.now() };
|
|
1034
|
+
lastTestCache.set(ref, cached);
|
|
1035
|
+
json(res, 200, { ok: cached.ok, ref, tail, source, probe, code: cached.code, latencyMs: cached.latencyMs, modelsCount: cached.modelsCount });
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
963
1038
|
json(res, 200, { ok: true, ref, tail, source });
|
|
964
1039
|
} catch (e) {
|
|
965
1040
|
json(res, 200, { ok: false, ref, code: 'error', message: String(e?.message ?? e) });
|
|
@@ -970,6 +1045,16 @@ export function apply(ctx, config = {}) {
|
|
|
970
1045
|
// Intercept the llm/stream waterfall: rotate any request whose provider maps
|
|
971
1046
|
// to a configured key pool; pass everything else (and internal dispatches)
|
|
972
1047
|
// straight through.
|
|
1048
|
+
// Read-only cache snapshot for clients (badge polling).
|
|
1049
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1050
|
+
kind: 'exact',
|
|
1051
|
+
path: SANDBOX_CACHE_PATH,
|
|
1052
|
+
handler: (req, res) => {
|
|
1053
|
+
if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: cache is local-only' } }); return; }
|
|
1054
|
+
json(res, 200, lastTestCache.snapshot());
|
|
1055
|
+
},
|
|
1056
|
+
}), 'dsh-key-rotation: sandbox cache');
|
|
1057
|
+
|
|
973
1058
|
ctx.on('llm/stream', (options, next) => {
|
|
974
1059
|
if (options[MARKER]) return next();
|
|
975
1060
|
const { providerToPool, modelPoolByProvider } = buildRuntime();
|
package/lib/sandbox.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// sandbox.js — probe sandbox-test runner + last-test cache.
|
|
2
|
+
// Ponytail-mode (full): simplest correct path.
|
|
3
|
+
// YAGNI: chat completions is a hook (not-implemented).
|
|
4
|
+
// In-memory only; restart dsh-web = clear cache.
|
|
5
|
+
|
|
6
|
+
export const PROBE_MODELS_TIMEOUT_MS = 5000;
|
|
7
|
+
export const PROBE_RETRY_DELAY_MS = 1000;
|
|
8
|
+
export const LAST_TEST_MAX = 200;
|
|
9
|
+
|
|
10
|
+
export class LastTestCache {
|
|
11
|
+
constructor(max = LAST_TEST_MAX) {
|
|
12
|
+
this._max = max;
|
|
13
|
+
this._data = new Map();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
set(ref, result) {
|
|
17
|
+
if (!ref || !result) return;
|
|
18
|
+
if (this._data.has(ref)) this._data.delete(ref);
|
|
19
|
+
this._data.set(ref, result);
|
|
20
|
+
while (this._data.size > this._max) {
|
|
21
|
+
const first = this._data.keys().next().value;
|
|
22
|
+
if (first === undefined) break;
|
|
23
|
+
this._data.delete(first);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
get(ref) {
|
|
28
|
+
return this._data.get(ref);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
snapshot() {
|
|
32
|
+
const out = {};
|
|
33
|
+
for (const [k, v] of this._data) out[k] = v;
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
clear() {
|
|
38
|
+
this._data.clear();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
get size() {
|
|
42
|
+
return this._data.size;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function classifyStatus(status) {
|
|
47
|
+
if (status === 401 || status === 403) return 'auth';
|
|
48
|
+
if (status === 404) return 'not-found';
|
|
49
|
+
if (status === 429) return 'rate-limit';
|
|
50
|
+
if (status >= 500 && status < 600) return 'server';
|
|
51
|
+
return `http-${status}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class SandboxRunner {
|
|
55
|
+
constructor({ fetchImpl, resolveBaseUrl, log = () => {} } = {}) {
|
|
56
|
+
if (typeof fetchImpl !== 'function') throw new Error('sandbox: fetchImpl required');
|
|
57
|
+
if (typeof resolveBaseUrl !== 'function') throw new Error('sandbox: resolveBaseUrl required');
|
|
58
|
+
this._fetch = fetchImpl;
|
|
59
|
+
this._resolveBaseUrl = resolveBaseUrl;
|
|
60
|
+
this._log = log;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async probeModels(ref, key) {
|
|
64
|
+
if (!ref || typeof key !== 'string' || key.length === 0) {
|
|
65
|
+
return { ok: false, code: 'no-credential', latencyMs: 0 };
|
|
66
|
+
}
|
|
67
|
+
const baseUrl = await this._resolveBaseUrl(ref);
|
|
68
|
+
if (!baseUrl) {
|
|
69
|
+
return { ok: false, code: 'no-baseurl', latencyMs: 0 };
|
|
70
|
+
}
|
|
71
|
+
const url = `${baseUrl.replace(/\/+$/, '')}/models`;
|
|
72
|
+
const started = Date.now();
|
|
73
|
+
const ctrl = new AbortController();
|
|
74
|
+
const timer = setTimeout(() => ctrl.abort(), PROBE_MODELS_TIMEOUT_MS);
|
|
75
|
+
const doFetch = () => this._fetch(url, {
|
|
76
|
+
method: 'GET',
|
|
77
|
+
headers: { authorization: `Bearer ${key}`, accept: 'application/json' },
|
|
78
|
+
signal: ctrl.signal,
|
|
79
|
+
});
|
|
80
|
+
try {
|
|
81
|
+
let res;
|
|
82
|
+
try {
|
|
83
|
+
res = await doFetch();
|
|
84
|
+
} catch (e) {
|
|
85
|
+
if (e && e.name === 'AbortError') return { ok: false, code: 'timeout', latencyMs: Date.now() - started };
|
|
86
|
+
return { ok: false, code: 'network', latencyMs: Date.now() - started };
|
|
87
|
+
}
|
|
88
|
+
// ponytail: 1 retry on 5xx — naive; classifier refines if needed
|
|
89
|
+
if (res.status >= 500 && res.status < 600) {
|
|
90
|
+
await new Promise((r) => setTimeout(r, PROBE_RETRY_DELAY_MS));
|
|
91
|
+
if (ctrl.signal.aborted) return { ok: false, code: 'timeout', latencyMs: Date.now() - started };
|
|
92
|
+
try {
|
|
93
|
+
res = await doFetch();
|
|
94
|
+
} catch (e) {
|
|
95
|
+
if (e && e.name === 'AbortError') return { ok: false, code: 'timeout', latencyMs: Date.now() - started };
|
|
96
|
+
return { ok: false, code: 'network', latencyMs: Date.now() - started };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const status = res.status;
|
|
100
|
+
if (status >= 200 && status < 300) {
|
|
101
|
+
let modelsCount = 0;
|
|
102
|
+
try {
|
|
103
|
+
const body = await res.json();
|
|
104
|
+
modelsCount = Array.isArray(body && body.data) ? body.data.length : 0;
|
|
105
|
+
} catch (_) { /* not json */ }
|
|
106
|
+
return { ok: true, code: 'ok', latencyMs: Date.now() - started, modelsCount };
|
|
107
|
+
}
|
|
108
|
+
return { ok: false, code: classifyStatus(status), latencyMs: Date.now() - started };
|
|
109
|
+
} finally {
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async probeChat(_ref, _key) {
|
|
115
|
+
return { ok: false, code: 'not-implemented', latencyMs: 0 };
|
|
116
|
+
}
|
|
117
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-key-rotation",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.21",
|
|
4
4
|
"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.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
@@ -53,6 +53,6 @@
|
|
|
53
53
|
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6"
|
|
54
54
|
},
|
|
55
55
|
"scripts": {
|
|
56
|
-
"test": "node --test test/*.test.js"
|
|
56
|
+
"test": "node --test test/*.test.js test/*.test.mjs"
|
|
57
57
|
}
|
|
58
58
|
}
|