@goodandready/dsh-key-rotation 0.8.11 → 0.8.13

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,113 @@
1
+ // lib/ops-telemetry.js — usage + snapshot operational routes (#312 split from routes-ops.js).
2
+ import {
3
+ json,
4
+ readJson,
5
+ descriptorOf,
6
+ NS,
7
+ } from './http-bridge.js';
8
+ import { isTrustedBridgeRequest } from './pool.js';
9
+ import {
10
+ usageRows,
11
+ usageCsv,
12
+ } from './usage-report.js';
13
+ import {
14
+ USAGE_PATH,
15
+ SNAPSHOT_PATH,
16
+ } from './ops-paths.js';
17
+ import { findSecrets } from './keycheck.js';
18
+
19
+ /**
20
+ * @param {object} ctx cordis context
21
+ * @param {object} deps live dependencies from apply()
22
+ */
23
+ export function registerTelemetryRoutes(ctx, deps) {
24
+ const {
25
+ buildRuntime,
26
+ } = deps;
27
+
28
+ ctx.effect(() => ctx.webServer.register({
29
+ kind: 'exact',
30
+ path: USAGE_PATH,
31
+ handler: (req, res) => {
32
+ if (req.method !== 'GET') { json(res, 405, { error: { code: 'method', message: 'GET only' } }); return; }
33
+ if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: usage is local-only' } }); return; }
34
+ const url = new URL(req.url ?? USAGE_PATH, 'http://localhost');
35
+ const days = Math.min(90, Math.max(1, Number(url.searchParams.get('days')) || 7));
36
+ const csv = url.searchParams.get('format') === 'csv';
37
+ const provider = url.searchParams.get('provider') ?? '';
38
+ const runtime = buildRuntime();
39
+ const now = Date.now();
40
+ const seen = new Set();
41
+ const report = [];
42
+ for (const pool of runtime.poolByRef.values()) {
43
+ if (seen.has(pool.base)) continue;
44
+ seen.add(pool.base);
45
+ if (provider && pool.base !== provider) continue;
46
+ report.push({ provider: pool.base, rows: usageRows(pool, days, now) });
47
+ }
48
+ if (csv) {
49
+ res.writeHead(200, { 'content-type': 'text/csv; charset=utf-8', 'content-disposition': 'attachment; filename="dsh-key-rotation-usage.csv"' });
50
+ const parts = [];
51
+ for (const p of report) {
52
+ if (parts.length > 0) parts.push('');
53
+ parts.push('# ' + p.provider);
54
+ parts.push(usageCsv(p.rows));
55
+ }
56
+ res.end(parts.join('\n') + '\n');
57
+ return;
58
+ }
59
+ json(res, 200, { at: now, days, providers: report });
60
+ },
61
+ }), 'dsh-key-rotation: usage route');
62
+
63
+ // #218: full config snapshot - one JSON file to move between machines.
64
+ // Secret values never travel: only credential/env names. Token fields are
65
+ // exported as empty strings; on import they keep existing values when empty.
66
+
67
+ ctx.effect(() => ctx.webServer.register({
68
+ kind: 'exact',
69
+ path: SNAPSHOT_PATH,
70
+ handler: async (req, res) => {
71
+ if (req.method !== 'GET' && req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'GET (export) or POST (import) only' } }); return; }
72
+ if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: snapshot is local-only' } }); return; }
73
+ if (req.method === 'GET') {
74
+ const descriptor = descriptorOf(ctx, NS);
75
+ const value = descriptor?.value ?? {};
76
+ const exportable = { ...value };
77
+ // token-shaped fields stay empty in the file; refs are names, not secrets
78
+ exportable.webhookActionToken = '';
79
+ json(res, 200, { at: Date.now(), version: 1, snapshot: exportable });
80
+ return;
81
+ }
82
+ // POST = import: { snapshot } -> merge with current section, PUT semantics
83
+ let body;
84
+ try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
85
+ const snap = body?.snapshot;
86
+ if (!snap || typeof snap !== 'object' || Array.isArray(snap)) { json(res, 400, { error: { code: 'bad-format', message: 'dsh-key-rotation: POST requires {"snapshot": {...}}' } }); return; }
87
+ // #200 leak guard applies to imported content too
88
+ try {
89
+ const masked = structuredClone(snap);
90
+ if (masked.webhookActionToken) masked.webhookActionToken = '***';
91
+ if (masked.notifyWebhook) masked.notifyWebhook = '***';
92
+ const findings = findSecrets(JSON.stringify(masked));
93
+ if (findings.length > 0) { json(res, 400, { error: { code: 'secret-in-snapshot', message: 'dsh-key-rotation: snapshot carries a live-looking credential', findings } }); return; }
94
+ } catch { /* scanning must never block a valid import */ }
95
+ const settings = ctx.get('settings');
96
+ if (!settings) { json(res, 503, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: no settings provider' } }); return; }
97
+ const desc = descriptorOf(ctx, NS);
98
+ if (desc === void 0) { json(res, 500, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: namespace missing' } }); return; }
99
+ const cur = desc.value ?? {};
100
+ // empty token fields in the file keep the current values (never wipe a secret)
101
+ const merged = { ...cur, ...snap };
102
+ if (!snap.webhookActionToken) merged.webhookActionToken = cur.webhookActionToken ?? '';
103
+ try {
104
+ await settings.replace(NS, merged, desc.revision);
105
+ const after = descriptorOf(ctx, NS);
106
+ json(res, 200, { ok: true, revision: after?.revision });
107
+ } catch (e) {
108
+ json(res, e?.code === 'SETTINGS_CONFLICT' ? 409 : 400, { error: { code: 'settings-rejected', message: String(e?.message ?? e) } });
109
+ }
110
+ },
111
+ }), 'dsh-key-rotation: snapshot route');
112
+
113
+ }
@@ -0,0 +1,106 @@
1
+ // lib/ops-test.js — sandbox test + sandbox cache routes (#312 split from routes-ops.js).
2
+ import {
3
+ json,
4
+ readJson,
5
+ } from './http-bridge.js';
6
+ import {
7
+ isTrustedBridgeRequest,
8
+ isValidRef,
9
+ keyTail,
10
+ envValue,
11
+ } from './pool.js';
12
+ import { bestEffort } from './best-effort.js';
13
+ import {
14
+ TEST_PATH,
15
+ SANDBOX_CACHE_PATH,
16
+ } from './ops-paths.js';
17
+
18
+ /**
19
+ * @param {object} ctx cordis context
20
+ * @param {object} deps live dependencies from apply()
21
+ */
22
+ export function registerTestRoutes(ctx, deps) {
23
+ const {
24
+ lastTestCache,
25
+ ensureSandboxRunner,
26
+ poolState,
27
+ } = deps;
28
+
29
+ // ── test route: dry-run a single key without rotation ──
30
+ ctx.effect(() => ctx.webServer.register({
31
+ kind: 'exact',
32
+ path: TEST_PATH,
33
+ handler: async (req, res) => {
34
+ if (req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'POST only' } }); return; }
35
+ if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: test is local-only' } }); return; }
36
+ let body; try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
37
+ const ref = typeof body?.ref === 'string' ? body.ref.trim() : '';
38
+ if (!isValidRef(ref)) { json(res, 400, { error: { code: 'bad-ref', message: 'dsh-key-rotation: ref must be an environment variable name' } }); return; }
39
+ // Optional value for pre-save validation (issue #118)
40
+ const testValue = typeof body?.value === 'string' && body.value.length > 0 ? body.value : undefined;
41
+ const probe = body?.probe === 'models' || body?.probe === 'chat' ? body.probe : undefined;
42
+ const base = ctx.get('credentials');
43
+ try {
44
+ let hit = await (base?.__dshKeyRotationOriginalResolve ?? base?.resolve)?.call(base, ref);
45
+ let present = Boolean(hit && typeof hit.value === 'string' && hit.value.length > 0);
46
+ const effectiveValue = testValue || hit?.value;
47
+ const valid = present ? Boolean(effectiveValue && typeof effectiveValue === 'string' && effectiveValue.length > 0) : Boolean(testValue);
48
+ const tail = valid ? keyTail(effectiveValue) : '';
49
+ let source = null;
50
+ await bestEffort('credentials.describe', async () => { const d = await base?.describe?.(ref); source = d?.source ?? null; }, ctx.logger);
51
+ if (!present && !testValue) { json(res, 200, { ok: false, ref, code: 'no-credential', message: 'no such credential' }); return; }
52
+ if (!present && testValue) { source = 'pre-save'; }
53
+ else if (!present) {
54
+ const ev = envValue(ref);
55
+ if (ev !== undefined) { present = true; json(res, 200, { ok: true, ref, tail: keyTail(ev), source: 'env' }); return; }
56
+ }
57
+ // sandbox probe (models is free; chat is hook-only, see sandbox.js)
58
+ if (probe) {
59
+ const keyForProbe = effectiveValue;
60
+ const runner = ensureSandboxRunner(ctx);
61
+ const result = probe === 'chat' ? await runner.probeChat(ref, keyForProbe) : await runner.probeModels(ref, keyForProbe);
62
+ const cached = { ...result, at: Date.now() };
63
+ lastTestCache.set(ref, cached);
64
+ if (cached.ok) {
65
+ for (const st of poolState.values()) {
66
+ if (st.failedUntil?.has(ref) || st.failCounts?.has(ref) || st.brokenUntil?.has(ref)) {
67
+ st.failedUntil?.delete(ref);
68
+ st.failCounts?.delete(ref);
69
+ st.authFailCounts?.delete(ref);
70
+ st.brokenUntil?.delete(ref);
71
+ }
72
+ }
73
+ }
74
+ json(res, 200, { ok: cached.ok, ref, tail, source, probe, code: cached.code, latencyMs: cached.latencyMs, modelsCount: cached.modelsCount });
75
+ return;
76
+ }
77
+ json(res, 200, { ok: true, ref, tail, source });
78
+ } catch (e) {
79
+ json(res, 200, { ok: false, ref, code: 'error', message: String(e?.message ?? e) });
80
+ }
81
+ },
82
+ }), 'dsh-key-rotation: test route');
83
+
84
+ // Intercept the llm/stream waterfall: rotate any request whose provider maps
85
+ // to a configured key pool; pass everything else (and internal dispatches)
86
+ // straight through.
87
+ // Read-only cache snapshot for clients (badge polling).
88
+
89
+ ctx.effect(() => ctx.webServer.register({
90
+ kind: 'exact',
91
+ path: SANDBOX_CACHE_PATH,
92
+ handler: (req, res) => {
93
+ if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: cache is local-only' } }); return; }
94
+ const cacheSnap = typeof lastTestCache?.snapshot === 'function'
95
+ ? lastTestCache.snapshot()
96
+ : (lastTestCache instanceof Map ? Object.fromEntries(lastTestCache) : (lastTestCache ?? {}));
97
+ json(res, 200, cacheSnap);
98
+ },
99
+ }), 'dsh-key-rotation: sandbox cache');
100
+
101
+
102
+
103
+ // #199 webhook-action: interactive webhook buttons call back here.
104
+ // Auth: bearer token from Config (external services like Telegram/Discord
105
+ // cannot be same-origin, so a shared secret is the gate).
106
+ }
@@ -0,0 +1,115 @@
1
+ // lib/ops-webhook.js — webhook interactive action route (#312 split from routes-ops.js).
2
+ import {
3
+ json,
4
+ readJson,
5
+ } from './http-bridge.js';
6
+ import { WEBHOOK_ACTION_PATH } from './ops-paths.js';
7
+
8
+ /**
9
+ * @param {object} ctx cordis context
10
+ * @param {object} deps live dependencies from apply()
11
+ */
12
+ export function registerWebhookActionRoute(ctx, deps) {
13
+ const {
14
+ buildRuntime,
15
+ poolState,
16
+ setRotationDisabled,
17
+ circuitBreaker,
18
+ } = deps;
19
+
20
+ ctx.effect(() => ctx.webServer.register({
21
+ kind: 'exact',
22
+ path: WEBHOOK_ACTION_PATH,
23
+ handler: async (req, res) => {
24
+ if (req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'POST only' } }); return; }
25
+ const runtime = buildRuntime();
26
+ const expected = runtime.webhookActionToken;
27
+ if (!expected) { json(res, 503, { error: { code: 'no-token', message: 'dsh-key-rotation: webhookActionToken is not configured' } }); return; }
28
+ const auth = String(req.headers.authorization ?? '');
29
+ if (auth !== `Bearer ${expected}`) { json(res, 401, { error: { code: 'unauthorized', message: 'dsh-key-rotation: bad webhook action token' } }); return; }
30
+ let body;
31
+ try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
32
+ // Accept callback payloads from formatInteractive (Telegram/Discord/Slack) or plain {action}
33
+ let action = typeof body?.action === 'string' ? body.action : '';
34
+ if (!action && typeof body?.data === 'string') {
35
+ try { action = String(JSON.parse(body.data)?.id ?? ''); } catch { action = ''; }
36
+ }
37
+ if (!action && typeof body?.callback_data === 'string') {
38
+ try { action = String(JSON.parse(body.callback_data)?.id ?? ''); } catch { action = ''; }
39
+ }
40
+ // #222: Telegram update envelope {update_id, callback_query:{data}}
41
+ if (!action && typeof body?.callback_query?.data === 'string') {
42
+ try { action = String(JSON.parse(body.callback_query.data)?.id ?? ''); } catch { action = ''; }
43
+ }
44
+ // #222: Telegram setWebhook registration helper
45
+ if (typeof body?.setWebhook === 'object' && body.setWebhook) {
46
+ const botToken = typeof body.setWebhook.botToken === 'string' ? body.setWebhook.botToken : '';
47
+ if (!botToken) { json(res, 400, { error: { code: 'bad-request', message: 'dsh-key-rotation: setWebhook.botToken required' } }); return; }
48
+ // derive the public URL from request headers; explicit URL wins
49
+ const url = typeof body.setWebhook.url === 'string' && body.setWebhook.url ? body.setWebhook.url : `https://${String(req.headers.host ?? '')}/dsh-key-rotation/webhook-action`;
50
+ try {
51
+ const hookRes = await fetch(`https://api.telegram.org/bot${botToken}/setWebhook`, {
52
+ method: 'POST', headers: { 'content-type': 'application/json' },
53
+ body: JSON.stringify({ url, allowed_updates: ['callback_query'] }),
54
+ });
55
+ const hookData = await hookRes.json().catch(() => ({}));
56
+ json(res, 200, { ok: hookRes.ok, url, telegram: hookData });
57
+ } catch (e) {
58
+ json(res, 502, { error: { code: 'telegram-failed', message: String(e?.message ?? e) } });
59
+ }
60
+ return;
61
+ }
62
+ if (!action) { json(res, 400, { error: { code: 'bad-action', message: 'dsh-key-rotation: no action in payload' } }); return; }
63
+ const provider = action.startsWith('pause-') || action.startsWith('reset-') ? action.replace(/^(pause|reset)-/, '') : '';
64
+ try {
65
+ if (action === 'disable-rotation') {
66
+ setRotationDisabled(true);
67
+ (ctx?.logger ? ctx.logger('dsh-key-rotation') : null)?.warn?.('[dsh-key-rotation] rotation DISABLED via webhook action');
68
+ json(res, 200, { ok: true, action });
69
+ return;
70
+ }
71
+ if (action === 'enable-rotation') {
72
+ setRotationDisabled(false);
73
+ json(res, 200, { ok: true, action });
74
+ return;
75
+ }
76
+ if (action.startsWith('pause-') || action.startsWith('reset-')) {
77
+ const st = poolState.get(provider);
78
+ if (!st) { json(res, 404, { error: { code: 'not-found', message: `dsh-key-rotation: no pool for '${provider}'` } }); return; }
79
+ if (action.startsWith('pause-')) {
80
+ const until = Date.now() + 3600000; // 1h pause
81
+ for (const ref of (st.failedUntil ? [...st.failedUntil.keys()] : [])) st.failedUntil.set(ref, Math.max(st.failedUntil.get(ref) ?? 0, until));
82
+ // also pause every key currently healthy
83
+ for (const p of buildRuntime().poolByRef.values()) {
84
+ if (p.base !== provider) continue;
85
+ for (const ref of p.refs) st.failedUntil.set(ref, Math.max(st.failedUntil.get(ref) ?? 0, until));
86
+ }
87
+ (ctx?.logger ? ctx.logger('dsh-key-rotation') : null)?.warn?.(`[dsh-key-rotation] pool ${provider} PAUSED 1h via webhook action`);
88
+ json(res, 200, { ok: true, action, provider, until: Date.now() + 3600000 });
89
+ return;
90
+ }
91
+ const cleared = st.failedUntil.size;
92
+ st.failedUntil.clear();
93
+ st.failCounts?.clear();
94
+ st.authFailCounts?.clear();
95
+ st.brokenUntil?.clear();
96
+ st.switches = 0;
97
+ st.lastReason = undefined;
98
+ st.lastSwitchAt = undefined;
99
+ let circuitReset = false;
100
+ const br = circuitBreaker ?? buildRuntime().breaker;
101
+ if (br) {
102
+ if (typeof br.reset === 'function') { br.reset(provider); circuitReset = true; }
103
+ else if (typeof br.onSuccess === 'function') { br.onSuccess(provider); circuitReset = true; }
104
+ }
105
+ (ctx?.logger ? ctx.logger('dsh-key-rotation') : null)?.warn?.(`[dsh-key-rotation] pool ${provider} RESET via webhook action (circuitReset=${circuitReset})`);
106
+ json(res, 200, { ok: true, action, provider, cleared, circuitReset });
107
+ return;
108
+ }
109
+ json(res, 400, { error: { code: 'unknown-action', message: `dsh-key-rotation: unknown action '${action}'` } });
110
+ } catch (e) {
111
+ json(res, 500, { error: { code: 'action-failed', message: String(e?.message ?? e) } });
112
+ }
113
+ },
114
+ }), 'dsh-key-rotation: webhook-action');
115
+ }
@@ -0,0 +1,86 @@
1
+ // lib/pool-builder.js — provider & per-model pool assembly and cleanup
2
+ import { bucketSweep } from './bucket.js';
3
+
4
+ export function parseExpiry(v) {
5
+ if (typeof v === 'number' && Number.isFinite(v) && v > 0) return v;
6
+ if (typeof v === 'string' && v.length > 0) {
7
+ const t = Date.parse(v);
8
+ if (!Number.isNaN(t)) return t;
9
+ }
10
+ return undefined;
11
+ }
12
+
13
+ export function buildPoolItem({
14
+ base,
15
+ keys,
16
+ weights,
17
+ poolCooldown,
18
+ poolMax,
19
+ expiresAt,
20
+ poolStrategy,
21
+ poolGuard,
22
+ rpmLimit,
23
+ makeState,
24
+ }) {
25
+ const refs = (keys ?? []).filter((ref) => typeof ref === 'string' && ref.length > 0);
26
+ if (refs.length === 0) return null;
27
+ const w = Array.isArray(weights) ? weights : [];
28
+ const weightedRefs = [];
29
+ for (let i = 0; i < refs.length; i++) {
30
+ const ww = typeof w[i] === 'number' && w[i] > 0 ? Math.floor(w[i]) : 1;
31
+ for (let k = 0; k < ww; k++) weightedRefs.push(refs[i]);
32
+ }
33
+ const parsedExpiry = {};
34
+ if (Array.isArray(expiresAt)) {
35
+ for (let i = 0; i < refs.length; i++) {
36
+ const exp = parseExpiry(expiresAt[i]);
37
+ if (exp !== undefined) parsedExpiry[refs[i]] = exp;
38
+ }
39
+ }
40
+ return {
41
+ base,
42
+ refs,
43
+ weights: refs.map((_, i) => (typeof w[i] === 'number' && w[i] > 0 ? Math.floor(w[i]) : 1)),
44
+ weightedRefs: weightedRefs.length > 0 ? weightedRefs : refs,
45
+ state: makeState(base),
46
+ cooldownMs: poolCooldown,
47
+ maxCooldownMs: poolMax,
48
+ expiresAt: parsedExpiry,
49
+ rpmLimit,
50
+ routingStrategy: poolStrategy,
51
+ proactiveRateLimitGuard: poolGuard,
52
+ };
53
+ }
54
+
55
+ export function cleanupRemovedProviders({
56
+ cfg,
57
+ poolState,
58
+ poolByRef,
59
+ providerToPool,
60
+ expectedClones,
61
+ moduleBreaker,
62
+ lowHealthNotifiedAt,
63
+ budgetNotifiedAt,
64
+ }) {
65
+ // auto-cleanup: remove poolState for providers that are now empty or removed
66
+ for (const key of [...poolState.keys()]) {
67
+ if (![...poolByRef.values()].some((p) => p.base === key)) {
68
+ poolState.delete(key);
69
+ lowHealthNotifiedAt?.delete?.(key);
70
+ budgetNotifiedAt?.delete?.(key + ':budget');
71
+ }
72
+ }
73
+ // #192: drop RPM windows for refs that no longer belong to any pool
74
+ for (const st of poolState.values()) {
75
+ if (st.rpmWindows) bucketSweep(st.rpmWindows, new Set(poolByRef.keys()));
76
+ }
77
+ // drop breaker entries for removed providers
78
+ if (moduleBreaker) {
79
+ for (const key of Object.keys(moduleBreaker.snapshot())) {
80
+ if (![...providerToPool.keys()].includes(key) && !expectedClones.has(key)) {
81
+ const still = (cfg.providers ?? []).some((p) => p.provider === key);
82
+ if (!still) moduleBreaker.reset(key);
83
+ }
84
+ }
85
+ }
86
+ }
package/lib/rotate.js CHANGED
@@ -33,9 +33,12 @@ export function createRotate(deps) {
33
33
  setRotateStartMs,
34
34
  quotaStore,
35
35
  circuitBreaker,
36
+ logger,
36
37
  now = nowMono,
37
38
  } = deps;
38
39
 
40
+ const logWarn = (msg) => (logger?.warn ? logger.warn(msg) : null);
41
+
39
42
  function rotate(options, pool) {
40
43
  return (async function* () {
41
44
  const runtime0 = buildRuntime();
@@ -93,7 +96,7 @@ export function createRotate(deps) {
93
96
 
94
97
  // #260: fail fast when provider circuit is open
95
98
  if (circuitBreaker && !circuitBreaker.canRequest(options.provider)) {
96
- console.warn(`[dsh-key-rotation] ${options.provider}: circuit open — skipping dispatch`);
99
+ logWarn(`[dsh-key-rotation] ${options.provider}: circuit open — skipping dispatch`);
97
100
  yield finishError('CIRCUIT_OPEN', `[dsh-key-rotation] provider '${options.provider}' circuit is open`);
98
101
  return;
99
102
  }
@@ -116,7 +119,7 @@ export function createRotate(deps) {
116
119
  penalizeRef(curRef, e?.code ?? 'TRANSPORT', String(e?.message ?? ''));
117
120
  lastFailure = finishError(e?.code ?? 'TRANSPORT',
118
121
  `dsh-key-rotation: dispatch failed: ${String(e?.message ?? e)}`);
119
- console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(curRef ?? '?')} threw ${String(e?.code ?? e?.message ?? e)}`);
122
+ logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(curRef ?? '?')} threw ${String(e?.code ?? e?.message ?? e)}`);
120
123
  continue;
121
124
  }
122
125
 
@@ -146,7 +149,7 @@ export function createRotate(deps) {
146
149
  pool.state.lastReason = String(code ?? 'UNKNOWN');
147
150
  pool.state.lastSwitchAt = now();
148
151
  lastFailure = chunk;
149
- console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)}) - next key`);
152
+ logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)}) - next key`);
150
153
  // #216: per-switch webhook (opt-in switchNotify), deduped per provider
151
154
  if (switchNotify && activeRef) {
152
155
  notifySwitch(runtime0, pool, {
@@ -206,7 +209,7 @@ export function createRotate(deps) {
206
209
  const effCool = Math.min(cool, maxCool ?? cool);
207
210
  recordFailure(pool, activeRef, now(), effCool, maxCool);
208
211
  pushEvent(pool, activeRef, 'RATE_LIMIT', effCool);
209
- console.warn(`[dsh-key-rotation] ${options.provider}: key ${activeRef} proactive pause (remaining ${String(rate.remaining ?? '?')}/${String(rate.limit ?? '?')}, cool ${Math.round(effCool / 1000)}s) — next request will rotate`);
212
+ logWarn(`[dsh-key-rotation] ${options.provider}: key ${activeRef} proactive pause (remaining ${String(rate.remaining ?? '?')}/${String(rate.limit ?? '?')}, cool ${Math.round(effCool / 1000)}s) — next request will rotate`);
210
213
  }
211
214
  }
212
215
  // #7: persist quota snapshot regardless of threshold (so dashboard widget can show it).
@@ -229,7 +232,7 @@ export function createRotate(deps) {
229
232
  pool.state.lastReason = String(e?.code ?? 'TRANSPORT');
230
233
  pool.state.lastSwitchAt = now();
231
234
  lastFailure = finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
232
- console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} stream threw ${String(e?.code ?? e?.message ?? e)} - failover to next key`);
235
+ logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} stream threw ${String(e?.code ?? e?.message ?? e)} - failover to next key`);
233
236
  if (switchNotify && activeRef) {
234
237
  notifySwitch(runtime0, pool, {
235
238
  provider: options.provider,
@@ -256,7 +259,7 @@ export function createRotate(deps) {
256
259
  // pool exhausted — all keys cooling or missing
257
260
  pool.state.lastExhaustionAt = now();
258
261
  pool.state.exhaustionCount = (pool.state.exhaustionCount ?? 0) + 1;
259
- console.warn(`[dsh-key-rotation] ${options.provider}: pool exhausted — all ${pool.refs.length} keys cooling`);
262
+ logWarn(`[dsh-key-rotation] ${options.provider}: pool exhausted — all ${pool.refs.length} keys cooling`);
260
263
  const runtime = buildRuntime();
261
264
  // notify via extracted helper (see notifyExhaustion above)
262
265
  notifyExhaustion(runtime, pool, { provider: options.provider });
@@ -266,7 +269,7 @@ export function createRotate(deps) {
266
269
  const pools = runtime.providerToPool;
267
270
  const fb = pickCascadeFallback(options.provider, runtime, pools);
268
271
  if (fb && fb.pool && fb.pool !== pool) {
269
- console.warn(`[dsh-key-rotation] ${options.provider}: pool exhausted — cascading to ${fb.provider}`);
272
+ logWarn(`[dsh-key-rotation] ${options.provider}: pool exhausted — cascading to ${fb.provider}`);
270
273
  pool.state.lastReason = 'CASCADE';
271
274
  pool.state.lastSwitchAt = now();
272
275
  // Re-dispatch on the fallback pool (depth-1 via __isCascade guard)