@goodandready/dsh-key-rotation 0.4.0 → 0.5.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/README.md +19 -6
- package/cordis.patch.yml +1 -1
- package/lib/client.js +386 -99
- package/lib/index.js +158 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,17 +7,25 @@
|
|
|
7
7
|
## What it does
|
|
8
8
|
|
|
9
9
|
- **Key pools per provider** — list the API keys (as credential/env names) that a provider may rotate through.
|
|
10
|
-
- **
|
|
10
|
+
- **The provider you picked stays the provider** — rotation swaps the key, never the route, so a multi-call turn does not break. Legacy clone routes remain registered but are hidden from the model dropdown.
|
|
11
11
|
- **Transparent on-failure rotation** — on a switchable error (`QUOTA`, `RATE_LIMIT`, `AUTH`/`INVALID`…) the request is retried on the next key.
|
|
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
|
-
- **Settings GUI** — a **Settings → Key Rotation** section to
|
|
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 (`OPENCODE_GO_API_KEY`, `_2`, `_3`, …) and shown only on hover; the card lists keys as *Key 1*, *Key 2*.
|
|
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
|
+
- **rotation counter** — how many times a provider switched key, on which failure, and how long ago.
|
|
18
|
+
- **key order** — ↑/↓ buttons; the order of keys is the order they are tried.
|
|
19
|
+
- **switch codes as checkboxes** instead of a comma-separated string.
|
|
15
20
|
|
|
16
21
|
## Install
|
|
17
22
|
|
|
18
23
|
```bash
|
|
19
|
-
# From npm
|
|
20
|
-
dsh plugin --profile web add dsh-key-rotation
|
|
24
|
+
# From npm after publishing:
|
|
25
|
+
dsh plugin --profile web add @goodandready/dsh-key-rotation
|
|
26
|
+
|
|
27
|
+
# From GitHub:
|
|
28
|
+
dsh plugin --profile web add github:GooDAnDReaDY/dsh-key-rotation
|
|
21
29
|
|
|
22
30
|
# Locally from a checkout:
|
|
23
31
|
dsh plugin --profile web add /path/to/dsh-key-rotation
|
|
@@ -52,7 +60,9 @@ dsh-key-rotation:
|
|
|
52
60
|
|
|
53
61
|
### How keys are stored
|
|
54
62
|
|
|
55
|
-
The plugin only ever references keys by **name** (e.g. `OPENCODE_GO_API_KEY`). The
|
|
63
|
+
The plugin config only ever references keys by **name** (e.g. `OPENCODE_GO_API_KEY`). The values live in the dsh **Credentials** service or `$DSH_HOME/.credentials.yaml` — never in the plugin config.
|
|
64
|
+
|
|
65
|
+
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.
|
|
56
66
|
|
|
57
67
|
## How it works
|
|
58
68
|
|
|
@@ -63,7 +73,10 @@ request ──► {provider: rotation} clone route ──► pick next healthy k
|
|
|
63
73
|
```
|
|
64
74
|
|
|
65
75
|
- 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.
|
|
76
|
+
- It intercepts `llm/stream` to retry the request on the next key after a switchable failure, instead of surfacing the error to the caller. The hook is deliberately **not** `async`: the loop iterates its result directly, and returning a promise breaks every turn.
|
|
77
|
+
- The provider identity never changes — only the resolved key does — which keeps the adapter's replay state consistent across a multi-call turn.
|
|
78
|
+
|
|
79
|
+
Two local-only routes back the card: `GET /dsh-key-rotation/status` (key state, rotation counters, last 5 characters of each key) and `PUT|DELETE /dsh-key-rotation/key` (store or drop one key value). Both refuse anything that is not a same-origin request from loopback.
|
|
67
80
|
|
|
68
81
|
## Structure
|
|
69
82
|
|
package/cordis.patch.yml
CHANGED
package/lib/client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// dsh-key-rotation — Settings section ("Key Rotation").
|
|
1
|
+
// dsh-key-rotation — Settings section ("Key Rotation" / "Ротация ключей").
|
|
2
2
|
// Renders in the harness Settings sidebar via the settings.section slot and
|
|
3
3
|
// edits the plugin's `dsh-key-rotation` settings namespace through the
|
|
4
4
|
// loopback-fenced config bridge at /dsh-key-rotation/config.
|
|
@@ -8,16 +8,220 @@
|
|
|
8
8
|
// actually registered with ctx.llm (served by the host as data.providers), so no
|
|
9
9
|
// manual route typing is ever needed. The plugin derives the fallback chain and
|
|
10
10
|
// auto-creates clone routes from the key count.
|
|
11
|
+
//
|
|
12
|
+
// Localization: the plugin registers its own en/ru dictionaries with the DSH
|
|
13
|
+
// locale service (ctx.locale.register) and resolves the "active" locale
|
|
14
|
+
// through ctx.locale.getSnapshot().active + ctx.locale.subscribe() via
|
|
15
|
+
// React.useSyncExternalStore, so the UI switches language live whenever the
|
|
16
|
+
// DSH UI locale changes (Settings → Language).
|
|
11
17
|
window.__ModuleLoader__.load({
|
|
12
|
-
id: 'dsh-key-rotation',
|
|
18
|
+
id: '@goodandready/dsh-key-rotation',
|
|
13
19
|
factory: (require) => {
|
|
14
20
|
var module = { exports: {} };
|
|
15
21
|
var exports = module.exports;
|
|
16
22
|
const React = require('react');
|
|
17
23
|
|
|
18
24
|
const CONFIG_PATH = '/dsh-key-rotation/config';
|
|
25
|
+
const NS = 'dsh-key-rotation';
|
|
26
|
+
|
|
27
|
+
// -------------------------------------------------------------- i18n
|
|
28
|
+
const en = {
|
|
29
|
+
title: 'Key Rotation',
|
|
30
|
+
loading: 'Loading…',
|
|
31
|
+
notRegistered: 'not registered',
|
|
32
|
+
removeKey: 'Remove key',
|
|
33
|
+
removeProvider: 'Remove provider',
|
|
34
|
+
addKey: '+ Add key',
|
|
35
|
+
addKeyTitle: 'Add API key',
|
|
36
|
+
noProviders: 'No providers registered with DSH — nothing to pick from yet.',
|
|
37
|
+
addProvider: '+ Add provider',
|
|
38
|
+
desc: '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.',
|
|
39
|
+
cooldown: 'Cooldown after failure (ms)',
|
|
40
|
+
switchCodes: 'Switch codes (comma-separated)',
|
|
41
|
+
providersTitle: 'Providers and their keys',
|
|
42
|
+
save: 'Save',
|
|
43
|
+
discard: 'Discard',
|
|
44
|
+
saving: 'Saving…',
|
|
45
|
+
moveUp: 'Move up',
|
|
46
|
+
moveDown: 'Move down',
|
|
47
|
+
keyActive: 'in use',
|
|
48
|
+
keyReady: 'ready',
|
|
49
|
+
keyCooling: 'cooling down, {s}s',
|
|
50
|
+
keyMissing: 'no such credential',
|
|
51
|
+
switchesNone: 'no switches yet',
|
|
52
|
+
switchesSome: 'switches: {n} · last: {reason}, {ago}',
|
|
53
|
+
justNow: 'just now',
|
|
54
|
+
minutesAgo: '{n} min ago',
|
|
55
|
+
hoursAgo: '{n} h ago',
|
|
56
|
+
codesTitle: 'Switch on these failures',
|
|
57
|
+
keyValuePlaceholder: 'paste the key, then Save',
|
|
58
|
+
keySave: 'Save key',
|
|
59
|
+
keySaved: 'saved',
|
|
60
|
+
keyFromEnv: 'from the environment, read-only here',
|
|
61
|
+
keyWriteFailed: 'could not store the key: {msg}',
|
|
62
|
+
keyHint: 'The value is stored in DSH credentials and never sent back to the browser — only its last 5 characters are shown. Names are generated for you; hover a key to see the one it uses.',
|
|
63
|
+
keyLabel: 'Key {n}',
|
|
64
|
+
};
|
|
65
|
+
const ru = {
|
|
66
|
+
title: 'Ротация ключей',
|
|
67
|
+
loading: 'Загрузка…',
|
|
68
|
+
notRegistered: 'не зарегистрирован',
|
|
69
|
+
removeKey: 'Удалить ключ',
|
|
70
|
+
removeProvider: 'Удалить провайдера',
|
|
71
|
+
addKey: '+ Добавить ключ',
|
|
72
|
+
addKeyTitle: 'Добавить API-ключ',
|
|
73
|
+
noProviders: 'Провайдеры ещё не зарегистрированы в DSH — выбирать не из чего.',
|
|
74
|
+
addProvider: '+ Добавить провайдера',
|
|
75
|
+
desc: 'Ротация API-ключей по провайдерам. Для каждого провайдера укажите его API-ключи (имена env, хранятся в учётных данных DSH). Плагин ведёт модель по ключам провайдера по порядку и переключается на следующий при исчерпании квоты/превышении лимита.',
|
|
76
|
+
cooldown: 'Задержка после сбоя (мс)',
|
|
77
|
+
switchCodes: 'Коды переключения (через запятую)',
|
|
78
|
+
providersTitle: 'Провайдеры и их ключи',
|
|
79
|
+
save: 'Сохранить',
|
|
80
|
+
discard: 'Отменить',
|
|
81
|
+
saving: 'Сохранение…',
|
|
82
|
+
moveUp: 'Выше',
|
|
83
|
+
moveDown: 'Ниже',
|
|
84
|
+
keyActive: 'используется',
|
|
85
|
+
keyReady: 'готов',
|
|
86
|
+
keyCooling: 'остывает, {s}с',
|
|
87
|
+
keyMissing: 'ключ не найден',
|
|
88
|
+
switchesNone: 'переключений не было',
|
|
89
|
+
switchesSome: 'переключений: {n} · последнее: {reason}, {ago}',
|
|
90
|
+
justNow: 'только что',
|
|
91
|
+
minutesAgo: '{n} мин назад',
|
|
92
|
+
hoursAgo: '{n} ч назад',
|
|
93
|
+
codesTitle: 'Переключаться при этих сбоях',
|
|
94
|
+
keyValuePlaceholder: 'вставьте ключ и нажмите «Сохранить»',
|
|
95
|
+
keySave: 'Сохранить ключ',
|
|
96
|
+
keySaved: 'сохранён',
|
|
97
|
+
keyFromEnv: 'задан в окружении, отсюда не меняется',
|
|
98
|
+
keyWriteFailed: 'не удалось сохранить ключ: {msg}',
|
|
99
|
+
keyHint: 'Значение хранится в учётных данных DSH и обратно в браузер не отдаётся — показываются только последние 5 символов. Имена переменных создаются автоматически; наведите на ключ, чтобы увидеть используемое имя.',
|
|
100
|
+
keyLabel: 'Ключ {n}',
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// Коды, на которых имеет смысл переключать ключ. Список из хоста
|
|
104
|
+
// (DEFAULT_SWITCH_CODES); конфиг может содержать и свои — они показываются
|
|
105
|
+
// отдельными отмеченными галочками, чтобы правило нельзя было потерять.
|
|
106
|
+
const KNOWN_CODES = ['QUOTA', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT', 'EMPTY_RESPONSE', 'UNKNOWN_MODEL', 'AUTH'];
|
|
107
|
+
|
|
108
|
+
/** Опрос статуса ротации, пока раздел настроек открыт. */
|
|
109
|
+
function useRotationStatus() {
|
|
110
|
+
const [byProvider, setByProvider] = React.useState({});
|
|
111
|
+
React.useEffect(() => {
|
|
112
|
+
let alive = true;
|
|
113
|
+
const pull = () => {
|
|
114
|
+
fetch('/dsh-key-rotation/status', { headers: { accept: 'application/json' } })
|
|
115
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
116
|
+
.then((data) => {
|
|
117
|
+
if (!alive || !data || !Array.isArray(data.providers)) return;
|
|
118
|
+
const map = {};
|
|
119
|
+
for (const entry of data.providers) map[entry.provider] = entry;
|
|
120
|
+
setByProvider(map);
|
|
121
|
+
})
|
|
122
|
+
.catch(() => { /* статус необязателен: карточка остаётся редактором */ });
|
|
123
|
+
};
|
|
124
|
+
pull();
|
|
125
|
+
const id = setInterval(pull, 4000);
|
|
126
|
+
return () => { alive = false; clearInterval(id); };
|
|
127
|
+
}, []);
|
|
128
|
+
return byProvider;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function formatAgo(t, at) {
|
|
132
|
+
if (!at) return '';
|
|
133
|
+
const sec = Math.max(0, Math.round((Date.now() - at) / 1000));
|
|
134
|
+
if (sec < 60) return t('justNow');
|
|
135
|
+
if (sec < 3600) return t('minutesAgo').replace('{n}', String(Math.round(sec / 60)));
|
|
136
|
+
return t('hoursAgo').replace('{n}', String(Math.round(sec / 3600)));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Разметка карточки: сетка, а не набор inline-стилей. Фиксированные ширины
|
|
140
|
+
// здесь уже приводили к тому, что имя ключа обрезалось, а кнопки наезжали
|
|
141
|
+
// на поле значения, поэтому имя занимает свою строку, а служебная строка
|
|
142
|
+
// под ним ужимается сама.
|
|
143
|
+
const CARD_CSS = [
|
|
144
|
+
'.krot{display:flex;flex-direction:column;gap:14px;max-width:640px}',
|
|
145
|
+
'.krot p{margin:0}',
|
|
146
|
+
'.krot-hint{font-size:11px;color:var(--dsw-alias-label-tertiary)}',
|
|
147
|
+
'.krot-err{font-size:12px;color:var(--dsw-alias-state-error-primary)}',
|
|
148
|
+
'.krot-label{font-size:12px;color:var(--dsw-alias-label-secondary)}',
|
|
149
|
+
'.krot-field{display:flex;flex-direction:column;gap:5px}',
|
|
150
|
+
'.krot-in{background:var(--dsw-specific-input-major);border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);border-radius:6px;padding:5px 8px;font-size:13px;font-family:inherit;min-width:0;width:100%;box-sizing:border-box}',
|
|
151
|
+
'.krot-in:focus{outline:none;border-color:var(--dsw-alias-border-l3,var(--dsw-alias-border-l2))}',
|
|
152
|
+
'.krot-codes{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:4px 12px}',
|
|
153
|
+
'.krot-code{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--dsw-alias-label-secondary)}',
|
|
154
|
+
'.krot-prov{display:flex;flex-direction:column;gap:10px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:10px 12px}',
|
|
155
|
+
'.krot-prov-head{display:flex;gap:8px;align-items:center}',
|
|
156
|
+
'.krot-prov-head select{flex:1;min-width:0}',
|
|
157
|
+
'.krot-keys{display:flex;flex-direction:column;gap:8px}',
|
|
158
|
+
'.krot-key{display:grid;grid-template-columns:18px minmax(0,1fr);gap:4px 8px;align-items:center}',
|
|
159
|
+
'.krot-num{font-size:12px;color:var(--dsw-alias-label-tertiary);text-align:right}',
|
|
160
|
+
'.krot-name{font-size:13px;color:var(--dsw-alias-label-primary);cursor:default}',
|
|
161
|
+
'.krot-meta{grid-column:2;display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
|
|
162
|
+
'.krot-dot{width:8px;height:8px;border-radius:50%;flex:none}',
|
|
163
|
+
'.krot-state{font-size:11px;color:var(--dsw-alias-label-tertiary)}',
|
|
164
|
+
'.krot-tail{font-size:11px;color:var(--dsw-alias-label-tertiary);font-family:ui-monospace,Menlo,Consolas,monospace}',
|
|
165
|
+
'.krot-secret{flex:1;min-width:120px;max-width:220px}',
|
|
166
|
+
'.krot-acts{display:flex;gap:4px;margin-left:auto;flex:none}',
|
|
167
|
+
'.krot-btn{cursor:pointer;border-radius:6px;padding:2px 8px;font-size:12px;font-family:inherit;background:transparent;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);line-height:1.6}',
|
|
168
|
+
'.krot-btn:disabled{opacity:.35;cursor:default}',
|
|
169
|
+
'.krot-btn:not(:disabled):hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3,var(--dsw-alias-border-l2))}',
|
|
170
|
+
'.krot-foot{display:flex;gap:8px;align-items:center}',
|
|
171
|
+
'.krot-save{background:var(--dsw-alias-button-info-fill);border-color:var(--dsw-alias-button-info-fill);color:var(--dsw-alias-label-primary-foreground);padding:5px 14px;font-size:13px}',
|
|
172
|
+
].join('');
|
|
173
|
+
const CARD_CSS_ID = 'dsh-key-rotation/section.module.css';
|
|
174
|
+
if (typeof document !== 'undefined' && !document.querySelector('style[data-plugin-css="' + CARD_CSS_ID + '"]')) {
|
|
175
|
+
const tag = document.createElement('style');
|
|
176
|
+
tag.textContent = CARD_CSS;
|
|
177
|
+
tag.setAttribute('data-plugin', 'dsh-key-rotation');
|
|
178
|
+
tag.dataset.pluginCss = CARD_CSS_ID;
|
|
179
|
+
document.head.appendChild(tag);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Имя переменной под новый ключ.
|
|
184
|
+
*
|
|
185
|
+
* Пользователь его больше не печатает: первый ключ провайдера получает имя
|
|
186
|
+
* вида OPENCODE_GO_API_KEY, следующие — тот же корень с суффиксом _2, _3…
|
|
187
|
+
* Корень берётся у уже существующих ключей, чтобы вручную заведённые имена
|
|
188
|
+
* не ломались, и проверяется на занятость по ВСЕМ провайдерам — иначе два
|
|
189
|
+
* провайдера незаметно делили бы одну учётную запись.
|
|
190
|
+
*/
|
|
191
|
+
function nextKeyRef(providerId, existingKeys, allRefs) {
|
|
192
|
+
const fromExisting = (existingKeys || []).find((k) => typeof k === 'string' && k.length > 0);
|
|
193
|
+
const base = fromExisting
|
|
194
|
+
? fromExisting.replace(/_\d+$/, '')
|
|
195
|
+
: String(providerId || 'provider').toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '') + '_API_KEY';
|
|
196
|
+
const taken = new Set(allRefs);
|
|
197
|
+
if (!taken.has(base)) return base;
|
|
198
|
+
for (let n = 2; n < 1000; n++) {
|
|
199
|
+
const candidate = base + '_' + n;
|
|
200
|
+
if (!taken.has(candidate)) return candidate;
|
|
201
|
+
}
|
|
202
|
+
return base + '_' + Date.now();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function useActiveLocale(ctx) {
|
|
206
|
+
return React.useSyncExternalStore(
|
|
207
|
+
React.useMemo(() => (cb) => (ctx && ctx.locale ? ctx.locale.subscribe(cb) : () => {}), [ctx]),
|
|
208
|
+
React.useCallback(() => {
|
|
209
|
+
if (ctx && ctx.locale) {
|
|
210
|
+
const active = ctx.locale.getSnapshot().active;
|
|
211
|
+
if (typeof active === 'string' && active) return active;
|
|
212
|
+
}
|
|
213
|
+
return typeof navigator !== 'undefined' ? String(navigator.language || '').slice(0, 2) : '';
|
|
214
|
+
}, [ctx])
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function makeT(DICT, fallbackKeys) {
|
|
219
|
+
return (key) => (DICT && DICT[key]) || (fallbackKeys && fallbackKeys[key]) || key;
|
|
220
|
+
}
|
|
19
221
|
|
|
20
|
-
function KeyRotationSection() {
|
|
222
|
+
function KeyRotationSection(props) {
|
|
223
|
+
const DICT = props.locale === 'ru' ? ru : en;
|
|
224
|
+
const t = makeT(DICT, en);
|
|
21
225
|
const [state, setState] = React.useState({ status: 'loading', value: null, revision: 0, error: '', providers: [] });
|
|
22
226
|
const [draft, setDraft] = React.useState(null);
|
|
23
227
|
|
|
@@ -41,8 +245,40 @@ window.__ModuleLoader__.load({
|
|
|
41
245
|
React.useEffect(() => { load(); }, [load]);
|
|
42
246
|
|
|
43
247
|
const val = draft ?? state.value;
|
|
248
|
+
const status = useRotationStatus();
|
|
249
|
+
// Значения ключей живут только здесь, до нажатия «сохранить»: обратно из
|
|
250
|
+
// хоста они не приходят, в карточке видны лишь последние символы.
|
|
251
|
+
const [secretDraft, setSecretDraft] = React.useState({});
|
|
252
|
+
const [secretError, setSecretError] = React.useState('');
|
|
253
|
+
|
|
254
|
+
const keyInfo = (providerId, ref) => {
|
|
255
|
+
const entryStatus = status[providerId];
|
|
256
|
+
if (!entryStatus || !ref) return null;
|
|
257
|
+
return (entryStatus.keys ?? []).find((k) => k.ref === ref) ?? null;
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const saveSecret = (ref, rowKey) => {
|
|
261
|
+
const value = secretDraft[rowKey];
|
|
262
|
+
if (!value) return;
|
|
263
|
+
setSecretError('');
|
|
264
|
+
fetch('/dsh-key-rotation/key', {
|
|
265
|
+
method: 'PUT',
|
|
266
|
+
headers: { 'content-type': 'application/json' },
|
|
267
|
+
body: JSON.stringify({ ref, value }),
|
|
268
|
+
})
|
|
269
|
+
.then((r) => r.json().then((data) => ({ ok: r.ok, data })))
|
|
270
|
+
.then(({ ok, data }) => {
|
|
271
|
+
if (!ok) throw new Error(data?.error?.message ?? 'unknown error');
|
|
272
|
+
setSecretDraft((cur) => {
|
|
273
|
+
const next = { ...cur };
|
|
274
|
+
delete next[rowKey];
|
|
275
|
+
return next;
|
|
276
|
+
});
|
|
277
|
+
})
|
|
278
|
+
.catch((e) => setSecretError(t('keyWriteFailed').replace('{msg}', String(e?.message ?? e))));
|
|
279
|
+
};
|
|
44
280
|
if (state.status === 'loading' || !val) {
|
|
45
|
-
return React.createElement('p', { style: { color: 'var(--dsw-alias-label-tertiary)', fontSize: 13 } }, '
|
|
281
|
+
return React.createElement('p', { style: { color: 'var(--dsw-alias-label-tertiary)', fontSize: 13 } }, t('loading'));
|
|
46
282
|
}
|
|
47
283
|
|
|
48
284
|
const providers = state.providers;
|
|
@@ -56,17 +292,13 @@ window.__ModuleLoader__.load({
|
|
|
56
292
|
next[index] = { ...next[index], provider: id };
|
|
57
293
|
return { ...cur, providers: next };
|
|
58
294
|
});
|
|
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
295
|
const addKey = (pIndex) => setField((cur) => {
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
296
|
+
const providers = [...(cur.providers ?? [])];
|
|
297
|
+
const entry = { ...(providers[pIndex] ?? {}) };
|
|
298
|
+
const allRefs = providers.flatMap((prov) => prov?.keys ?? []);
|
|
299
|
+
entry.keys = [...(entry.keys ?? []), nextKeyRef(entry.provider, entry.keys, allRefs)];
|
|
300
|
+
providers[pIndex] = entry;
|
|
301
|
+
return { ...cur, providers };
|
|
70
302
|
});
|
|
71
303
|
const removeKey = (pIndex, kIndex) => setField((cur) => {
|
|
72
304
|
const next = [...(Array.isArray(cur.providers) ? cur.providers : [])];
|
|
@@ -77,6 +309,32 @@ window.__ModuleLoader__.load({
|
|
|
77
309
|
...cur,
|
|
78
310
|
providers: (Array.isArray(cur.providers) ? cur.providers : []).filter((_, i) => i !== pIndex),
|
|
79
311
|
}));
|
|
312
|
+
// Порядок ключей = порядок попыток, поэтому его надо менять кнопками,
|
|
313
|
+
// а не перепечатыванием имён.
|
|
314
|
+
const moveKey = (pIndex, kIndex, delta) => setField((cur) => {
|
|
315
|
+
const providers = [...(cur.providers ?? [])];
|
|
316
|
+
const entry = { ...(providers[pIndex] ?? {}) };
|
|
317
|
+
const keys = [...(entry.keys ?? [])];
|
|
318
|
+
const target = kIndex + delta;
|
|
319
|
+
if (target < 0 || target >= keys.length) return cur;
|
|
320
|
+
const moved = keys[kIndex];
|
|
321
|
+
keys[kIndex] = keys[target];
|
|
322
|
+
keys[target] = moved;
|
|
323
|
+
entry.keys = keys;
|
|
324
|
+
providers[pIndex] = entry;
|
|
325
|
+
return { ...cur, providers };
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// Коды из конфига, которых нет в известном списке, показываем тоже:
|
|
329
|
+
// иначе галочки молча выбросили бы чужое правило при первом сохранении.
|
|
330
|
+
const selectedCodes = new Set(Array.isArray(val.switchCodes) ? val.switchCodes : []);
|
|
331
|
+
const codeList = [...KNOWN_CODES, ...[...selectedCodes].filter((c) => !KNOWN_CODES.includes(c))];
|
|
332
|
+
const toggleCode = (code, on) => setField((cur) => {
|
|
333
|
+
const current = new Set(Array.isArray(cur.switchCodes) ? cur.switchCodes : []);
|
|
334
|
+
if (on) current.add(code); else current.delete(code);
|
|
335
|
+
return { ...cur, switchCodes: codeList.filter((c) => current.has(c)) };
|
|
336
|
+
});
|
|
337
|
+
|
|
80
338
|
const addProvider = () => setField((cur) => ({
|
|
81
339
|
...cur,
|
|
82
340
|
providers: [...(Array.isArray(cur.providers) ? cur.providers : []), { provider: '', keys: [''] }],
|
|
@@ -102,132 +360,161 @@ window.__ModuleLoader__.load({
|
|
|
102
360
|
.catch((e) => setState((s) => ({ ...s, status: 'error', error: String(e) })));
|
|
103
361
|
};
|
|
104
362
|
|
|
105
|
-
const
|
|
106
|
-
const field = (labelText, node) => React.createElement('label', { style: { display: 'flex', flexDirection: 'column', gap: 4 } },
|
|
107
|
-
React.createElement('span', { style: labelStyle }, labelText), node);
|
|
363
|
+
const h = React.createElement;
|
|
108
364
|
|
|
109
|
-
const
|
|
365
|
+
const field = (labelText, node) => h('label', { className: 'krot-field' },
|
|
366
|
+
h('span', { className: 'krot-label' }, labelText), node);
|
|
367
|
+
|
|
368
|
+
const textInput = (value, onChange, placeholder) => h('input', {
|
|
369
|
+
className: 'krot-in',
|
|
110
370
|
value: value ?? '',
|
|
111
371
|
onChange: (e) => onChange(e.target.value),
|
|
112
372
|
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
373
|
});
|
|
123
374
|
|
|
124
|
-
const
|
|
375
|
+
const btn = (labelText, onClick, opts) => h('button', {
|
|
376
|
+
className: 'krot-btn' + (opts && opts.primary ? ' krot-save' : ''),
|
|
125
377
|
onClick,
|
|
126
|
-
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
|
-
},
|
|
378
|
+
disabled: Boolean(opts && opts.disabled),
|
|
379
|
+
title: (opts && opts.title) || undefined,
|
|
138
380
|
}, labelText);
|
|
139
381
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
382
|
+
// Точка состояния ключа: цвет и подпись читаются с одного взгляда,
|
|
383
|
+
// а «ключ не найден» ловит опечатку в имени env, которая иначе молчит.
|
|
384
|
+
const keyStatus = (providerId, ref) => {
|
|
385
|
+
const hit = keyInfo(providerId, ref);
|
|
386
|
+
if (!hit) return null;
|
|
387
|
+
if (!hit.present) return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyMissing') };
|
|
388
|
+
if (hit.cooldownMsLeft > 0) {
|
|
389
|
+
return {
|
|
390
|
+
color: 'var(--dsw-alias-state-warning-primary)',
|
|
391
|
+
text: t('keyCooling').replace('{s}', String(Math.ceil(hit.cooldownMsLeft / 1000))),
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
if (hit.active) return { color: 'var(--dsw-alias-state-success-primary)', text: t('keyActive') };
|
|
395
|
+
return { color: 'var(--dsw-alias-label-tertiary)', text: t('keyReady') };
|
|
149
396
|
};
|
|
150
397
|
|
|
151
398
|
const providerRows = providerList.map((entry, pIndex) => {
|
|
152
399
|
const options = [];
|
|
153
400
|
if (entry.provider && !providerById.has(entry.provider)) {
|
|
154
|
-
options.push(
|
|
401
|
+
options.push(h('option', { key: entry.provider, value: entry.provider }, entry.provider + ' (' + t('notRegistered') + ')'));
|
|
155
402
|
}
|
|
156
|
-
options.push(...providers.map((
|
|
157
|
-
|
|
403
|
+
options.push(...providers.map((prov) =>
|
|
404
|
+
h('option', { key: prov.id, value: prov.id }, prov.name + (prov.id !== prov.name ? ' — ' + prov.id : ''))));
|
|
158
405
|
|
|
159
406
|
const keys = entry.keys ?? [];
|
|
160
|
-
const keyRows = keys.map((key, kIndex) =>
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
407
|
+
const keyRows = keys.map((key, kIndex) => {
|
|
408
|
+
const st = keyStatus(entry.provider, key);
|
|
409
|
+
const info = keyInfo(entry.provider, key);
|
|
410
|
+
const rowKey = entry.provider + '/' + kIndex;
|
|
411
|
+
const typed = secretDraft[rowKey];
|
|
412
|
+
const fromEnv = Boolean(info && info.source === 'env');
|
|
413
|
+
|
|
414
|
+
// Имя ключа занимает свою строку целиком: раньше оно обрезалось и
|
|
415
|
+
// соседние ключи выглядели одинаково.
|
|
416
|
+
const nameRow = [
|
|
417
|
+
h('span', { className: 'krot-num', key: 'n' }, String(kIndex + 1)),
|
|
418
|
+
h('span', { key: 'i', className: 'krot-name', title: key },
|
|
419
|
+
t('keyLabel').replace('{n}', String(kIndex + 1))),
|
|
420
|
+
];
|
|
421
|
+
|
|
422
|
+
const meta = [
|
|
423
|
+
h('span', { key: 'd', className: 'krot-dot', style: { background: st ? st.color : 'var(--dsw-alias-border-l2)' } }),
|
|
424
|
+
h('span', { key: 's', className: 'krot-state' }, st ? st.text : ''),
|
|
425
|
+
];
|
|
426
|
+
if (fromEnv) {
|
|
427
|
+
meta.push(h('span', { key: 'v', className: 'krot-tail', title: t('keyFromEnv') },
|
|
428
|
+
info.tail ? '••••' + info.tail : t('keyFromEnv')));
|
|
429
|
+
} else {
|
|
430
|
+
meta.push(h('input', {
|
|
431
|
+
key: 'v',
|
|
432
|
+
type: 'password',
|
|
433
|
+
className: 'krot-in krot-secret',
|
|
434
|
+
value: typed ?? '',
|
|
435
|
+
placeholder: info && info.tail ? '••••' + info.tail : t('keyValuePlaceholder'),
|
|
436
|
+
onChange: (e) => setSecretDraft((cur) => ({ ...cur, [rowKey]: e.target.value })),
|
|
437
|
+
}));
|
|
438
|
+
if (typed) meta.push(btn('✓', () => saveSecret(key, rowKey), { title: t('keySave'), key: 'w' }));
|
|
439
|
+
}
|
|
440
|
+
meta.push(h('span', { key: 'a', className: 'krot-acts' },
|
|
441
|
+
btn('↑', () => moveKey(pIndex, kIndex, -1), { disabled: kIndex === 0, title: t('moveUp') }),
|
|
442
|
+
btn('↓', () => moveKey(pIndex, kIndex, 1), { disabled: kIndex === keys.length - 1, title: t('moveDown') }),
|
|
443
|
+
btn('✕', () => removeKey(pIndex, kIndex), { title: t('removeKey') }),
|
|
165
444
|
));
|
|
166
445
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
446
|
+
return h('div', { key: kIndex, className: 'krot-key' },
|
|
447
|
+
nameRow,
|
|
448
|
+
h('div', { className: 'krot-meta' }, meta),
|
|
449
|
+
);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
const providerStatus = status[entry.provider];
|
|
453
|
+
const switchesLine = h('p', { className: 'krot-hint' },
|
|
454
|
+
providerStatus && providerStatus.switches > 0
|
|
455
|
+
? t('switchesSome')
|
|
456
|
+
.replace('{n}', String(providerStatus.switches))
|
|
457
|
+
.replace('{reason}', String(providerStatus.lastReason || '—'))
|
|
458
|
+
.replace('{ago}', formatAgo(t, providerStatus.lastSwitchAt))
|
|
459
|
+
: t('switchesNone'));
|
|
460
|
+
|
|
461
|
+
return h('div', { key: pIndex, className: 'krot-prov' },
|
|
462
|
+
h('div', { className: 'krot-prov-head' },
|
|
463
|
+
h('select', { className: 'krot-in', value: entry.provider, onChange: (e) => setProvider(pIndex, e.target.value) }, options),
|
|
464
|
+
btn('✕', () => removeProvider(pIndex), { title: t('removeProvider') }),
|
|
171
465
|
),
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
),
|
|
466
|
+
h('div', { className: 'krot-keys' }, keyRows),
|
|
467
|
+
h('div', { className: 'krot-foot' },
|
|
468
|
+
btn(t('addKey'), () => addKey(pIndex), { title: t('addKeyTitle') }),
|
|
469
|
+
switchesLine,
|
|
177
470
|
),
|
|
178
471
|
);
|
|
179
472
|
});
|
|
180
473
|
|
|
181
474
|
const noProviders = providers.length === 0
|
|
182
|
-
?
|
|
183
|
-
'No providers registered with DSH — nothing to pick from yet.')
|
|
475
|
+
? h('p', { className: 'krot-err' }, t('noProviders'))
|
|
184
476
|
: null;
|
|
185
477
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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 } },
|
|
478
|
+
return h('div', { className: 'krot' },
|
|
479
|
+
h('p', { className: 'krot-hint' }, t('desc')),
|
|
480
|
+
field(t('cooldown'), textInput(String(val.cooldownMs ?? 60000), (v) => setField((cur) => ({ ...cur, cooldownMs: Number(v) || 0 })))),
|
|
481
|
+
field(t('codesTitle'), h('div', { className: 'krot-codes' }, codeList.map((code) => h('label', { key: code, className: 'krot-code' },
|
|
482
|
+
h('input', {
|
|
483
|
+
type: 'checkbox',
|
|
484
|
+
checked: selectedCodes.has(code),
|
|
485
|
+
onChange: (e) => toggleCode(code, e.target.checked),
|
|
486
|
+
}),
|
|
487
|
+
code,
|
|
488
|
+
)))),
|
|
489
|
+
field(t('providersTitle'), h('div', { className: 'krot-keys' },
|
|
206
490
|
providerRows,
|
|
207
|
-
|
|
208
|
-
btn('+ Add provider', addProvider, false),
|
|
209
|
-
noProviders,
|
|
210
|
-
),
|
|
491
|
+
h('div', { className: 'krot-foot' }, btn(t('addProvider'), addProvider, {}), noProviders),
|
|
211
492
|
)),
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
493
|
+
h('p', { className: 'krot-hint' }, t('keyHint')),
|
|
494
|
+
secretError ? h('p', { className: 'krot-err' }, secretError) : null,
|
|
495
|
+
state.error ? h('p', { className: 'krot-err' }, state.error) : null,
|
|
496
|
+
h('div', { className: 'krot-foot' },
|
|
497
|
+
btn(t('save'), save, { primary: true }),
|
|
498
|
+
btn(t('discard'), load, {}),
|
|
499
|
+
state.status === 'saving' ? h('span', { className: 'krot-hint' }, t('saving')) : null,
|
|
217
500
|
),
|
|
218
501
|
);
|
|
219
502
|
}
|
|
220
503
|
|
|
221
504
|
function apply(ctx) {
|
|
505
|
+
ctx.effect(() => ctx.locale.register(NS, { en, ru }), 'dsh-key-rotation: dictionaries');
|
|
506
|
+
function useLocale() {
|
|
507
|
+
return useActiveLocale(ctx);
|
|
508
|
+
}
|
|
222
509
|
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
223
510
|
name: 'settings.section',
|
|
224
511
|
id: 'dsh-key-rotation',
|
|
225
512
|
order: 20,
|
|
226
513
|
label: () => 'Key Rotation',
|
|
227
|
-
}, KeyRotationSection));
|
|
514
|
+
}, (props) => React.createElement(KeyRotationSection, { ...props, locale: useLocale() })));
|
|
228
515
|
}
|
|
229
516
|
|
|
230
|
-
module.exports = { apply, inject: ['slots'] };
|
|
517
|
+
module.exports = { apply, inject: ['slots', 'locale'] };
|
|
231
518
|
return module.exports;
|
|
232
519
|
},
|
|
233
520
|
});
|
package/lib/index.js
CHANGED
|
@@ -41,6 +41,16 @@ export const inject = ['llm', 'webServer', 'settings', 'credentials'];
|
|
|
41
41
|
const NS = 'dsh-key-rotation';
|
|
42
42
|
/** Config bridge route (GET / PUT / DELETE), loopback-fenced like llm-fallback. */
|
|
43
43
|
const CONFIG_PATH = '/dsh-key-rotation/config';
|
|
44
|
+
const STATUS_PATH = '/dsh-key-rotation/status';
|
|
45
|
+
const KEY_PATH = '/dsh-key-rotation/key';
|
|
46
|
+
|
|
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
|
+
}
|
|
44
54
|
/** The llm-pi-ai namespace whose provider profiles map providers to pools. */
|
|
45
55
|
const PIAI_NS = 'llm-pi-ai';
|
|
46
56
|
/** Marker on internally re-dispatched requests so the interceptor does not loop. */
|
|
@@ -294,7 +304,16 @@ export function apply(ctx, config = {}) {
|
|
|
294
304
|
if (refs.length === 0) continue;
|
|
295
305
|
let state = poolState.get(p.provider);
|
|
296
306
|
if (!state) {
|
|
297
|
-
state = {
|
|
307
|
+
state = {
|
|
308
|
+
failedUntil: new Map(),
|
|
309
|
+
pointer: 0,
|
|
310
|
+
lastUsed: undefined,
|
|
311
|
+
// Счётчики для карточки настроек: без них о работе ротации можно было
|
|
312
|
+
// судить только по console.warn на сервере.
|
|
313
|
+
switches: 0,
|
|
314
|
+
lastReason: undefined,
|
|
315
|
+
lastSwitchAt: undefined,
|
|
316
|
+
};
|
|
298
317
|
poolState.set(p.provider, state);
|
|
299
318
|
}
|
|
300
319
|
const pool = { base: p.provider, refs, state };
|
|
@@ -323,6 +342,9 @@ export function apply(ctx, config = {}) {
|
|
|
323
342
|
const credentials = ctx.get('credentials');
|
|
324
343
|
if (credentials && typeof credentials.resolve === 'function' && !credentials.__dshKeyRotationPatched) {
|
|
325
344
|
const original = credentials.resolve.bind(credentials);
|
|
345
|
+
// Kept for the status route: it must ask about one exact ref instead of
|
|
346
|
+
// being rotated to a different key by the patch below.
|
|
347
|
+
credentials.__dshKeyRotationOriginalResolve = original;
|
|
326
348
|
credentials.resolve = async (ref) => {
|
|
327
349
|
const { poolByRef } = buildRuntime();
|
|
328
350
|
const pool = poolByRef.get(ref);
|
|
@@ -392,6 +414,9 @@ export function apply(ctx, config = {}) {
|
|
|
392
414
|
(switchCodes.has(code) || SWITCHABLE_MESSAGE_PATTERN.test(message));
|
|
393
415
|
if (switchable) {
|
|
394
416
|
if (pool.state.lastUsed) pool.state.failedUntil.set(pool.state.lastUsed, Date.now() + cooldownMs);
|
|
417
|
+
pool.state.switches = (pool.state.switches ?? 0) + 1;
|
|
418
|
+
pool.state.lastReason = String(code ?? 'UNKNOWN');
|
|
419
|
+
pool.state.lastSwitchAt = Date.now();
|
|
395
420
|
lastFailure = chunk;
|
|
396
421
|
console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(pool.state.lastUsed ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)}) — next key`);
|
|
397
422
|
switching = true;
|
|
@@ -415,6 +440,138 @@ export function apply(ctx, config = {}) {
|
|
|
415
440
|
})();
|
|
416
441
|
}
|
|
417
442
|
|
|
443
|
+
// ── status route: what the settings card cannot know on its own ──
|
|
444
|
+
//
|
|
445
|
+
// Reports, per configured provider, which key is in use, which are cooling
|
|
446
|
+
// down and until when, whether an env name resolves to a credential at all
|
|
447
|
+
// (a typo is otherwise silent), and how often rotation has fired.
|
|
448
|
+
//
|
|
449
|
+
// Key VALUES never leave the host — only the boolean fact that one exists.
|
|
450
|
+
ctx.effect(() => ctx.webServer.register({
|
|
451
|
+
kind: 'exact',
|
|
452
|
+
path: STATUS_PATH,
|
|
453
|
+
handler: async (req, res) => {
|
|
454
|
+
if (req.method !== 'GET') {
|
|
455
|
+
json(res, 405, { error: { code: 'method', message: 'GET only' } });
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
if (!isTrustedBridgeRequest(req)) {
|
|
459
|
+
json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: status is local-only' } });
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
const { poolByRef } = buildRuntime();
|
|
463
|
+
const base = ctx.get('credentials');
|
|
464
|
+
const now = Date.now();
|
|
465
|
+
const seen = new Set();
|
|
466
|
+
const providers = [];
|
|
467
|
+
for (const pool of poolByRef.values()) {
|
|
468
|
+
if (seen.has(pool.base)) continue;
|
|
469
|
+
seen.add(pool.base);
|
|
470
|
+
const keys = [];
|
|
471
|
+
for (const ref of pool.refs) {
|
|
472
|
+
let present = false;
|
|
473
|
+
let tail = '';
|
|
474
|
+
let source = null;
|
|
475
|
+
let writable = true;
|
|
476
|
+
try {
|
|
477
|
+
// The resolve patch is installed on this same service, so ask for
|
|
478
|
+
// the exact ref: a pool ref would otherwise round-robin to another
|
|
479
|
+
// key and report a missing name as present.
|
|
480
|
+
const hit = await (base?.__dshKeyRotationOriginalResolve ?? base?.resolve)?.call(base, ref);
|
|
481
|
+
present = Boolean(hit && typeof hit.value === 'string' && hit.value.length > 0);
|
|
482
|
+
// Only the last few characters travel to the browser: enough to
|
|
483
|
+
// tell two keys apart in the card, useless for authenticating.
|
|
484
|
+
if (present) tail = keyTail(hit.value);
|
|
485
|
+
} catch {
|
|
486
|
+
present = false;
|
|
487
|
+
}
|
|
488
|
+
try {
|
|
489
|
+
const described = await base?.describe?.(ref);
|
|
490
|
+
source = described?.source ?? null;
|
|
491
|
+
writable = described?.writable !== false;
|
|
492
|
+
} catch {
|
|
493
|
+
/* describe is optional — the card falls back to editable */
|
|
494
|
+
}
|
|
495
|
+
const until = pool.state.failedUntil.get(ref);
|
|
496
|
+
keys.push({
|
|
497
|
+
ref,
|
|
498
|
+
present,
|
|
499
|
+
tail,
|
|
500
|
+
source,
|
|
501
|
+
writable,
|
|
502
|
+
active: pool.state.lastUsed === ref,
|
|
503
|
+
cooldownMsLeft: until !== undefined && until > now ? until - now : 0,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
providers.push({
|
|
507
|
+
provider: pool.base,
|
|
508
|
+
keys,
|
|
509
|
+
switches: pool.state.switches ?? 0,
|
|
510
|
+
lastReason: pool.state.lastReason ?? null,
|
|
511
|
+
lastSwitchAt: pool.state.lastSwitchAt ?? null,
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
json(res, 200, { providers });
|
|
515
|
+
},
|
|
516
|
+
}), 'dsh-key-rotation: status route');
|
|
517
|
+
|
|
518
|
+
// ── key route: store a key value without leaving the rotation card ──
|
|
519
|
+
//
|
|
520
|
+
// Adding a key used to mean two screens: create the credential elsewhere,
|
|
521
|
+
// then type its env name here. The value is write-only from the browser —
|
|
522
|
+
// it is never sent back, only its last few characters are (see the status
|
|
523
|
+
// route) — and the route is loopback- and same-origin-gated like the config
|
|
524
|
+
// bridge next to it.
|
|
525
|
+
ctx.effect(() => ctx.webServer.register({
|
|
526
|
+
kind: 'exact',
|
|
527
|
+
path: KEY_PATH,
|
|
528
|
+
handler: async (req, res) => {
|
|
529
|
+
if (req.method !== 'PUT' && req.method !== 'DELETE') {
|
|
530
|
+
json(res, 405, { error: { code: 'method', message: 'PUT or DELETE only' } });
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
if (!isTrustedBridgeRequest(req)) {
|
|
534
|
+
json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: keys are local-only' } });
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
const credentialsService = ctx.get('credentials');
|
|
538
|
+
if (!credentialsService || typeof credentialsService.set !== 'function') {
|
|
539
|
+
json(res, 503, { error: { code: 'no-credentials', message: 'dsh-key-rotation: no credentials service is mounted' } });
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
let body;
|
|
543
|
+
try {
|
|
544
|
+
body = await readJson(req);
|
|
545
|
+
} catch (error) {
|
|
546
|
+
json(res, 400, { error: { code: 'bad-request', message: String(error?.message ?? error) } });
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
const ref = typeof body?.ref === 'string' ? body.ref.trim() : '';
|
|
550
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(ref)) {
|
|
551
|
+
json(res, 400, { error: { code: 'bad-ref', message: 'dsh-key-rotation: ref must be an environment variable name' } });
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
try {
|
|
555
|
+
if (req.method === 'DELETE') {
|
|
556
|
+
await credentialsService.unset(ref);
|
|
557
|
+
json(res, 200, { ok: true, ref });
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
const value = typeof body?.value === 'string' ? body.value.trim() : '';
|
|
561
|
+
if (value.length === 0) {
|
|
562
|
+
json(res, 400, { error: { code: 'empty-value', message: 'dsh-key-rotation: an empty key cannot be stored' } });
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
await credentialsService.set(ref, value);
|
|
566
|
+
json(res, 200, { ok: true, ref, tail: keyTail(value) });
|
|
567
|
+
} catch (error) {
|
|
568
|
+
// A ref supplied by the launching environment is read-only, and the
|
|
569
|
+
// service says so in plain words — pass that through to the card.
|
|
570
|
+
json(res, 409, { error: { code: 'write-rejected', message: String(error?.message ?? error) } });
|
|
571
|
+
}
|
|
572
|
+
},
|
|
573
|
+
}), 'dsh-key-rotation: key route');
|
|
574
|
+
|
|
418
575
|
// Intercept the llm/stream waterfall: rotate any request whose provider maps
|
|
419
576
|
// to a configured key pool; pass everything else (and internal dispatches)
|
|
420
577
|
// straight through.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-key-rotation",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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",
|