@goodandready/dsh-key-rotation 0.4.0
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/LICENSE +21 -0
- package/README.md +86 -0
- package/cordis.patch.yml +7 -0
- package/lib/client.js +233 -0
- package/lib/index.js +437 -0
- package/package.json +55 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-key-rotation contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# dsh-key-rotation
|
|
2
|
+
|
|
3
|
+
**Per-provider API key rotation** for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh). Instead of failing on a quota/rate-limit error, the plugin transparently retries the request on the **next healthy key** in a per-provider pool.
|
|
4
|
+
|
|
5
|
+
> Hermes-style rotation: every configured provider has a key pool; when a key's limit is exhausted, the request is retried on the next key. Exhausted keys stay in cooldown and return to rotation after `cooldownMs`.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
- **Key pools per provider** — list the API keys (as credential/env names) that a provider may rotate through.
|
|
10
|
+
- **Auto-created clone routes** — the plugin registers a virtual provider/route and wires it to the pool; clone routes are hidden from the model dropdown.
|
|
11
|
+
- **Transparent on-failure rotation** — on a switchable error (`QUOTA`, `RATE_LIMIT`, `AUTH`/`INVALID`…) the request is retried on the next key.
|
|
12
|
+
- **Cooldown** — an exhausted key is skipped for `cooldownMs`, then returns.
|
|
13
|
+
- **Dead/revoked key handling** — an auth/invalid key rotates to the next pool key instead of erroring out.
|
|
14
|
+
- **Settings GUI** — a **Settings → Key Rotation** section to edit key pools, switch codes and cooldown without touching config files by hand.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
# From npm / GitHub after publishing:
|
|
20
|
+
dsh plugin --profile web add dsh-key-rotation
|
|
21
|
+
|
|
22
|
+
# Locally from a checkout:
|
|
23
|
+
dsh plugin --profile web add /path/to/dsh-key-rotation
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Restart the Web UI afterwards.
|
|
27
|
+
|
|
28
|
+
## Configure
|
|
29
|
+
|
|
30
|
+
### Web GUI (recommended)
|
|
31
|
+
|
|
32
|
+
Open **Settings → Key Rotation** and, for each provider, list the credential names of its keys. The plugin stores this in the `dsh-key-rotation` settings namespace (same place as `settings.yaml`).
|
|
33
|
+
|
|
34
|
+
### `settings.yaml`
|
|
35
|
+
|
|
36
|
+
```yaml
|
|
37
|
+
dsh-key-rotation:
|
|
38
|
+
switchCodes: [QUOTA, RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT, EMPTY_RESPONSE, UNKNOWN_MODEL]
|
|
39
|
+
cooldownMs: 60000
|
|
40
|
+
providers:
|
|
41
|
+
- provider: opencode-go
|
|
42
|
+
keys: [OPENCODE_GO_API_KEY, OPENCODE_GO_API_KEY_2, OPENCODE_GO_API_KEY_3]
|
|
43
|
+
- provider: ollama
|
|
44
|
+
keys: [OLLAMA_API_KEY, OLLAMA_API_KEY_2, OLLAMA_API_KEY_3]
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
| Field | Default | Description |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| `switchCodes` | `[QUOTA, RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT, EMPTY_RESPONSE, UNKNOWN_MODEL]` | Error codes that trigger a key switch. |
|
|
50
|
+
| `cooldownMs` | `60000` | How long an exhausted key stays out of rotation. |
|
|
51
|
+
| `providers` | — | `[{ provider, keys: [envName, ...] }]`. `keys` are credential/env **names**, not the key values themselves. |
|
|
52
|
+
|
|
53
|
+
### How keys are stored
|
|
54
|
+
|
|
55
|
+
The plugin only ever references keys by **name** (e.g. `OPENCODE_GO_API_KEY`). The actual values live in the dsh **Credentials** service (Web: **Settings → Credentials**) or `$DSH_HOME/.credentials.yaml` — never in the plugin config.
|
|
56
|
+
|
|
57
|
+
## How it works
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
request ──► {provider: rotation} clone route ──► pick next healthy key in pool
|
|
61
|
+
┌────────┐ on switchable failure retry with next key, stay in cooldown
|
|
62
|
+
└─────────┘
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
- The plugin patches `ctx.credentials.resolve` so a pool reference resolves to the current healthy key (round-robin, skipping keys in cooldown).
|
|
66
|
+
- It intercepts `llm/stream` to retry the request on the next key after a switchable failure, instead of surfacing the error to the caller.
|
|
67
|
+
|
|
68
|
+
## Structure
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
dsh-key-rotation/
|
|
72
|
+
├── package.json # dsh bundle/plugin metadata + peerDependencies
|
|
73
|
+
├── cordis.patch.yml # bundle layer: registers the virtual route "rotation"
|
|
74
|
+
├── lib/index.js # host: pools, credentials.resolve patch, stream retry
|
|
75
|
+
├── lib/client.js # browser: Settings → Key Rotation panel
|
|
76
|
+
└── README.md
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Security notes
|
|
80
|
+
|
|
81
|
+
- Key **values** never leave your Credentials store; the plugin config only holds env/credential **names**.
|
|
82
|
+
- `switchCodes` are error classification strings, not expressions — no secrets involved.
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# dsh-key-rotation bundle layer.
|
|
2
|
+
# Registers the virtual provider route "rotation"; the adapter delegates each
|
|
3
|
+
# request to the configured clone routes of the matching backend and rotates
|
|
4
|
+
# them on switchable failures (QUOTA / RATE_LIMIT / ...).
|
|
5
|
+
- insert:
|
|
6
|
+
- id: dsh-key-rotation
|
|
7
|
+
name: 'dsh-key-rotation'
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// dsh-key-rotation — Settings section ("Key Rotation").
|
|
2
|
+
// Renders in the harness Settings sidebar via the settings.section slot and
|
|
3
|
+
// edits the plugin's `dsh-key-rotation` settings namespace through the
|
|
4
|
+
// loopback-fenced config bridge at /dsh-key-rotation/config.
|
|
5
|
+
//
|
|
6
|
+
// The config is a KEY POOL PER PROVIDER: a list of providers, each with a list
|
|
7
|
+
// of API-key env names. The provider is picked from the catalog of providers
|
|
8
|
+
// actually registered with ctx.llm (served by the host as data.providers), so no
|
|
9
|
+
// manual route typing is ever needed. The plugin derives the fallback chain and
|
|
10
|
+
// auto-creates clone routes from the key count.
|
|
11
|
+
window.__ModuleLoader__.load({
|
|
12
|
+
id: 'dsh-key-rotation',
|
|
13
|
+
factory: (require) => {
|
|
14
|
+
var module = { exports: {} };
|
|
15
|
+
var exports = module.exports;
|
|
16
|
+
const React = require('react');
|
|
17
|
+
|
|
18
|
+
const CONFIG_PATH = '/dsh-key-rotation/config';
|
|
19
|
+
|
|
20
|
+
function KeyRotationSection() {
|
|
21
|
+
const [state, setState] = React.useState({ status: 'loading', value: null, revision: 0, error: '', providers: [] });
|
|
22
|
+
const [draft, setDraft] = React.useState(null);
|
|
23
|
+
|
|
24
|
+
const load = React.useCallback(() => {
|
|
25
|
+
setState((s) => ({ ...s, status: 'loading', error: '' }));
|
|
26
|
+
fetch(CONFIG_PATH, { headers: { accept: 'application/json' } })
|
|
27
|
+
.then((r) => r.json())
|
|
28
|
+
.then((data) => {
|
|
29
|
+
setState({
|
|
30
|
+
status: 'ready',
|
|
31
|
+
value: data.value ?? null,
|
|
32
|
+
revision: data.revision ?? 0,
|
|
33
|
+
providers: Array.isArray(data.providers) ? data.providers : [],
|
|
34
|
+
error: data.error ? data.error.message : '',
|
|
35
|
+
});
|
|
36
|
+
setDraft(null);
|
|
37
|
+
})
|
|
38
|
+
.catch((e) => setState((s) => ({ ...s, status: 'error', error: String(e) })));
|
|
39
|
+
}, []);
|
|
40
|
+
|
|
41
|
+
React.useEffect(() => { load(); }, [load]);
|
|
42
|
+
|
|
43
|
+
const val = draft ?? state.value;
|
|
44
|
+
if (state.status === 'loading' || !val) {
|
|
45
|
+
return React.createElement('p', { style: { color: 'var(--dsw-alias-label-tertiary)', fontSize: 13 } }, 'Loading…');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const providers = state.providers;
|
|
49
|
+
const providerById = new Map(providers.map((p) => [p.id, p.name]));
|
|
50
|
+
|
|
51
|
+
const setField = (fn) => setDraft(fn(val));
|
|
52
|
+
const providerList = Array.isArray(val.providers) ? val.providers : [];
|
|
53
|
+
|
|
54
|
+
const setProvider = (index, id) => setField((cur) => {
|
|
55
|
+
const next = [...(Array.isArray(cur.providers) ? cur.providers : [])];
|
|
56
|
+
next[index] = { ...next[index], provider: id };
|
|
57
|
+
return { ...cur, providers: next };
|
|
58
|
+
});
|
|
59
|
+
const setKey = (pIndex, kIndex, value) => setField((cur) => {
|
|
60
|
+
const next = [...(Array.isArray(cur.providers) ? cur.providers : [])];
|
|
61
|
+
const keys = [...(next[pIndex].keys ?? [])];
|
|
62
|
+
keys[kIndex] = value;
|
|
63
|
+
next[pIndex] = { ...next[pIndex], keys };
|
|
64
|
+
return { ...cur, providers: next };
|
|
65
|
+
});
|
|
66
|
+
const addKey = (pIndex) => setField((cur) => {
|
|
67
|
+
const next = [...(Array.isArray(cur.providers) ? cur.providers : [])];
|
|
68
|
+
next[pIndex] = { ...next[pIndex], keys: [...(next[pIndex].keys ?? []), ''] };
|
|
69
|
+
return { ...cur, providers: next };
|
|
70
|
+
});
|
|
71
|
+
const removeKey = (pIndex, kIndex) => setField((cur) => {
|
|
72
|
+
const next = [...(Array.isArray(cur.providers) ? cur.providers : [])];
|
|
73
|
+
next[pIndex] = { ...next[pIndex], keys: (next[pIndex].keys ?? []).filter((_, i) => i !== kIndex) };
|
|
74
|
+
return { ...cur, providers: next };
|
|
75
|
+
});
|
|
76
|
+
const removeProvider = (pIndex) => setField((cur) => ({
|
|
77
|
+
...cur,
|
|
78
|
+
providers: (Array.isArray(cur.providers) ? cur.providers : []).filter((_, i) => i !== pIndex),
|
|
79
|
+
}));
|
|
80
|
+
const addProvider = () => setField((cur) => ({
|
|
81
|
+
...cur,
|
|
82
|
+
providers: [...(Array.isArray(cur.providers) ? cur.providers : []), { provider: '', keys: [''] }],
|
|
83
|
+
}));
|
|
84
|
+
|
|
85
|
+
const save = () => {
|
|
86
|
+
if (!draft) return;
|
|
87
|
+
setState((s) => ({ ...s, status: 'saving', error: '' }));
|
|
88
|
+
fetch(CONFIG_PATH, {
|
|
89
|
+
method: 'PUT',
|
|
90
|
+
headers: { 'content-type': 'application/json' },
|
|
91
|
+
body: JSON.stringify({ section: draft, expectedRevision: state.revision }),
|
|
92
|
+
})
|
|
93
|
+
.then((r) => r.json())
|
|
94
|
+
.then((data) => {
|
|
95
|
+
if (data.error) {
|
|
96
|
+
setState((s) => ({ ...s, status: 'error', error: data.error.message }));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
setState((s) => ({ status: 'ready', value: data.value ?? draft, revision: data.revision ?? s.revision, error: '', providers: s.providers }));
|
|
100
|
+
setDraft(null);
|
|
101
|
+
})
|
|
102
|
+
.catch((e) => setState((s) => ({ ...s, status: 'error', error: String(e) })));
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const labelStyle = { color: 'var(--dsw-alias-label-secondary)', fontSize: 13 };
|
|
106
|
+
const field = (labelText, node) => React.createElement('label', { style: { display: 'flex', flexDirection: 'column', gap: 4 } },
|
|
107
|
+
React.createElement('span', { style: labelStyle }, labelText), node);
|
|
108
|
+
|
|
109
|
+
const textInput = (value, onChange, placeholder) => React.createElement('input', {
|
|
110
|
+
value: value ?? '',
|
|
111
|
+
onChange: (e) => onChange(e.target.value),
|
|
112
|
+
placeholder,
|
|
113
|
+
style: {
|
|
114
|
+
background: 'var(--dsw-specific-input-major)',
|
|
115
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
116
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
117
|
+
borderRadius: 6,
|
|
118
|
+
padding: '5px 8px',
|
|
119
|
+
fontSize: 13,
|
|
120
|
+
fontFamily: 'inherit',
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const controlBtn = (labelText, onClick, disabled, title) => React.createElement('button', {
|
|
125
|
+
onClick,
|
|
126
|
+
disabled: !!disabled,
|
|
127
|
+
title,
|
|
128
|
+
style: {
|
|
129
|
+
cursor: disabled ? 'default' : 'pointer',
|
|
130
|
+
borderRadius: 6,
|
|
131
|
+
padding: '2px 7px',
|
|
132
|
+
fontSize: 12,
|
|
133
|
+
fontFamily: 'inherit',
|
|
134
|
+
background: 'transparent',
|
|
135
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
136
|
+
color: disabled ? 'var(--dsw-alias-label-tertiary)' : 'var(--dsw-alias-label-secondary)',
|
|
137
|
+
},
|
|
138
|
+
}, labelText);
|
|
139
|
+
|
|
140
|
+
const selectStyle = {
|
|
141
|
+
flex: 1,
|
|
142
|
+
background: 'var(--dsw-specific-input-major)',
|
|
143
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
144
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
145
|
+
borderRadius: 6,
|
|
146
|
+
padding: '5px 8px',
|
|
147
|
+
fontSize: 13,
|
|
148
|
+
fontFamily: 'inherit',
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const providerRows = providerList.map((entry, pIndex) => {
|
|
152
|
+
const options = [];
|
|
153
|
+
if (entry.provider && !providerById.has(entry.provider)) {
|
|
154
|
+
options.push(React.createElement('option', { key: entry.provider, value: entry.provider }, `${entry.provider} (not registered)`));
|
|
155
|
+
}
|
|
156
|
+
options.push(...providers.map((p) =>
|
|
157
|
+
React.createElement('option', { key: p.id, value: p.id }, `${p.name}${p.id !== p.name ? ' — ' + p.id : ''}`)));
|
|
158
|
+
|
|
159
|
+
const keys = entry.keys ?? [];
|
|
160
|
+
const keyRows = keys.map((key, kIndex) =>
|
|
161
|
+
React.createElement('div', { key: kIndex, style: { display: 'flex', gap: 6, alignItems: 'center' } },
|
|
162
|
+
React.createElement('span', { style: { width: 18, color: 'var(--dsw-alias-label-tertiary)', fontSize: 12, textAlign: 'right' } }, String(kIndex + 1)),
|
|
163
|
+
textInput(key, (v) => setKey(pIndex, kIndex, v), 'API_KEY_ENV_NAME'),
|
|
164
|
+
controlBtn('✕', () => removeKey(pIndex, kIndex), false, 'Remove key'),
|
|
165
|
+
));
|
|
166
|
+
|
|
167
|
+
return React.createElement('div', { key: pIndex, style: { display: 'flex', flexDirection: 'column', gap: 6, border: '1px solid var(--dsw-alias-border-l2)', borderRadius: 8, padding: 8 } },
|
|
168
|
+
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
|
|
169
|
+
React.createElement('select', { value: entry.provider, onChange: (e) => setProvider(pIndex, e.target.value), style: selectStyle }, options),
|
|
170
|
+
controlBtn('✕', () => removeProvider(pIndex), false, 'Remove provider'),
|
|
171
|
+
),
|
|
172
|
+
React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 6 } },
|
|
173
|
+
keyRows,
|
|
174
|
+
React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center' } },
|
|
175
|
+
controlBtn('+ Add key', () => addKey(pIndex), false, 'Add API key'),
|
|
176
|
+
),
|
|
177
|
+
),
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const noProviders = providers.length === 0
|
|
182
|
+
? React.createElement('p', { style: { color: 'var(--dsw-alias-state-warning-primary)', fontSize: 12, margin: 0 } },
|
|
183
|
+
'No providers registered with DSH — nothing to pick from yet.')
|
|
184
|
+
: null;
|
|
185
|
+
|
|
186
|
+
const btn = (labelText, onClick, primary) => React.createElement('button', {
|
|
187
|
+
onClick,
|
|
188
|
+
style: {
|
|
189
|
+
cursor: 'pointer',
|
|
190
|
+
borderRadius: 6,
|
|
191
|
+
padding: '5px 12px',
|
|
192
|
+
fontSize: 13,
|
|
193
|
+
fontFamily: 'inherit',
|
|
194
|
+
background: primary ? 'var(--dsw-alias-button-info-fill)' : 'transparent',
|
|
195
|
+
border: primary ? '1px solid var(--dsw-alias-button-info-fill)' : '1px solid var(--dsw-alias-border-l2)',
|
|
196
|
+
color: primary ? 'var(--dsw-alias-label-primary-foreground)' : 'var(--dsw-alias-label-secondary)',
|
|
197
|
+
},
|
|
198
|
+
}, labelText);
|
|
199
|
+
|
|
200
|
+
return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 560 } },
|
|
201
|
+
React.createElement('p', { style: { color: 'var(--dsw-alias-label-tertiary)', fontSize: 12, margin: 0 } },
|
|
202
|
+
'Per-provider API key rotation. For each provider, list its API keys (env names, stored in DSH credentials). The plugin routes a model through that provider\u2019s keys in order and switches to the next on a quota/rate-limit failure.'),
|
|
203
|
+
field('Cooldown after failure (ms)', textInput(String(val.cooldownMs ?? 60000), (v) => setField((cur) => ({ ...cur, cooldownMs: Number(v) || 0 })))),
|
|
204
|
+
field('Switch codes (comma-separated)', textInput((val.switchCodes ?? []).join(', '), (v) => setField((cur) => ({ ...cur, switchCodes: v.split(',').map((s) => s.trim()).filter(Boolean) })))),
|
|
205
|
+
field('Providers and their keys', React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
|
|
206
|
+
providerRows,
|
|
207
|
+
React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', marginTop: 2 } },
|
|
208
|
+
btn('+ Add provider', addProvider, false),
|
|
209
|
+
noProviders,
|
|
210
|
+
),
|
|
211
|
+
)),
|
|
212
|
+
state.error ? React.createElement('p', { style: { color: 'var(--dsw-alias-state-error-primary)', fontSize: 12, margin: 0 } }, state.error) : null,
|
|
213
|
+
React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center' } },
|
|
214
|
+
btn('Save', save, true),
|
|
215
|
+
btn('Discard', load, false),
|
|
216
|
+
state.status === 'saving' ? React.createElement('span', { style: { color: 'var(--dsw-alias-label-tertiary)', fontSize: 12 } }, 'Saving…') : null,
|
|
217
|
+
),
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function apply(ctx) {
|
|
222
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
223
|
+
name: 'settings.section',
|
|
224
|
+
id: 'dsh-key-rotation',
|
|
225
|
+
order: 20,
|
|
226
|
+
label: () => 'Key Rotation',
|
|
227
|
+
}, KeyRotationSection));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
module.exports = { apply, inject: ['slots'] };
|
|
231
|
+
return module.exports;
|
|
232
|
+
},
|
|
233
|
+
});
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// dsh-key-rotation — per-provider API key rotation for DeepSeek Harness.
|
|
3
|
+
//
|
|
4
|
+
// Transparent key rotation, Hermes-style: every configured provider has a KEY
|
|
5
|
+
// POOL (env refs). The plugin patches `ctx.credentials.resolve` so a pool ref
|
|
6
|
+
// resolves to the next available key (round-robin, skipping keys in cooldown),
|
|
7
|
+
// and intercepts `llm/stream` to retry a request on the next key when the
|
|
8
|
+
// current one fails with a switchable error (QUOTA, RATE_LIMIT, ...) before
|
|
9
|
+
// any content chunk.
|
|
10
|
+
//
|
|
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
|
|
15
|
+
// INVALID_REPLAY_STATE).
|
|
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
|
|
23
|
+
// belongs to the same pool).
|
|
24
|
+
//
|
|
25
|
+
// The Settings section ("Key Rotation") edits the provider key pools as a
|
|
26
|
+
// simple list: pick a provider from the dropdown of every provider registered
|
|
27
|
+
// with ctx.llm (clone routes are hidden from the dropdown), then add/remove key
|
|
28
|
+
// env names. Plus cooldown and switch codes.
|
|
29
|
+
//
|
|
30
|
+
// Config (all optional, sane defaults):
|
|
31
|
+
// switchCodes: string[] failure codes eligible to switch
|
|
32
|
+
// cooldownMs: number key cooldown after a switchable failure
|
|
33
|
+
// providers: array [{ provider, keys: [envName, ...] }]
|
|
34
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
35
|
+
import Schema from '@deepseek-ai/schemastery';
|
|
36
|
+
|
|
37
|
+
export const name = 'dsh-key-rotation';
|
|
38
|
+
export const inject = ['llm', 'webServer', 'settings', 'credentials'];
|
|
39
|
+
|
|
40
|
+
/** Settings namespace owning the GUI-editable section (settingsNamespace-valid). */
|
|
41
|
+
const NS = 'dsh-key-rotation';
|
|
42
|
+
/** Config bridge route (GET / PUT / DELETE), loopback-fenced like llm-fallback. */
|
|
43
|
+
const CONFIG_PATH = '/dsh-key-rotation/config';
|
|
44
|
+
/** The llm-pi-ai namespace whose provider profiles map providers to pools. */
|
|
45
|
+
const PIAI_NS = 'llm-pi-ai';
|
|
46
|
+
/** Marker on internally re-dispatched requests so the interceptor does not loop. */
|
|
47
|
+
const MARKER = '__dshKeyRotation';
|
|
48
|
+
|
|
49
|
+
const DEFAULT_SWITCH_CODES = [
|
|
50
|
+
'QUOTA', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT', 'EMPTY_RESPONSE', 'UNKNOWN_MODEL', 'AUTH',
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
// Fallback classification by failure message. pi-ai surfaces many real quota /
|
|
54
|
+
// rate-limit / transport failures as thrown exceptions (e.g. the OpenAI SDK
|
|
55
|
+
// throws on HTTP 429 before the stream starts), and dsh-llm then normalizes
|
|
56
|
+
// them to finish chunks with code "UNKNOWN". The message still carries the
|
|
57
|
+
// provider's own text ("429: ...", "Weekly usage limit reached", ...), so we
|
|
58
|
+
// treat pre-content failures whose message matches these patterns as
|
|
59
|
+
// switchable even when the code is not in `switchCodes`.
|
|
60
|
+
const SWITCHABLE_MESSAGE_PATTERN = new RegExp([
|
|
61
|
+
/\b(?:quota|usage[\s_-]+limit|rate[\s_-]?limit)\b/i,
|
|
62
|
+
/\binsufficient[\s_-]+(?:quota|balance|credits?)\b/i,
|
|
63
|
+
/\bout[\s_-]+of[\s_-]+(?:credits?|budget)\b/i,
|
|
64
|
+
/\b(?:exceeded|exhausted)[\s_-]+(?:quota|limit|budget)\b/i,
|
|
65
|
+
/\bbilling\b/i,
|
|
66
|
+
/\b429\b|\b5\d\d\b/i,
|
|
67
|
+
/\btime(?:d)?\s*out\b|timeout/i,
|
|
68
|
+
/\b(?:network|connection|socket|fetch|ECONN[A-Z]+)\b/i,
|
|
69
|
+
/\bother side closed|premature close|stream ended (?:before|without)\b/i,
|
|
70
|
+
// auth: a dead/revoked key should also rotate to the next pool key
|
|
71
|
+
/\b401\b|\b403\b/i,
|
|
72
|
+
/\b(?:invalid|expired|revoked|unauthorized)[\s_-]+(?:api[\s_-]?key|token)\b/i,
|
|
73
|
+
/\bapi[\s_-]?key[\s_-]+(?:is[\s_-]+)?(?:invalid|expired|revoked|unauthorized)\b/i,
|
|
74
|
+
/\b(?:authentication|unauthorized|not[\s_-]+authorized)\b/i,
|
|
75
|
+
].map((r) => r.source).join('|'));
|
|
76
|
+
|
|
77
|
+
// Bootstrap key pools. The user edits these in the Settings GUI; this is just
|
|
78
|
+
// the default matching the current server setup.
|
|
79
|
+
const DEFAULT_PROVIDERS = [
|
|
80
|
+
{
|
|
81
|
+
provider: 'opencode-go',
|
|
82
|
+
keys: ['OPENCODE_GO_API_KEY', 'OPENCODE_GO_API_KEY_2', 'OPENCODE_GO_API_KEY_3', 'OPENCODE_GO_API_KEY_4'],
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
provider: 'ollama',
|
|
86
|
+
keys: ['OLLAMA_API_KEY', 'OLLAMA_API_KEY_2', 'OLLAMA_API_KEY_3'],
|
|
87
|
+
},
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
export const Config = Schema.object({
|
|
91
|
+
switchCodes: Schema.array(Schema.string()).default([...DEFAULT_SWITCH_CODES]),
|
|
92
|
+
cooldownMs: Schema.number().default(60000),
|
|
93
|
+
providers: Schema.array(Schema.object({
|
|
94
|
+
provider: Schema.string().required(),
|
|
95
|
+
keys: Schema.array(Schema.string()).default([]),
|
|
96
|
+
})).default([...DEFAULT_PROVIDERS]),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ── config bridge (GET/PUT/DELETE on CONFIG_PATH), mirroring llm-fallback ──
|
|
100
|
+
|
|
101
|
+
function isLoopbackAddress(address) {
|
|
102
|
+
if (address === void 0) return false;
|
|
103
|
+
if (address === '127.0.0.1' || address === '::1') return true;
|
|
104
|
+
if (address.startsWith('::ffff:')) return address.slice(7) === '127.0.0.1';
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isTrustedBridgeRequest(request) {
|
|
109
|
+
if (!isLoopbackAddress(request.socket?.remoteAddress)) return false;
|
|
110
|
+
if (request.headers['sec-fetch-site'] === 'cross-site') return false;
|
|
111
|
+
const origin = request.headers['origin'];
|
|
112
|
+
if (origin === void 0) return true;
|
|
113
|
+
try {
|
|
114
|
+
const host = request.headers['host'];
|
|
115
|
+
if (host === void 0) return false;
|
|
116
|
+
return new URL(origin).host === host;
|
|
117
|
+
} catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function json(res, status, obj) {
|
|
123
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
124
|
+
res.end(JSON.stringify(obj));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function readJson(request) {
|
|
128
|
+
return new Promise((resolve, reject) => {
|
|
129
|
+
let raw = '';
|
|
130
|
+
request.on('data', (c) => { raw += c; });
|
|
131
|
+
request.on('end', () => {
|
|
132
|
+
try {
|
|
133
|
+
resolve(JSON.parse(raw || '{}'));
|
|
134
|
+
} catch (e) {
|
|
135
|
+
reject(e);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
request.on('error', reject);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function descriptorOf(ctx, ns) {
|
|
143
|
+
const settings = ctx.get('settings');
|
|
144
|
+
if (settings === void 0) return void 0;
|
|
145
|
+
return settings.describe({ redactSecrets: true }).find((candidate) => candidate.ns === ns);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function viewOf(descriptor, settings) {
|
|
149
|
+
return {
|
|
150
|
+
available: true,
|
|
151
|
+
writable: settings.writable,
|
|
152
|
+
hasDocument: settings.hasDocument,
|
|
153
|
+
value: descriptor.value,
|
|
154
|
+
...descriptor.base === void 0 ? {} : { base: descriptor.base },
|
|
155
|
+
...descriptor.user === void 0 || Object.keys(descriptor.user).length === 0 ? {} : { user: descriptor.user },
|
|
156
|
+
revision: descriptor.revision,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function writeSection(ctx, ns, section, expectedRevision, res) {
|
|
161
|
+
const settings = ctx.get('settings');
|
|
162
|
+
if (settings === void 0) {
|
|
163
|
+
json(res, 503, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: no settings provider is mounted' } });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
await settings.replace(ns, section, expectedRevision);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (error?.code === 'SETTINGS_CONFLICT') {
|
|
170
|
+
json(res, 409, { error: { code: 'settings-conflict', message: `dsh-key-rotation: changed elsewhere (expected revision ${String(error.expected)}, current ${String(error.actual)}); reload and retry` } });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
json(res, 400, { error: { code: 'settings-rejected', message: error instanceof Error ? error.message : String(error) } });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const descriptor = descriptorOf(ctx, ns);
|
|
177
|
+
if (descriptor === void 0) {
|
|
178
|
+
json(res, 500, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: namespace vanished after write' } });
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
json(res, 200, viewOf(descriptor, { writable: settings.writable, hasDocument: settings.documentPath !== void 0 }));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Provider catalog for the GUI dropdown, minus clone routes of configured chains. */
|
|
185
|
+
function providerCatalog(ctx, cloneIds) {
|
|
186
|
+
const seen = new Set();
|
|
187
|
+
const out = [];
|
|
188
|
+
for (const info of ctx.llm.listProviders()) {
|
|
189
|
+
if (seen.has(info.id) || cloneIds.has(info.id)) continue;
|
|
190
|
+
seen.add(info.id);
|
|
191
|
+
out.push({ id: info.id, name: info.name ?? info.id });
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function handleConfigBridge(ctx, request, res, getCloneIds) {
|
|
197
|
+
if (!isTrustedBridgeRequest(request)) {
|
|
198
|
+
res.writeHead(403);
|
|
199
|
+
res.end();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const method = request.method ?? 'GET';
|
|
203
|
+
if (method === 'GET') {
|
|
204
|
+
const settings = ctx.get('settings');
|
|
205
|
+
const descriptor = descriptorOf(ctx, NS);
|
|
206
|
+
const body = {
|
|
207
|
+
providers: providerCatalog(ctx, getCloneIds()),
|
|
208
|
+
};
|
|
209
|
+
if (descriptor === void 0) {
|
|
210
|
+
json(res, 200, {
|
|
211
|
+
...body,
|
|
212
|
+
available: false,
|
|
213
|
+
writable: settings?.writable ?? false,
|
|
214
|
+
hasDocument: settings?.documentPath !== void 0,
|
|
215
|
+
value: void 0,
|
|
216
|
+
revision: 0,
|
|
217
|
+
});
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
json(res, 200, {
|
|
221
|
+
...body,
|
|
222
|
+
...viewOf(descriptor, {
|
|
223
|
+
writable: settings?.writable ?? false,
|
|
224
|
+
hasDocument: settings?.documentPath !== void 0,
|
|
225
|
+
}),
|
|
226
|
+
});
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (method === 'PUT' || method === 'DELETE') {
|
|
230
|
+
let section;
|
|
231
|
+
let expectedRevision;
|
|
232
|
+
if (method === 'PUT') {
|
|
233
|
+
let body;
|
|
234
|
+
try {
|
|
235
|
+
body = await readJson(request);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
json(res, 400, { error: { code: 'settings-rejected', message: `dsh-key-rotation: invalid request body: ${error instanceof Error ? error.message : String(error)}` } });
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (typeof body !== 'object' || body === null || typeof body.section !== 'object' || body.section === null || Array.isArray(body.section)) {
|
|
241
|
+
json(res, 400, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: PUT requires {"section": {...}}' } });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
section = body.section;
|
|
245
|
+
expectedRevision = typeof body.expectedRevision === 'number' ? body.expectedRevision : void 0;
|
|
246
|
+
} else {
|
|
247
|
+
section = {};
|
|
248
|
+
}
|
|
249
|
+
await writeSection(ctx, NS, section, expectedRevision, res);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
res.writeHead(405);
|
|
253
|
+
res.end();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function registerConfigBridge(ctx, getCloneIds) {
|
|
257
|
+
return ctx.webServer.register({
|
|
258
|
+
kind: 'exact',
|
|
259
|
+
path: CONFIG_PATH,
|
|
260
|
+
handler: (req, res) => void handleConfigBridge(ctx, req, res, getCloneIds),
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── plugin ──
|
|
265
|
+
|
|
266
|
+
export function apply(ctx, config = {}) {
|
|
267
|
+
// GUI section: defaults -> cordis row config -> saved user section.
|
|
268
|
+
// (installSettingsSection inlined: no @deepseek-ai/dsh-settings import, so the
|
|
269
|
+
// profile does not need a second copy of that package.)
|
|
270
|
+
let getConfig = () => config;
|
|
271
|
+
registerConfigBridge(ctx, () => buildRuntime().cloneIds);
|
|
272
|
+
|
|
273
|
+
// ── key-pool state, persisted across config reloads ──
|
|
274
|
+
// base provider -> { failedUntil: Map<ref, epochMs>, pointer: number, lastUsed: ref }
|
|
275
|
+
const poolState = new Map();
|
|
276
|
+
|
|
277
|
+
// ── runtime snapshot: config + llm-pi-ai profile mapping ──
|
|
278
|
+
function buildRuntime() {
|
|
279
|
+
// Deep-clone before resolving: the frozen snapshot from settings.register
|
|
280
|
+
// must never be written to by schemastery's dict resolver.
|
|
281
|
+
const cfg = Config(structuredClone(getConfig() ?? {})) ?? {};
|
|
282
|
+
const switchCodes = new Set(cfg.switchCodes ?? DEFAULT_SWITCH_CODES);
|
|
283
|
+
const cooldownMs = cfg.cooldownMs ?? 60000;
|
|
284
|
+
|
|
285
|
+
// ref -> pool (every key env of every configured provider)
|
|
286
|
+
const poolByRef = new Map();
|
|
287
|
+
// provider route (from llm-pi-ai profiles) -> its key pool
|
|
288
|
+
const providerToPool = new Map();
|
|
289
|
+
// clone route ids (for the settings dropdown filter)
|
|
290
|
+
const cloneIds = new Set();
|
|
291
|
+
|
|
292
|
+
for (const p of cfg.providers ?? []) {
|
|
293
|
+
const refs = (p.keys ?? []).filter((ref) => typeof ref === 'string' && ref.length > 0);
|
|
294
|
+
if (refs.length === 0) continue;
|
|
295
|
+
let state = poolState.get(p.provider);
|
|
296
|
+
if (!state) {
|
|
297
|
+
state = { failedUntil: new Map(), pointer: 0, lastUsed: undefined };
|
|
298
|
+
poolState.set(p.provider, state);
|
|
299
|
+
}
|
|
300
|
+
const pool = { base: p.provider, refs, state };
|
|
301
|
+
for (const ref of refs) poolByRef.set(ref, pool);
|
|
302
|
+
for (let i = 1; i < refs.length; i++) cloneIds.add(`${p.provider}-${i + 1}`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
let profiles = {};
|
|
306
|
+
try {
|
|
307
|
+
profiles = ctx.get('settings')?.get(PIAI_NS)?.providers ?? {};
|
|
308
|
+
} catch {
|
|
309
|
+
/* settings not mounted yet — empty mapping */
|
|
310
|
+
}
|
|
311
|
+
for (const [provider, profile] of Object.entries(profiles)) {
|
|
312
|
+
if (profile?.apiKeyEnv && poolByRef.has(profile.apiKeyEnv)) {
|
|
313
|
+
providerToPool.set(provider, poolByRef.get(profile.apiKeyEnv));
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return { switchCodes, cooldownMs, poolByRef, providerToPool, cloneIds };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ── patch credentials.resolve: pool refs resolve to the next healthy key ──
|
|
321
|
+
// Round-robin over the pool, skipping keys in cooldown; the request's
|
|
322
|
+
// provider identity never changes, so pi-ai replay state stays consistent.
|
|
323
|
+
const credentials = ctx.get('credentials');
|
|
324
|
+
if (credentials && typeof credentials.resolve === 'function' && !credentials.__dshKeyRotationPatched) {
|
|
325
|
+
const original = credentials.resolve.bind(credentials);
|
|
326
|
+
credentials.resolve = async (ref) => {
|
|
327
|
+
const { poolByRef } = buildRuntime();
|
|
328
|
+
const pool = poolByRef.get(ref);
|
|
329
|
+
if (!pool) return original(ref);
|
|
330
|
+
const now = Date.now();
|
|
331
|
+
const start = pool.state.pointer ?? 0;
|
|
332
|
+
for (let i = 0; i < pool.refs.length; i++) {
|
|
333
|
+
const index = (start + i) % pool.refs.length;
|
|
334
|
+
const candidate = pool.refs[index];
|
|
335
|
+
const until = pool.state.failedUntil.get(candidate);
|
|
336
|
+
if (until !== undefined && until > now) continue;
|
|
337
|
+
const hit = await original(candidate);
|
|
338
|
+
if (hit && typeof hit.value === 'string' && hit.value.length > 0) {
|
|
339
|
+
pool.state.pointer = (index + 1) % pool.refs.length;
|
|
340
|
+
pool.state.lastUsed = candidate;
|
|
341
|
+
return hit;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return original(ref); // everything cooled/missing — surface the base value
|
|
345
|
+
};
|
|
346
|
+
credentials.__dshKeyRotationPatched = true;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const finishError = (code, message) => ({
|
|
350
|
+
type: 'finish',
|
|
351
|
+
reason: { kind: 'error', failure: Object.freeze({ code, message }) },
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// Retry one request on the next pool key when the current key fails with a
|
|
355
|
+
// switchable error before any content chunk. The provider never changes —
|
|
356
|
+
// the resolve patch hands out the next key on each dispatch.
|
|
357
|
+
function rotate(options, pool) {
|
|
358
|
+
return (async function* () {
|
|
359
|
+
const { switchCodes, cooldownMs } = buildRuntime();
|
|
360
|
+
let lastFailure = null;
|
|
361
|
+
|
|
362
|
+
for (let attempt = 0; attempt < pool.refs.length; attempt++) {
|
|
363
|
+
let yielded = false;
|
|
364
|
+
let switching = false;
|
|
365
|
+
let inner;
|
|
366
|
+
try {
|
|
367
|
+
// mark the internal dispatch so the interceptor does not re-rotate
|
|
368
|
+
inner = ctx.llm.stream({ ...options, [MARKER]: true });
|
|
369
|
+
} catch (e) {
|
|
370
|
+
if (pool.state.lastUsed) pool.state.failedUntil.set(pool.state.lastUsed, Date.now() + cooldownMs);
|
|
371
|
+
lastFailure = finishError(e?.code ?? 'TRANSPORT',
|
|
372
|
+
`dsh-key-rotation: dispatch failed: ${String(e?.message ?? e)}`);
|
|
373
|
+
console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(pool.state.lastUsed ?? '?')} threw ${String(e?.code ?? e?.message ?? e)}`);
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
try {
|
|
378
|
+
for await (const chunk of inner) {
|
|
379
|
+
// Only actual content deltas lock the stream (no more rotation).
|
|
380
|
+
// Structural/metadata chunks (block-start/end, usage) do not.
|
|
381
|
+
if (chunk && (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta' || chunk.type === 'tool-call-delta')) {
|
|
382
|
+
yielded = true;
|
|
383
|
+
yield chunk;
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
if (chunk && chunk.type === 'finish') {
|
|
387
|
+
const kind = chunk.reason?.kind;
|
|
388
|
+
const failure = chunk.reason?.failure;
|
|
389
|
+
const code = failure?.code;
|
|
390
|
+
const message = failure?.message ?? '';
|
|
391
|
+
const switchable = !yielded && kind === 'error' &&
|
|
392
|
+
(switchCodes.has(code) || SWITCHABLE_MESSAGE_PATTERN.test(message));
|
|
393
|
+
if (switchable) {
|
|
394
|
+
if (pool.state.lastUsed) pool.state.failedUntil.set(pool.state.lastUsed, Date.now() + cooldownMs);
|
|
395
|
+
lastFailure = chunk;
|
|
396
|
+
console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(pool.state.lastUsed ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)}) — next key`);
|
|
397
|
+
switching = true;
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
yield chunk;
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
yield chunk;
|
|
404
|
+
}
|
|
405
|
+
} catch (e) {
|
|
406
|
+
yield finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (switching) continue; // try the next key
|
|
411
|
+
return; // clean end — served
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
yield lastFailure ?? finishError('TRANSPORT', 'dsh-key-rotation: all keys failed');
|
|
415
|
+
})();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Intercept the llm/stream waterfall: rotate any request whose provider maps
|
|
419
|
+
// to a configured key pool; pass everything else (and internal dispatches)
|
|
420
|
+
// straight through.
|
|
421
|
+
ctx.on('llm/stream', (options, next) => {
|
|
422
|
+
if (options[MARKER]) return next();
|
|
423
|
+
const { providerToPool } = buildRuntime();
|
|
424
|
+
const pool = providerToPool.get(options.provider);
|
|
425
|
+
if (!pool) return next();
|
|
426
|
+
console.warn(`[dsh-key-rotation] rotating ${options.provider}/${options.model} across ${pool.refs.length} keys`);
|
|
427
|
+
return rotate(options, pool);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
ctx.inject(['settings'], (sctx) => {
|
|
431
|
+
const scope = sctx.settings.register(NS, Config, { base: config });
|
|
432
|
+
getConfig = () => scope.get() ?? config;
|
|
433
|
+
sctx.effect(() => () => {
|
|
434
|
+
getConfig = () => config;
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@goodandready/dsh-key-rotation",
|
|
3
|
+
"version": "0.4.0",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"deepseek-harness",
|
|
7
|
+
"dsh",
|
|
8
|
+
"plugin",
|
|
9
|
+
"bundle",
|
|
10
|
+
"api-key",
|
|
11
|
+
"rotation",
|
|
12
|
+
"quota",
|
|
13
|
+
"rate-limit"
|
|
14
|
+
],
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "https://github.com/GooDAnDReaDY/dsh-key-rotation.git"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/GooDAnDReaDY/dsh-key-rotation",
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/GooDAnDReaDY/dsh-key-rotation/issues"
|
|
22
|
+
},
|
|
23
|
+
"type": "module",
|
|
24
|
+
"main": "lib/index.js",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": "./lib/index.js",
|
|
27
|
+
"./client": "./lib/client.js",
|
|
28
|
+
"./package.json": "./package.json",
|
|
29
|
+
"./cordis.patch.yml": "./cordis.patch.yml"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"lib",
|
|
33
|
+
"cordis.patch.yml",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"dsh": {
|
|
38
|
+
"bundle": {
|
|
39
|
+
"patch": "./cordis.patch.yml"
|
|
40
|
+
},
|
|
41
|
+
"client": {
|
|
42
|
+
"platform": "web",
|
|
43
|
+
"inject": [
|
|
44
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
45
|
+
"@deepseek-ai/dsh-client-ui-slots"
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"license": "MIT",
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
52
|
+
"@deepseek-ai/schemastery": "^3.18.1",
|
|
53
|
+
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6"
|
|
54
|
+
}
|
|
55
|
+
}
|