@goodandready/dsh-key-rotation 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -45,7 +45,7 @@
45
45
  - **🕒 Accurate Midnight PST Resets**: Corrected UTC-8 timezone calculation offset sign for calendar quota reset windows.
46
46
  - **🧹 Lifecycle Timer Cleanup**: Wrapped `canaryTimer` and `selfHealTimer` in Cordis effect scopes, eliminating background orphaned intervals on hot reload.
47
47
  - **⚡ Stale Lock Recovery in Load Balancer**: Added expired lock detection to `pickLeastLoaded` for uninterrupted least-connections routing.
48
- - **🌐 Full Chinese Localization**: Added complete `zh` locale dictionary to the React settings dashboard for comprehensive 3-language parity.
48
+ - **🌐 Localization (Changed in v0.8.2)**: Source strings are English-only. Russian/Chinese UI comes from the DSH core locale service and translation plugins (`props.t`). Active locale: host snapshot first `navigator.languages` entry `en` (#277).
49
49
 
50
50
 
51
51
  ### 🚀 What's New in v0.7.31
@@ -301,5 +301,5 @@ MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
301
301
  - **Lifecycle Cleanups**: Wrapped `credentials.resolve` patch and `ctx.on` event handlers (`llm/stream`, `agent/request-error`) in `ctx.effect` scopes with guaranteed unmount cleanup (#238, #239).
302
302
  - **Settings & Secret Roles**: Added `.role('secret')` to `incidentGitHubToken` and `webhookActionToken` in `Config` schema for automatic UI masking (#237).
303
303
  - **Settings Architecture & UI**: Added native `settingsScope` snapshot reading/saving in settings card with graceful bridge fallback (#235).
304
- - **Localization**: Localized `settings.section` sidebar fallback label via `t('title')` and registered `zh` dictionary in `ctx.locale` alongside `en` and `ru` (#236).
304
+ - **Localization (Changed in v0.8.2)**: Card uses `props.t` with `locale: NS`; plugin registers only `en`; no `settings.section` fallback and no bundled `ru`/`zh` tables (#236, #275, #277).
305
305
  - **Dead Code Purge**: Removed obsolete `mountDashboard` routine after header-chip migration (#240).
package/README.ru.md CHANGED
@@ -295,7 +295,7 @@ MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
295
295
  - **Очистка жизненного цикла**: Патч `credentials.resolve` и слушатели событий `ctx.on` (`llm/stream`, `agent/request-error`) переведены в скоупы `ctx.effect` с автоматическим восстановлением функций и отпиской при выгрузке плагина (#238, #239).
296
296
  - **Роли секретов в схеме**: Полям `incidentGitHubToken` и `webhookActionToken` в схеме `Config` присвоена роль `.role('secret')` для маскирования в UI (#237).
297
297
  - **Архитектура настроек**: Добавлена нативная интеграция со снимками `settingsScope` в карточке настроек с безопасным фоллбеком на HTTP-мост (#235).
298
- - **Локализация**: Фоллбек секции настроек `settings.section` переведён на локализованную метку `t('title')` со слотом `locale: NS`, а китайский словарь `zh` зарегистрирован в `ctx.locale` наряду с `en` и `ru` (#236).
298
+ - **Локализация (Changed in v0.8.2)**: Исходные строки только на английском (`en`). Русский и китайский приходят через core `props.t` / translation-плагины. Фолбек активной локали: snapshot первый `navigator.languages` `en`. Свой `settings.section` и встроенные `ru`/`zh`-таблицы удалены (#236, #275, #277).
299
299
  - **Удаление мёртвого кода**: Удалена неиспользуемая функция `mountDashboard` после перехода на header chip (#240).
300
300
 
301
301
  ### Параметры circuit breaker (Changed in v0.8.0)
package/README.zh.md CHANGED
@@ -259,7 +259,7 @@ MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
259
259
  - **生命周期清理**: 将 `credentials.resolve` 猴子补丁和 `ctx.on` 事件监听器 (`llm/stream`, `agent/request-error`) 封装在 `ctx.effect` 作用域内,确保卸载时自动注销并恢复原始方法 (#238, #239)。
260
260
  - **配置密钥角色**: 在 `Config` Schema 中为 `incidentGitHubToken` 和 `webhookActionToken` 增加 `.role('secret')`,避免明文泄露并在 UI 中掩码显示 (#237)。
261
261
  - **设置架构与状态**: 在设置卡片中增加原生 `settingsScope` 绑定支持,保留 HTTP 桥接安全回退机制 (#235)。
262
- - **本地化与文案**: 侧边栏备用项 `settings.section` 标签支持本地化 `t('title')` 并配置 `locale: NS`,并在 `ctx.locale` 中注册 `zh` 中文字典 (#236)。
262
+ - **本地化与文案 (Changed in v0.8.2)**: 插件仅注册英文源字符串 `en`;俄文/中文由 DSH 核心 locale 与 translation 插件通过 `props.t` 提供。活动语言回退:snapshot → 首个 `navigator.languages` `en`。已移除 `settings.section` 回退与内置 `ru`/`zh` (#236, #275, #277)。
263
263
  - **死代码清理**: 移除 header-chip 迁移后残留的废弃 `mountDashboard` 函数 (#240)。
264
264
 
265
265
 
package/lib/client.js CHANGED
@@ -1,4 +1,4 @@
1
- // dsh-key-rotation — Settings section ("Key Rotation" / "Ротация ключей").
1
+ // dsh-key-rotation — Settings card ("Key Rotation" / "Ротация ключей").
2
2
  // Renders in Settings → Plugins → Plugin settings via the settings.plugin.item 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.
@@ -9,11 +9,10 @@
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
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).
12
+ // Localization: source strings are English only (ctx.locale.register(NS, { en })).
13
+ // Other languages come from the DSH core locale service / translation plugins
14
+ // via props.t (slot locale: NS). Active locale prefers ctx.locale.getSnapshot().active,
15
+ // else first navigator.languages entry, else 'en' same fallback chain as DSH core.
17
16
  window.__ModuleLoader__.load({
18
17
  id: '@goodandready/dsh-key-rotation',
19
18
  factory: (require) => {
@@ -94,152 +93,43 @@ window.__ModuleLoader__.load({
94
93
  poolExhausted: 'pool exhausted — all keys cooling',
95
94
  resetting: 'Resetting…',
96
95
  keyLabel: 'Key {n}',
96
+ undoProvider: 'Provider removed',
97
+ undoKey: 'Key removed',
98
+ undo: 'Undo',
97
99
  };
98
- const ru = {
99
- title: 'Ротация ключей',
100
- filterAll: 'Все',
101
- filterReady: 'Готовы',
102
- filterCooldown: 'В кулдауне',
103
- filterErrors: 'С ошибками',
104
- subtitle: 'Ротация API-ключей по провайдерам: пулы ключей, автоматическое переключение при исчерпании квоты или лимита, кулдаун и восстановление.',
105
- cardDesc: 'Ротация API-ключей по провайдерам: пулы ключей, автоматическое переключение при исчерпании квоты или лимита, кулдаун и восстановление.',
106
- loading: 'Загрузка…',
107
- notRegistered: 'не зарегистрирован',
108
- removeKey: 'Удалить ключ',
109
- removeProvider: 'Удалить провайдера',
110
- addKey: '+ Добавить ключ',
111
- addKeyTitle: 'Добавить API-ключ',
112
- noProviders: 'Провайдеры ещё не зарегистрированы в DSH — выбирать не из чего.',
113
- addProvider: '+ Добавить провайдера',
114
- desc: 'Ротация API-ключей по провайдерам. Для каждого провайдера укажите его API-ключи (имена env, хранятся в учётных данных DSH). Плагин ведёт модель по ключам провайдера по порядку и переключается на следующий при исчерпании квоты/превышении лимита.',
115
- cooldown: 'Задержка после сбоя (мс)',
116
- scheduleDays: 'Расписание ротации (дней, 0=выкл)',
117
- switchCodes: 'Коды переключения (через запятую)',
118
- providersTitle: 'Провайдеры и их ключи',
119
- save: 'Сохранить',
120
- discard: 'Отменить',
121
- saving: 'Сохранение…',
122
- moveUp: 'Выше',
123
- moveDown: 'Ниже',
124
- keyActive: 'используется',
125
- keyReady: 'готов',
126
- keyCooling: 'остывает, {s}с',
127
- keyMissing: 'ключ не найден',
128
- switchesNone: 'переключений не было',
129
- switchesSome: 'переключений: {n} · последнее: {reason}, {ago}',
130
- justNow: 'только что',
131
- minutesAgo: '{n} мин назад',
132
- hoursAgo: '{n} ч назад',
133
- codesTitle: 'Переключаться при этих сбоях',
134
- keyValuePlaceholder: 'вставьте ключ и нажмите «Сохранить»',
135
- keySave: 'Сохранить ключ',
136
- keySaved: 'сохранён',
137
- keyFromEnv: 'задан в окружении, отсюда не меняется',
138
- keyWriteFailed: 'не удалось сохранить ключ: {msg}',
139
- notSecretShape: 'сохранено, но значение не похоже на API-ключ — проверьте опечатки',
140
- rpmTitle: 'запросов/мин: {u} использовано, {r} осталось',
141
- budgetLabel: 'бюджет:',
142
- exportCsv: 'CSV',
143
- weightHint: 'вес в круге: сколько раз ключ участвует в ротации (1 = поровну)',
144
- retestBroken: 'Re-test',
145
- retestFail: 'перепроверка не прошла — ключ всё ещё недоступен',
146
- snapshotExport: 'Снапшот ⬇',
147
- snapshotImport: 'Восстановить',
148
- keyHint: 'Значение хранится в учётных данных DSH и обратно в браузер не отдаётся — показываются только последние 5 символов. Имена переменных создаются автоматически; наведите на ключ, чтобы увидеть используемое имя.',
149
- brokenKey: 'сломан (3× AUTH)',
150
- keyExpired: 'истёк',
151
- keyExpiringSoon: 'истекает через {n} д',
152
- exportPools: 'Экспорт',
153
- exportOne: '⬇',
154
- usedAgo: '{ago} назад',
155
- importPools: 'Импорт',
156
- importEnv: 'Импорт .env',
157
- resetCooldown: 'Сбросить кулдаун',
158
- testAll: 'Тест всех ключей',
159
- testing: 'Тестирование…',
160
- testKey: 'Тест',
161
- testOk: 'OK',
162
- testFail: 'FAIL',
163
- poolExhausted: 'пул исчерпан — все ключи остывают',
164
- resetting: 'Сброс…',
165
- keyLabel: 'Ключ {n}',
166
- };
167
- const zh = {
168
- title: '密钥轮换',
169
- filterAll: '全部',
170
- filterReady: '就绪',
171
- filterCooldown: '冷却中',
172
- filterErrors: '故障',
173
- subtitle: '按提供商轮换 API 密钥:密钥池管理、配额/限流自动故障转移、平滑冷却与自愈。',
174
- cardDesc: '按提供商轮换 API 密钥:密钥池管理、配额/限流自动故障转移、平滑冷却与自愈。',
175
- loading: '加载中…',
176
- notRegistered: '未注册',
177
- removeKey: '删除密钥',
178
- removeProvider: '删除提供商',
179
- addKey: '+ 添加密钥',
180
- addKeyTitle: '添加 API 密钥',
181
- noProviders: 'DSH 尚未注册任何提供商 — 暂无可选项目。',
182
- addProvider: '+ 添加提供商',
183
- desc: '按提供商轮换 API 密钥。为每个提供商配置其 API 密钥(环境变量名,存储于 DSH 凭据中)。插件按序调度请求,遇配额或速率超限自动切换至下一密钥。',
184
- cooldown: '故障后冷却时间 (毫秒)',
185
- scheduleDays: '轮换排期 (天数,0=关闭)',
186
- switchCodes: '切换触发错误码 (逗号分隔)',
187
- providersTitle: '提供商与密钥池',
188
- save: '保存',
189
- discard: '放弃',
190
- saving: '保存中…',
191
- moveUp: '上移',
192
- moveDown: '下移',
193
- keyActive: '正在使用',
194
- keyReady: '就绪',
195
- keyCooling: '冷却中,{s}秒',
196
- keyMissing: '凭据未找到',
197
- switchesNone: '暂无切换',
198
- switchesSome: '已切换:{n} 次 · 最近:{reason},{ago}',
199
- justNow: '刚刚',
200
- minutesAgo: '{n} 分钟前',
201
- hoursAgo: '{n} 小时前',
202
- codesTitle: '遇以下故障自动切换',
203
- keyValuePlaceholder: '粘贴密钥,然后点击保存',
204
- keySave: '保存密钥',
205
- keySaved: '已保存',
206
- keyFromEnv: '来自环境变量,此处只读',
207
- keyWriteFailed: '无法保存密钥:{msg}',
208
- notSecretShape: '已保存,但格式不像常见 API 密钥 — 请检查是否有拼写错误',
209
- rpmTitle: '请求数/分钟:已用 {u},剩余 {r}',
210
- budgetLabel: '预算:',
211
- exportCsv: '导出 CSV',
212
- weightHint: '轮询权重:该密钥参与循环的轮次(1 = 均等分担)',
213
- retestBroken: '重新测试',
214
- retestFail: '重试失败 — 密钥仍不可用',
215
- snapshotExport: '快照 ⬇',
216
- snapshotImport: '恢复快照',
217
- keyHint: '密钥安全保存在 DSH 凭据中,绝不返回浏览器 — 界面仅展示末尾 5 位字符。变量名自动生成,鼠标悬停可查看。',
218
- brokenKey: '已损坏 (连续 3 次鉴权失败)',
219
- keyExpired: '已过期',
220
- keyExpiringSoon: '{n} 天后过期',
221
- exportPools: '导出',
222
- exportOne: '⬇',
223
- usedAgo: '{ago}',
224
- importPools: '导入',
225
- importEnv: '导入 .env',
226
- resetCooldown: '重置冷却',
227
- testAll: '测试全部密钥',
228
- testing: '测试中…',
229
- testKey: '测试',
230
- testOk: '正常',
231
- testFail: '失败',
232
- poolExhausted: '密钥池耗尽 — 所有密钥均处于冷却状态',
233
- resetting: '重置中…',
234
- keyLabel: '密钥 {n}',
235
- };
236
-
237
100
 
238
101
  // Коды, на которых имеет смысл переключать ключ. Список из хоста
239
102
  // (DEFAULT_SWITCH_CODES); конфиг может содержать и свои — они показываются
240
103
  // отдельными отмеченными галочками, чтобы правило нельзя было потерять.
241
104
  const KNOWN_CODES = ['QUOTA', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT', 'EMPTY_RESPONSE', 'UNKNOWN_MODEL', 'AUTH'];
242
105
 
106
+ // #273: keep the settings list item mounted if the section throws.
107
+ class KeyRotationErrorBoundary extends React.Component {
108
+ constructor(props) {
109
+ super(props);
110
+ this.state = { error: null };
111
+ }
112
+ static getDerivedStateFromError(error) {
113
+ return { error };
114
+ }
115
+ componentDidCatch(error, info) {
116
+ try { console.error('[dsh-key-rotation] settings card crashed', error, info); } catch (_) {}
117
+ }
118
+ render() {
119
+ if (this.state.error) {
120
+ return React.createElement('div', { className: 'krot krot-err', style: { padding: 12 } },
121
+ React.createElement('p', null, 'Key Rotation settings failed to render.'),
122
+ React.createElement('p', { style: { fontSize: 11, opacity: 0.8 } }, String(this.state.error?.message || this.state.error)),
123
+ React.createElement('button', {
124
+ type: 'button',
125
+ className: 'krot-btn',
126
+ onClick: () => this.setState({ error: null }),
127
+ }, 'Retry'));
128
+ }
129
+ return this.props.children;
130
+ }
131
+ }
132
+
243
133
  /** Опрос статуса ротации, пока раздел настроек открыт (smart polling). */
244
134
  function useRotationStatus() {
245
135
  const [byProvider, setByProvider] = React.useState({});
@@ -392,7 +282,17 @@ window.__ModuleLoader__.load({
392
282
  const active = ctx.locale.getSnapshot().active;
393
283
  if (typeof active === 'string' && active) return active;
394
284
  }
395
- return typeof navigator !== 'undefined' ? String(navigator.language || '').slice(0, 2) : '';
285
+ // Match DSH core: first entry of navigator.languages, else en.
286
+ if (typeof navigator !== 'undefined') {
287
+ const langs = (navigator.languages && navigator.languages.length)
288
+ ? Array.from(navigator.languages)
289
+ : (navigator.language ? [navigator.language] : []);
290
+ for (const lang of langs) {
291
+ const code = String(lang || '').slice(0, 2).toLowerCase();
292
+ if (code) return code;
293
+ }
294
+ }
295
+ return 'en';
396
296
  }, [ctx])
397
297
  );
398
298
  }
@@ -401,9 +301,18 @@ window.__ModuleLoader__.load({
401
301
  return (key) => (DICT && DICT[key]) || (fallbackKeys && fallbackKeys[key]) || key;
402
302
  }
403
303
 
304
+ // Prefer the host slot translator (props.t + translation plugins); fall back to en source.
305
+ function resolveT(props) {
306
+ const coreT = props && typeof props.t === 'function' ? props.t : null;
307
+ if (!coreT) return makeT(en, en);
308
+ return (key) => {
309
+ const v = coreT(key);
310
+ return (v && v !== key) ? v : (en[key] || key);
311
+ };
312
+ }
313
+
404
314
  function KeyRotationSection(props) {
405
- const DICT = props.locale === 'ru' ? ru : (props.locale === 'zh' ? zh : en);
406
- const t = makeT(DICT, en);
315
+ const t = resolveT(props);
407
316
  const settingsScope = React.useMemo(() => {
408
317
  if (props.ctx && props.ctx.settingsScope) {
409
318
  try {
@@ -414,9 +323,31 @@ window.__ModuleLoader__.load({
414
323
  }
415
324
  return null;
416
325
  }, [props.ctx]);
326
+ // #273: getSnapshot must be referentially stable or React 18 unmounts the tree
327
+ const scopeCacheRef = React.useRef({ has: false, value: null });
328
+ const getScopeSnapshot = React.useCallback(() => {
329
+ if (!settingsScope || typeof settingsScope.getSnapshot !== 'function') return null;
330
+ let next = null;
331
+ try {
332
+ next = settingsScope.getSnapshot();
333
+ } catch (_) {
334
+ return null;
335
+ }
336
+ const prev = scopeCacheRef.current;
337
+ if (prev.has && Object.is(prev.value, next)) return prev.value;
338
+ // shallow-compare common snapshot shape to avoid identity thrash
339
+ if (prev.has && prev.value && next
340
+ && prev.value.status === next.status
341
+ && prev.value.revision === next.revision
342
+ && prev.value.value === next.value) {
343
+ return prev.value;
344
+ }
345
+ scopeCacheRef.current = { has: true, value: next };
346
+ return next;
347
+ }, [settingsScope]);
417
348
  const scopeSnapshot = React.useSyncExternalStore(
418
- React.useMemo(() => (cb) => (settingsScope ? settingsScope.subscribe(cb) : () => {}), [settingsScope]),
419
- React.useCallback(() => (settingsScope ? settingsScope.getSnapshot() : null), [settingsScope])
349
+ React.useMemo(() => (cb) => (settingsScope && typeof settingsScope.subscribe === 'function' ? settingsScope.subscribe(cb) : () => {}), [settingsScope]),
350
+ getScopeSnapshot
420
351
  );
421
352
  const [state, setState] = React.useState({ status: 'loading', value: null, revision: 0, error: '', providers: [] });
422
353
  const [draft, setDraft] = React.useState(null);
@@ -799,10 +730,10 @@ window.__ModuleLoader__.load({
799
730
  });
800
731
 
801
732
  const filterBar = keys.length > 1 ? h('div', { className: 'krot-filter-bar' },
802
- h('button', { type: 'button', className: 'krot-pill' + (statusFilter === 'all' ? ' krot-pill-active' : ''), onClick: () => setStatusFilter('all') }, (t('filterAll') || 'Все') + ' (' + keys.length + ')'),
803
- h('button', { type: 'button', className: 'krot-pill' + (statusFilter === 'ready' ? ' krot-pill-active' : ''), onClick: () => setStatusFilter('ready') }, (t('filterReady') || 'Готовы') + ' (' + readyCount + ')'),
804
- cooldownCount > 0 ? h('button', { type: 'button', className: 'krot-pill krot-pill-warn' + (statusFilter === 'cooldown' ? ' krot-pill-active' : ''), onClick: () => setStatusFilter('cooldown') }, (t('filterCooldown') || 'В кулдауне') + ' (' + cooldownCount + ')') : null,
805
- errorCount > 0 ? h('button', { type: 'button', className: 'krot-pill krot-pill-err' + (statusFilter === 'error' ? ' krot-pill-active' : ''), onClick: () => setStatusFilter('error') }, (t('filterErrors') || 'С ошибками') + ' (' + errorCount + ')') : null,
733
+ h('button', { type: 'button', className: 'krot-pill' + (statusFilter === 'all' ? ' krot-pill-active' : ''), onClick: () => setStatusFilter('all') }, t('filterAll') + ' (' + keys.length + ')'),
734
+ h('button', { type: 'button', className: 'krot-pill' + (statusFilter === 'ready' ? ' krot-pill-active' : ''), onClick: () => setStatusFilter('ready') }, t('filterReady') + ' (' + readyCount + ')'),
735
+ cooldownCount > 0 ? h('button', { type: 'button', className: 'krot-pill krot-pill-warn' + (statusFilter === 'cooldown' ? ' krot-pill-active' : ''), onClick: () => setStatusFilter('cooldown') }, t('filterCooldown') + ' (' + cooldownCount + ')') : null,
736
+ errorCount > 0 ? h('button', { type: 'button', className: 'krot-pill krot-pill-err' + (statusFilter === 'error' ? ' krot-pill-active' : ''), onClick: () => setStatusFilter('error') }, t('filterErrors') + ' (' + errorCount + ')') : null,
806
737
  ) : null;
807
738
 
808
739
  const keyRows = filteredIndices.map(({ key, kIndex }) => {
@@ -1068,7 +999,7 @@ window.__ModuleLoader__.load({
1068
999
  h('p', { className: 'krot-hint' }, t('keyHint')),
1069
1000
  secretError ? h('p', { className: 'krot-err' }, secretError) : null,
1070
1001
  state.error ? h('p', { className: 'krot-err' }, state.error) : null,
1071
- undo ? h('div', { className: 'krot-foot' }, h('span', { className: 'krot-hint' }, undo.type === 'provider' ? 'Удалён провайдер' : 'Удалён ключ'), btn('Undo', doUndo, {})) : null,
1002
+ undo ? h('div', { className: 'krot-foot' }, h('span', { className: 'krot-hint' }, undo.type === 'provider' ? t('undoProvider') : t('undoKey')), btn(t('undo'), doUndo, {})) : null,
1072
1003
  h('div', { className: 'krot-foot' },
1073
1004
  btn(t('save'), save, { primary: true }),
1074
1005
  btn(t('discard'), load, {}),
@@ -1128,7 +1059,7 @@ window.__ModuleLoader__.load({
1128
1059
  h('button', {
1129
1060
  type: 'button',
1130
1061
  className: 'krot-header-chip',
1131
- title: 'Ротация ключей / Key Rotation Pools',
1062
+ title: 'Key Rotation',
1132
1063
  onClick: () => setOpen((v) => !v),
1133
1064
  },
1134
1065
  h('span', { style: { width: '8px', height: '8px', borderRadius: '50%', background: color, flex: 'none', boxShadow: `0 0 6px ${color}` } }),
@@ -1136,8 +1067,8 @@ window.__ModuleLoader__.load({
1136
1067
  h('span', { style: { fontSize: '9px', opacity: 0.6 } }, open ? '▲' : '▼')
1137
1068
  ),
1138
1069
  open ? h('div', { className: 'krot-popover' },
1139
- h('div', { className: 'krot-pop-title' }, 'Пулы ротации ключей'),
1140
- !poolEntries.length ? h('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-tertiary)' } }, 'Нет активных пулов') : null,
1070
+ h('div', { className: 'krot-pop-title' }, 'Key rotation pools'),
1071
+ !poolEntries.length ? h('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-tertiary)' } }, 'No active pools') : null,
1141
1072
  poolEntries.map(([name, p]) => {
1142
1073
  const c = p.exhausted ? '#e5484d' : (p.healthy < p.total ? '#f5a623' : '#30a46c');
1143
1074
  return h('div', { key: name, className: 'krot-pop-row' },
@@ -1153,7 +1084,7 @@ window.__ModuleLoader__.load({
1153
1084
  }
1154
1085
 
1155
1086
  function apply(ctx) {
1156
- ctx.effect(() => ctx.locale.register(NS, { en, ru, zh }), 'dsh-key-rotation: dictionaries');
1087
+ ctx.effect(() => ctx.locale.register(NS, { en }), 'dsh-key-rotation: dictionaries');
1157
1088
  // Header chip (#201): status dot in the session header utilities slot,
1158
1089
  // same slot dsh-gitea / dsh-subscriptions use for their header widgets.
1159
1090
  ctx.effect(() => {
@@ -1175,7 +1106,7 @@ window.__ModuleLoader__.load({
1175
1106
  // key MUST equal the settings namespace (NS), else the tab silently skips it.
1176
1107
  function KeyRotationCard(props) {
1177
1108
  const locale = useLocale();
1178
- const t = makeT(locale === 'ru' ? ru : (locale === 'zh' ? zh : en), en);
1109
+ const t = resolveT(props);
1179
1110
  const [open, setOpen] = React.useState(false);
1180
1111
  return h('div', { className: 'krot-card' + (open ? ' krot-card-open' : '') },
1181
1112
  h('button', { type: 'button', className: 'krot-card-header', 'aria-expanded': open, onClick: () => setOpen((v) => !v) },
@@ -1185,32 +1116,18 @@ window.__ModuleLoader__.load({
1185
1116
  h('span', { className: 'krot-card-chevron' + (open ? ' krot-card-chevron-open' : ''), 'aria-hidden': 'true' },
1186
1117
  h('svg', { width: 14, height: 14, viewBox: '0 0 14 14', fill: 'none', stroke: 'currentColor', strokeWidth: 1.5, style: { display: 'block' } },
1187
1118
  h('path', { d: 'M3.5 5.25L7 8.75L10.5 5.25' })))),
1188
- open ? h('div', { className: 'krot-card-body' }, h(KeyRotationSection, { ...props, locale })) : null);
1119
+ open ? h('div', { className: 'krot-card-body' }, h(KeyRotationErrorBoundary, null, h(KeyRotationSection, { ...props, locale }))) : null);
1189
1120
  }
1190
- const tryPluginItem = () => {
1191
- try {
1192
- ctx.slots.inject('settings.plugin.item', () =>
1193
- ctx.slots.register(
1194
- { name: 'settings.plugin.item', key: NS, locale: NS, inject: () => ({ ctx }) },
1195
- KeyRotationCard,
1196
- ));
1197
- return true;
1198
- } catch { return false; }
1199
- };
1200
- if (!tryPluginItem()) {
1201
- // Fallback for builds without the Plugins tab slot: keep the sidebar section.
1202
- const t = ctx.locale ? ctx.locale.bind(NS) : null;
1203
- ctx.slots.inject('settings.section', () => ctx.slots.register(
1204
- {
1205
- name: 'settings.section',
1206
- id: 'dsh-key-rotation',
1207
- order: 20,
1208
- locale: NS,
1209
- label: () => (t ? t('title') : 'Key Rotation') || 'Key Rotation',
1210
- inject: () => ({ ctx }),
1211
- },
1212
- (props) => h(KeyRotationSection, { ...props, locale: useLocale() }),
1213
- ));
1121
+ // #275: card only no top-level sidebar row. The host sidebar is a
1122
+ // shared flat list; a duplicate row wastes a slot and confuses Settings.
1123
+ try {
1124
+ ctx.slots.inject('settings.plugin.item', () =>
1125
+ ctx.slots.register(
1126
+ { name: 'settings.plugin.item', key: NS, locale: NS, inject: () => ({ ctx }) },
1127
+ KeyRotationCard,
1128
+ ));
1129
+ } catch (e) {
1130
+ try { console.error('[dsh-key-rotation] settings.plugin.item register failed', e); } catch (_) {}
1214
1131
  }
1215
1132
  }
1216
1133
 
@@ -69,7 +69,8 @@ export async function writeSection(ctx, ns, section, expectedRevision, res) {
69
69
  export function providerCatalog(ctx, cloneIds) {
70
70
  const seen = new Set();
71
71
  const out = [];
72
- for (const info of ctx.llm.listProviders()) {
72
+ const llm = ctx.get('llm');
73
+ for (const info of (llm?.listProviders?.() ?? [])) {
73
74
  if (seen.has(info.id) || cloneIds.has(info.id)) continue;
74
75
  seen.add(info.id);
75
76
  out.push({ id: info.id, name: info.name ?? info.id });
package/lib/index.js CHANGED
@@ -794,7 +794,12 @@ export function apply(ctx, config = {}) {
794
794
  }), 'dsh-key-rotation: agent/request-error');
795
795
 
796
796
  ctx.inject(['settings'], (sctx) => {
797
- const scope = sctx.settings.register(NS, Config, { base: config });
797
+ const settingsSvc = sctx.get('settings');
798
+ if (!settingsSvc || typeof settingsSvc.register !== 'function') {
799
+ console.warn('[dsh-key-rotation] settings service unavailable — config scope not registered');
800
+ return;
801
+ }
802
+ const scope = settingsSvc.register(NS, Config, { base: config });
798
803
  getConfig = () => scope.get() ?? config;
799
804
  sctx.effect(() => () => {
800
805
  getConfig = () => config;
package/lib/rotate.js CHANGED
@@ -98,7 +98,13 @@ export function createRotate(deps) {
98
98
  let inner;
99
99
  try {
100
100
  // mark the internal dispatch so the interceptor does not re-rotate
101
- inner = dispatchStorage.run(reqStore, () => ctx.llm.stream({ ...options, [MARKER]: true }));
101
+ inner = dispatchStorage.run(reqStore, () => {
102
+ const llm = ctx.get('llm');
103
+ if (!llm || typeof llm.stream !== 'function') {
104
+ throw new Error('dsh-key-rotation: llm service unavailable');
105
+ }
106
+ return llm.stream({ ...options, [MARKER]: true });
107
+ });
102
108
  } catch (e) {
103
109
  const curRef = reqStore.pickedRef ?? pool.state.lastUsed;
104
110
  penalizeRef(curRef, e?.code ?? 'TRANSPORT', String(e?.message ?? ''));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "packageManager": "pnpm@10.33.2",
5
5
  "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.",
6
6
  "keywords": [