@goodandready/dsh-key-rotation 0.5.0 → 0.5.2
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/README.md +8 -6
- package/lib/client.js +1 -1
- package/lib/index.js +19 -69
- package/lib/pool.js +107 -0
- package/package.json +8 -2
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
- **Cooldown** — an exhausted key is skipped for `cooldownMs`, then returns.
|
|
13
13
|
- **Dead/revoked key handling** — an auth/invalid key rotates to the next pool key instead of erroring out.
|
|
14
14
|
- **Settings GUI** — a **Settings → Key Rotation** section to manage everything without touching config files:
|
|
15
|
-
- **add a key in one place** — press *Add key*, paste the value, done. The credential name is generated for you (
|
|
15
|
+
- **add a key in one place** — press *Add key*, paste the value, done. The credential name is generated for you (`<PROVIDER>_API_KEY`, then `_2`, `_3`, …) and shown only on hover; the card lists keys as *Key 1*, *Key 2*.
|
|
16
16
|
- **live key status** — per key: in use / ready / cooling down with a countdown / **no such credential**, which is what catches a mistyped name that would otherwise fail silently.
|
|
17
17
|
- **rotation counter** — how many times a provider switched key, on which failure, and how long ago.
|
|
18
18
|
- **key order** — ↑/↓ buttons; the order of keys is the order they are tried.
|
|
@@ -46,10 +46,12 @@ dsh-key-rotation:
|
|
|
46
46
|
switchCodes: [QUOTA, RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT, EMPTY_RESPONSE, UNKNOWN_MODEL]
|
|
47
47
|
cooldownMs: 60000
|
|
48
48
|
providers:
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
- provider:
|
|
52
|
-
keys: [
|
|
49
|
+
# `provider` is the id of a provider registered with dsh, as it appears
|
|
50
|
+
# in Settings -> Models. `keys` are CREDENTIAL NAMES, never key values.
|
|
51
|
+
- provider: my-provider
|
|
52
|
+
keys: [MY_PROVIDER_API_KEY, MY_PROVIDER_API_KEY_2, MY_PROVIDER_API_KEY_3]
|
|
53
|
+
- provider: another-provider
|
|
54
|
+
keys: [ANOTHER_PROVIDER_API_KEY, ANOTHER_PROVIDER_API_KEY_2]
|
|
53
55
|
```
|
|
54
56
|
|
|
55
57
|
| Field | Default | Description |
|
|
@@ -60,7 +62,7 @@ dsh-key-rotation:
|
|
|
60
62
|
|
|
61
63
|
### How keys are stored
|
|
62
64
|
|
|
63
|
-
The plugin config only ever references keys by **name** (e.g. `
|
|
65
|
+
The plugin config only ever references keys by **name** (e.g. `MY_PROVIDER_API_KEY`). The values live in the dsh **Credentials** service or `$DSH_HOME/.credentials.yaml` — never in the plugin config.
|
|
64
66
|
|
|
65
67
|
A key typed into the Key Rotation card is written to that same credentials store: the value travels to the host once and is never sent back to the browser. Only its **last 5 characters** are, so two keys can be told apart in the UI. A key supplied by the launching environment is shown as read-only, because overwriting it here would be shadowed anyway.
|
|
66
68
|
|
package/lib/client.js
CHANGED
|
@@ -183,7 +183,7 @@ window.__ModuleLoader__.load({
|
|
|
183
183
|
* Имя переменной под новый ключ.
|
|
184
184
|
*
|
|
185
185
|
* Пользователь его больше не печатает: первый ключ провайдера получает имя
|
|
186
|
-
* вида
|
|
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
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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 (
|
|
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('|'));
|
|
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.
|
|
3
|
+
"version": "0.5.2",
|
|
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
|
+
}
|