@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 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
- - **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.
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 edit key pools, switch codes and cooldown without touching config files by hand.
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 / GitHub after publishing:
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 actual values live in the dsh **Credentials** service (Web: **Settings → Credentials**) or `$DSH_HOME/.credentials.yaml` — never in the plugin config.
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
@@ -4,4 +4,4 @@
4
4
  # them on switchable failures (QUOTA / RATE_LIMIT / ...).
5
5
  - insert:
6
6
  - id: dsh-key-rotation
7
- name: 'dsh-key-rotation'
7
+ name: '@goodandready/dsh-key-rotation'
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 } }, 'Loading…');
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 next = [...(Array.isArray(cur.providers) ? cur.providers : [])];
68
- next[pIndex] = { ...next[pIndex], keys: [...(next[pIndex].keys ?? []), ''] };
69
- return { ...cur, providers: next };
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 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);
363
+ const h = React.createElement;
108
364
 
109
- const textInput = (value, onChange, placeholder) => React.createElement('input', {
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 controlBtn = (labelText, onClick, disabled, title) => React.createElement('button', {
375
+ const btn = (labelText, onClick, opts) => h('button', {
376
+ className: 'krot-btn' + (opts && opts.primary ? ' krot-save' : ''),
125
377
  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
- },
378
+ disabled: Boolean(opts && opts.disabled),
379
+ title: (opts && opts.title) || undefined,
138
380
  }, labelText);
139
381
 
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',
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(React.createElement('option', { key: entry.provider, value: entry.provider }, `${entry.provider} (not registered)`));
401
+ options.push(h('option', { key: entry.provider, value: entry.provider }, entry.provider + ' (' + t('notRegistered') + ')'));
155
402
  }
156
- options.push(...providers.map((p) =>
157
- React.createElement('option', { key: p.id, value: p.id }, `${p.name}${p.id !== p.name ? ' — ' + p.id : ''}`)));
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
- 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'),
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
- 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'),
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
- 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
- ),
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
- ? 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.')
475
+ ? h('p', { className: 'krot-err' }, t('noProviders'))
184
476
  : null;
185
477
 
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 } },
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
- React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', marginTop: 2 } },
208
- btn('+ Add provider', addProvider, false),
209
- noProviders,
210
- ),
491
+ h('div', { className: 'krot-foot' }, btn(t('addProvider'), addProvider, {}), noProviders),
211
492
  )),
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,
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 = { failedUntil: new Map(), pointer: 0, lastUsed: undefined };
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.4.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",