@goodandready/dsh-key-rotation 0.5.1 → 0.5.3

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/client.js CHANGED
@@ -183,7 +183,7 @@ window.__ModuleLoader__.load({
183
183
  * Имя переменной под новый ключ.
184
184
  *
185
185
  * Пользователь его больше не печатает: первый ключ провайдера получает имя
186
- * вида OPENCODE_GO_API_KEY, следующие — тот же корень с суффиксом _2, _3…
186
+ * вида <PROVIDER>_API_KEY, следующие — тот же корень с суффиксом _2, _3…
187
187
  * Корень берётся у уже существующих ключей, чтобы вручную заведённые имена
188
188
  * не ломались, и проверяется на занятость по ВСЕМ провайдерам — иначе два
189
189
  * провайдера незаметно делили бы одну учётную запись.
package/lib/index.js CHANGED
@@ -9,17 +9,17 @@
9
9
  // any content chunk.
10
10
  //
11
11
  // The PROVIDER IDENTITY NEVER CHANGES: requests always go out with the
12
- // provider the user selected (e.g. "opencode-go"), only the resolved API key
13
- // differs. This keeps pi-ai's replay state consistent across multi-call turns
14
- // and multi-turn sessions (the earlier clone-provider approach broke it with
12
+ // provider the user selected, only the resolved API key differs. This keeps
13
+ // pi-ai's replay state consistent across multi-call turns and multi-turn
14
+ // sessions (the earlier clone-provider approach broke it with
15
15
  // INVALID_REPLAY_STATE).
16
16
  //
17
- // Config is a KEY POOL PER PROVIDER: you list a real provider (e.g. "ollama")
18
- // and the env names of its API keys (e.g. OLLAMA_API_KEY, OLLAMA_API_KEY_2,
19
- // OLLAMA_API_KEY_3). When a key's limit is exhausted, the request retries on
20
- // the next key in the list; exhausted keys stay in cooldown for cooldownMs.
21
- // Clone provider routes (opencode-go-2, ...) are no longer used for rotation
22
- // but remain registered, so selecting them also rotates (their apiKeyEnv ref
17
+ // Config is a KEY POOL PER PROVIDER: you list a real provider id and the env
18
+ // names of its API keys (e.g. <PROVIDER>_API_KEY, <PROVIDER>_API_KEY_2, ...).
19
+ // When a key's limit is exhausted, the request retries on the next key in the
20
+ // list; exhausted keys stay in cooldown for cooldownMs. Clone provider routes
21
+ // (named `<base>-2`, `<base>-3`, ...) are no longer used for rotation but
22
+ // remain registered, so selecting them also rotates (their apiKeyEnv ref
23
23
  // belongs to the same pool).
24
24
  //
25
25
  // The Settings section ("Key Rotation") edits the provider key pools as a
@@ -33,9 +33,11 @@
33
33
  // providers: array [{ provider, keys: [envName, ...] }]
34
34
  // ─────────────────────────────────────────────────────────────────────────────
35
35
  import Schema from '@deepseek-ai/schemastery';
36
+ import { keyTail, isLoopbackAddress, isTrustedBridgeRequest, SWITCHABLE_MESSAGE_PATTERN, DEFAULT_SWITCH_CODES, isValidRef, pickNext, applyCooldown } from './pool.js';
36
37
 
37
38
  export const name = 'dsh-key-rotation';
38
39
  export const inject = ['llm', 'webServer', 'settings', 'credentials'];
40
+ export { keyTail, isLoopbackAddress, isTrustedBridgeRequest, DEFAULT_SWITCH_CODES };
39
41
 
40
42
  /** Settings namespace owning the GUI-editable section (settingsNamespace-valid). */
41
43
  const NS = 'dsh-key-rotation';
@@ -44,21 +46,11 @@ const CONFIG_PATH = '/dsh-key-rotation/config';
44
46
  const STATUS_PATH = '/dsh-key-rotation/status';
45
47
  const KEY_PATH = '/dsh-key-rotation/key';
46
48
 
47
- /** Хвост ключа для карточки: по нему ключ узнаётся, но не восстанавливается. */
48
- const KEY_TAIL_CHARS = 5;
49
-
50
- function keyTail(value) {
51
- if (typeof value !== 'string' || value.length === 0) return '';
52
- return value.length <= KEY_TAIL_CHARS ? value : value.slice(-KEY_TAIL_CHARS);
53
- }
54
49
  /** The llm-pi-ai namespace whose provider profiles map providers to pools. */
55
50
  const PIAI_NS = 'llm-pi-ai';
56
51
  /** Marker on internally re-dispatched requests so the interceptor does not loop. */
57
52
  const MARKER = '__dshKeyRotation';
58
53
 
59
- const DEFAULT_SWITCH_CODES = [
60
- 'QUOTA', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT', 'EMPTY_RESPONSE', 'UNKNOWN_MODEL', 'AUTH',
61
- ];
62
54
 
63
55
  // Fallback classification by failure message. pi-ai surfaces many real quota /
64
56
  // rate-limit / transport failures as thrown exceptions (e.g. the OpenAI SDK
@@ -67,35 +59,13 @@ const DEFAULT_SWITCH_CODES = [
67
59
  // provider's own text ("429: ...", "Weekly usage limit reached", ...), so we
68
60
  // treat pre-content failures whose message matches these patterns as
69
61
  // switchable even when the code is not in `switchCodes`.
70
- const SWITCHABLE_MESSAGE_PATTERN = new RegExp([
71
- /\b(?:quota|usage[\s_-]+limit|rate[\s_-]?limit)\b/i,
72
- /\binsufficient[\s_-]+(?:quota|balance|credits?)\b/i,
73
- /\bout[\s_-]+of[\s_-]+(?:credits?|budget)\b/i,
74
- /\b(?:exceeded|exhausted)[\s_-]+(?:quota|limit|budget)\b/i,
75
- /\bbilling\b/i,
76
- /\b429\b|\b5\d\d\b/i,
77
- /\btime(?:d)?\s*out\b|timeout/i,
78
- /\b(?:network|connection|socket|fetch|ECONN[A-Z]+)\b/i,
79
- /\bother side closed|premature close|stream ended (?:before|without)\b/i,
80
- // auth: a dead/revoked key should also rotate to the next pool key
81
- /\b401\b|\b403\b/i,
82
- /\b(?:invalid|expired|revoked|unauthorized)[\s_-]+(?:api[\s_-]?key|token)\b/i,
83
- /\bapi[\s_-]?key[\s_-]+(?:is[\s_-]+)?(?:invalid|expired|revoked|unauthorized)\b/i,
84
- /\b(?:authentication|unauthorized|not[\s_-]+authorized)\b/i,
85
- ].map((r) => r.source).join('|'));
86
-
87
- // Bootstrap key pools. The user edits these in the Settings GUI; this is just
88
- // the default matching the current server setup.
89
- const DEFAULT_PROVIDERS = [
90
- {
91
- provider: 'opencode-go',
92
- keys: ['OPENCODE_GO_API_KEY', 'OPENCODE_GO_API_KEY_2', 'OPENCODE_GO_API_KEY_3', 'OPENCODE_GO_API_KEY_4'],
93
- },
94
- {
95
- provider: 'ollama',
96
- keys: ['OLLAMA_API_KEY', 'OLLAMA_API_KEY_2', 'OLLAMA_API_KEY_3'],
97
- },
98
- ];
62
+
63
+ // Bootstrap key pools. The user configures them in the Settings GUI or via
64
+ // the dsh profile bundle config; the plugin itself ships no provider defaults
65
+ // so it does not bind to any specific installation. Empty array means: until
66
+ // the user adds a pool, no rotation happens, and every provider falls back to
67
+ // its single configured credential exactly as before this plugin was installed.
68
+ const DEFAULT_PROVIDERS = [];
99
69
 
100
70
  export const Config = Schema.object({
101
71
  switchCodes: Schema.array(Schema.string()).default([...DEFAULT_SWITCH_CODES]),
@@ -108,26 +78,6 @@ export const Config = Schema.object({
108
78
 
109
79
  // ── config bridge (GET/PUT/DELETE on CONFIG_PATH), mirroring llm-fallback ──
110
80
 
111
- function isLoopbackAddress(address) {
112
- if (address === void 0) return false;
113
- if (address === '127.0.0.1' || address === '::1') return true;
114
- if (address.startsWith('::ffff:')) return address.slice(7) === '127.0.0.1';
115
- return false;
116
- }
117
-
118
- function isTrustedBridgeRequest(request) {
119
- if (!isLoopbackAddress(request.socket?.remoteAddress)) return false;
120
- if (request.headers['sec-fetch-site'] === 'cross-site') return false;
121
- const origin = request.headers['origin'];
122
- if (origin === void 0) return true;
123
- try {
124
- const host = request.headers['host'];
125
- if (host === void 0) return false;
126
- return new URL(origin).host === host;
127
- } catch {
128
- return false;
129
- }
130
- }
131
81
 
132
82
  function json(res, status, obj) {
133
83
  res.writeHead(status, { 'content-type': 'application/json' });
@@ -547,7 +497,7 @@ export function apply(ctx, config = {}) {
547
497
  return;
548
498
  }
549
499
  const ref = typeof body?.ref === 'string' ? body.ref.trim() : '';
550
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(ref)) {
500
+ if (!isValidRef(ref)) {
551
501
  json(res, 400, { error: { code: 'bad-ref', message: 'dsh-key-rotation: ref must be an environment variable name' } });
552
502
  return;
553
503
  }
package/lib/pool.js ADDED
@@ -0,0 +1,107 @@
1
+ // Pure helpers for the key-rotation plugin. Kept free of DSH runtime
2
+ // dependencies so they can be unit-tested under `node --test` without
3
+ // `@deepseek-ai/cordis` / `@deepseek-ai/schemastery` being installed.
4
+ // Anything that talks to `ctx` stays in lib/index.js.
5
+
6
+ /** Number of trailing characters of a key shown in the UI for disambiguation. */
7
+ export const KEY_TAIL_CHARS = 5;
8
+
9
+ /** Returns the last KEY_TAIL_CHARS characters of a key, or the whole key if shorter. */
10
+ export function keyTail(value) {
11
+ if (typeof value !== 'string' || value.length === 0) return '';
12
+ return value.length <= KEY_TAIL_CHARS ? value : value.slice(-KEY_TAIL_CHARS);
13
+ }
14
+
15
+ /** True if a socket remoteAddress is a loopback (v4 / v6). */
16
+ export function isLoopbackAddress(address) {
17
+ if (address === undefined || address === null) return false;
18
+ if (address === '127.0.0.1' || address === '::1') return true;
19
+ if (typeof address === 'string' && address.startsWith('::ffff:')) {
20
+ return address.slice(7) === '127.0.0.1';
21
+ }
22
+ return false;
23
+ }
24
+
25
+ /** True if a request is both loopback and same-origin (sec-fetch-site guard). */
26
+ export function isTrustedBridgeRequest(request) {
27
+ if (!isLoopbackAddress(request?.socket?.remoteAddress)) return false;
28
+ if (request?.headers?.['sec-fetch-site'] === 'cross-site') return false;
29
+ const origin = request?.headers?.origin;
30
+ if (origin === undefined) return true; // no Origin header (loopback tool) is allowed
31
+ try {
32
+ const host = request?.headers?.host;
33
+ if (host === undefined) return false;
34
+ return new URL(origin).host === host;
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ /** Compiled once at module load. Matches error messages that should rotate the key
41
+ * even when the structured failure code is not in `switchCodes`.
42
+ * Mirrors SWITCHABLE_MESSAGE_PATTERN in lib/index.js exactly. */
43
+ export const SWITCHABLE_MESSAGE_PATTERN = new RegExp([
44
+ /\b(?:quota|usage[\s_-]+limit|rate[\s_-]?limit)\b/i,
45
+ /\binsufficient[\s_-]+(?:quota|balance|credits?)\b/i,
46
+ /\bout[\s_-]+of[\s_-]+(?:credits?|budget)\b/i,
47
+ /\b(?:exceeded|exhausted)[\s_-]+(?:quota|limit|budget)\b/i,
48
+ /\bbilling\b/i,
49
+ /\b429\b|\b5\d\d\b/i,
50
+ /\btime(?:d)?\s*out\b|timeout/i,
51
+ /\b(?:network|connection|socket|fetch|ECONN[A-Z]+)\b/i,
52
+ /\bother side closed|premature close|stream ended (?:before|without)\b/i,
53
+ /\b401\b|\b403\b/i,
54
+ /\b(?:invalid|expired|revoked|unauthorized)[\s_-]+(?:api[\s_-]?key|token)\b/i,
55
+ /\bapi[\s_-]?key[\s_-]+(?:is[\s_-]+)?(?:invalid|expired|revoked|unauthorized)\b/i,
56
+ /\b(?:authentication|unauthorized|not[\s_-]+authorized)\b/i,
57
+ ].map((r) => r.source).join('|'), 'i');
58
+
59
+ /** Default switch codes used by lib/index.js. Kept here so tests assert against
60
+ * the same list the runtime ships. */
61
+ export const DEFAULT_SWITCH_CODES = [
62
+ 'QUOTA', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT',
63
+ 'EMPTY_RESPONSE', 'UNKNOWN_MODEL', 'AUTH',
64
+ ];
65
+
66
+ /** Ref name validator. Same rule lib/index.js enforces in PUT/DELETE /key. */
67
+ const REF_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
68
+ export function isValidRef(ref) {
69
+ return typeof ref === 'string' && REF_RE.test(ref);
70
+ }
71
+
72
+ /**
73
+ * Pick the next healthy ref in a round-robin pool.
74
+ *
75
+ * @param {{ refs: string[], state: { failedUntil: Map<string, number>, pointer: number } }} pool
76
+ * @param {number} now - epoch milliseconds (injectable for tests)
77
+ * @param {number} refsCount - for tests: number of refs to consider (defaults to pool.refs.length)
78
+ * @returns {string|undefined} the ref to use, or undefined if pool has no refs
79
+ *
80
+ * Mirrors the inline logic in the credentials.resolve patch in lib/index.js.
81
+ * Exported here for unit tests; not used at runtime to avoid duplicating logic.
82
+ */
83
+ export function pickNext(pool, now, refsCount = pool.refs.length) {
84
+ if (refsCount === 0) return undefined;
85
+ const start = pool.state.pointer ?? 0;
86
+ for (let i = 0; i < refsCount; i++) {
87
+ const index = (start + i) % refsCount;
88
+ const candidate = pool.refs[index];
89
+ const until = pool.state.failedUntil.get(candidate);
90
+ if (until !== undefined && until > now) continue;
91
+ return candidate;
92
+ }
93
+ return undefined; // all cooled
94
+ }
95
+
96
+ /** Apply a failed-key cooldown to pool state. Pure: returns next state shape,
97
+ * does not mutate input. Runtime in lib/index.js mutates in place; tests use
98
+ * this helper to construct expected snapshots. */
99
+ export function applyCooldown(pool, ref, cooldownMs, now = Date.now()) {
100
+ return {
101
+ ...pool,
102
+ state: {
103
+ ...pool.state,
104
+ failedUntil: new Map(pool.state.failedUntil).set(ref, now + cooldownMs),
105
+ },
106
+ };
107
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
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",
@@ -51,5 +51,11 @@
51
51
  "@deepseek-ai/cordis": "^4.0.1",
52
52
  "@deepseek-ai/schemastery": "^3.18.1",
53
53
  "@deepseek-ai/dsh-llm": "^0.1.0-rc.6"
54
+ },
55
+ "scripts": {
56
+ "test": "node --test test/*.test.js"
57
+ },
58
+ "publishConfig": {
59
+ "access": "public"
54
60
  }
55
61
  }