@goodandready/dsh-key-rotation 0.8.28 → 0.8.30

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/CHANGELOG.md CHANGED
@@ -3,6 +3,55 @@
3
3
  All notable changes to `@goodandready/dsh-key-rotation` are documented here.
4
4
  User-facing feature notes also appear in README (en is source of truth).
5
5
 
6
+ ## 0.8.30 - 2026-09-28
7
+
8
+ ### Added & Enhanced
9
+ - **Per-Model Per-Key Token Quotas (#380)**:
10
+ - Granular token budgets per key within model sub-pools (`models.<model>.quotas.<ref>.tokenLimit`).
11
+ - Fail-closed local quota enforcement without penalising keys (healthy keys reaching their budget are safely skipped for that model without poisoning health scores or cooldown timers).
12
+ - Live token usage meters and progress bars in Settings UI card with remaining token counts and reset countdowns.
13
+ - Multi-window persistence of token consumption across daemon restarts.
14
+ - **Resilient Provider Catalog (#379)**:
15
+ - Hardened provider catalog discovery for RPC-backed LLM service layers and backwards compatibility across core versions.
16
+ - Safe fallbacks from `listProviders()` to `listConfigurableProviders()` preventing card load failures when remote providers are registered.
17
+ - **Method-Aware Bridge Security Guard (#378)**:
18
+ - Reinforced loopback origin checks distinguishing read-only operations (GET, HEAD, OPTIONS) from mutating operations.
19
+ - Hardened host header normalization (IPv6 bracket stripping and port stripping) guarding local management endpoints.
20
+
21
+ ### Refactored & Optimized
22
+ - **Isolated Credential Resolver Module (#381)**:
23
+ - Extracted `credentials.resolve` dispatch logic from `lib/index.js` into modular `lib/resolver.js` (`createResolver`).
24
+ - Unit-testable routing decisions, request-scoped pool ownership (AsyncLocalStorage), and fail-closed budget checks.
25
+
26
+ ### Fixed & Hardened (#385)
27
+ - **Lifecycle & Quota State Synchronization**:
28
+ - `/health` operational route now accurately respects `isKeyRevoked` and `isKeyPaused` in healthy count and degradation status.
29
+ - Fixed cost budget loop early `continue` in `lib/budget-monitor.js` which previously suppressed low-health and latency SLO alerts on providers lacking cost budgets.
30
+ - Integrated model token quota exhaustion detection (`isModelQuotaAvailable`) into low-health alerting (`warnBelowHealthy`).
31
+ - `DELETE /key` and `POST /reset` now cleanly purge stale `st.tokenUsage` entries.
32
+ - Replaced hardcoded hex color fallback in UI `Sparkline` with CSS theme variables.
33
+
34
+ ## 0.8.29 - 2026-09-27
35
+
36
+ ### Fixed & Hardened (#376)
37
+ - **Decrypted secret resolution at rest**:
38
+ - `lib/index.js`: patched `credentials.resolve` now decrypts ciphertext (`enc:v1:...`) via `decryptSecret` for pooled hits, fallback environment variables, and unpooled standalone key queries, preventing raw encrypted strings from being passed to upstream LLM providers.
39
+ - **Fail-safe candidate selection in rotation pool**:
40
+ - `lib/index.js`: `credentials.resolve` candidate check loop now explicitly enforces `!isKeyRevoked(pool, candidate)` and `!isKeyPaused(pool, candidate)` before selecting candidates.
41
+ - **Predicate logic & dynamic state resolution**:
42
+ - `lib/pool.js`: corrected `isKeyPaused` predicate to ensure `pool.state.pausedRefs` is inspected when `pool.pausedRefs` Set or Array does not contain the ref, allowing runtime key pause mutations to take effect.
43
+ - `lib/cascade.js`: replaced raw `.has()` call with `isKeyRevoked(pool, ref)` and unified `isKeyPaused`, preventing `TypeError` crashes when `revokedRefs` is represented as an Array.
44
+ - **Config & schema synchronization**:
45
+ - `lib/index.js`: propagated `p.revoked` into `buildPool` / `buildPoolItem` and added missing `paused` and `revoked` definitions to `SettingsSchema.providers`.
46
+ - **Status & key administrative routes**:
47
+ - `lib/ops-status.js`: exposed `paused` and `revoked` booleans on key entries in `/dsh-key-rotation/status`.
48
+ - `lib/pool.js`: updated `keyTail` to transparently decrypt ciphertext before extracting the tail, displaying the actual credential mask instead of random ciphertext characters while preserving test safety contracts.
49
+ - `lib/ops-keys.js`: `/dsh-key-rotation/reset` now clears `st.revokedRefs` for provider resets and single ref resets, enabling operators to unblock fixed keys.
50
+ - **Prometheus metrics & monitoring**:
51
+ - `lib/ops-metrics.js`: separated expired keys from active counts and added `dsh_key_rotation_expired_keys` gauge.
52
+ - `lib/budget-monitor.js`: excluded revoked and paused keys from healthy count in `warnBelowHealthy` threshold evaluation.
53
+ - `lib/webhook.js`: hardened Ntfy topic extraction against URLs with trailing slashes and query strings.
54
+
6
55
  ## 0.8.28 - 2026-09-27
7
56
 
8
57
  ### Security & Key Lifecycle (Block 3)
package/README.md CHANGED
@@ -264,6 +264,16 @@ dsh-key-rotation:
264
264
  - PRIMARY_API_KEY
265
265
  - PRIMARY_API_KEY_2
266
266
  - PRIMARY_API_KEY_BACKUP
267
+ models:
268
+ reasoning-model-id:
269
+ keys:
270
+ - PRIMARY_API_KEY
271
+ - PRIMARY_API_KEY_2
272
+ quotas:
273
+ PRIMARY_API_KEY:
274
+ tokenLimit: 500000
275
+ PRIMARY_API_KEY_2:
276
+ tokenLimit: 1000000
267
277
  - provider: secondary-provider
268
278
  keys:
269
279
  - SECONDARY_API_KEY
package/README.ru.md CHANGED
@@ -261,6 +261,16 @@ dsh-key-rotation:
261
261
  - PRIMARY_API_KEY
262
262
  - PRIMARY_API_KEY_2
263
263
  - PRIMARY_API_KEY_BACKUP
264
+ models:
265
+ reasoning-model-id:
266
+ keys:
267
+ - PRIMARY_API_KEY
268
+ - PRIMARY_API_KEY_2
269
+ quotas:
270
+ PRIMARY_API_KEY:
271
+ tokenLimit: 500000
272
+ PRIMARY_API_KEY_2:
273
+ tokenLimit: 1000000
264
274
  - provider: secondary-provider
265
275
  keys:
266
276
  - SECONDARY_API_KEY
package/README.zh.md CHANGED
@@ -242,6 +242,16 @@ dsh-key-rotation:
242
242
  - PRIMARY_API_KEY
243
243
  - PRIMARY_API_KEY_2
244
244
  - PRIMARY_API_KEY_BACKUP
245
+ models:
246
+ reasoning-model-id:
247
+ keys:
248
+ - PRIMARY_API_KEY
249
+ - PRIMARY_API_KEY_2
250
+ quotas:
251
+ PRIMARY_API_KEY:
252
+ tokenLimit: 500000
253
+ PRIMARY_API_KEY_2:
254
+ tokenLimit: 1000000
245
255
  - provider: secondary-provider
246
256
  keys:
247
257
  - SECONDARY_API_KEY
@@ -1,5 +1,6 @@
1
1
  // lib/budget-monitor.js — periodic budget, expiry, low health and SLO alerts
2
- import { expiringSoon, shouldNotifyDaily, costForDay, costForWeek, budgetVerdict } from './pool.js';
2
+ import { expiringSoon, shouldNotifyDaily, costForDay, costForWeek, budgetVerdict, isKeyPaused, isKeyRevoked } from './pool.js';
3
+ import { isModelQuotaAvailable, hasModelQuotaConfig } from './model-quota.js';
3
4
 
4
5
  const DAY_MS = 86400000;
5
6
 
@@ -35,47 +36,51 @@ export function checkBudgetAndHealthAlerts({
35
36
  }
36
37
  }
37
38
  // #208: daily/weekly budget -> warn webhook, optional 1-day pause at 100%
38
- const budget = runtime.providerBudgets.get(pool.base);
39
- if (!budget) continue;
40
- const daily = costForDay(pool.state.costDays);
41
- const weekly = costForWeek(pool.state.costDays, now);
42
- const verdict = budgetVerdict(daily, budget.costBudgetDaily);
43
- const wVerdict = budgetVerdict(weekly, budget.costBudgetWeekly);
44
- const hit = verdict.warn || wVerdict.warn;
45
- if (hit && shouldNotifyDaily(budgetNotifiedAt, pool.base + ':budget', now)) {
46
- logger?.warn?.(`[dsh-key-rotation] ${pool.base}: cost budget - day $${daily.toFixed(2)}/$${budget.costBudgetDaily} week $${weekly.toFixed(2)}/$${budget.costBudgetWeekly}`);
47
- if (runtime.notifyWebhook) {
48
- // #217: budget webhook gains action buttons when a callback token is configured
49
- const token = runtime.webhookActionToken ?? '';
50
- webhookSender.send(runtime.notifyWebhook, {
51
- title: `Cost budget: ${pool.base}`,
52
- text: `day $${daily.toFixed(2)} of $${budget.costBudgetDaily} · week $${weekly.toFixed(2)} of $${budget.costBudgetWeekly}` + (verdict.exceeded || wVerdict.exceeded ? ' · EXCEEDED' : ''),
53
- provider: pool.base,
54
- kind: 'budget',
55
- spend: { daily, weekly },
56
- actionToken: token || undefined,
57
- actions: token ? [
58
- { id: `pause-${pool.base}`, label: 'Pause 1h' },
59
- { id: `reset-${pool.base}`, label: 'Reset cooldown' },
60
- ] : undefined,
61
- });
39
+ const budget = runtime.providerBudgets?.get?.(pool.base);
40
+ if (budget) {
41
+ const daily = costForDay(pool.state.costDays);
42
+ const weekly = costForWeek(pool.state.costDays, now);
43
+ const verdict = budgetVerdict(daily, budget.costBudgetDaily);
44
+ const wVerdict = budgetVerdict(weekly, budget.costBudgetWeekly);
45
+ const hit = verdict.warn || wVerdict.warn;
46
+ if (hit && shouldNotifyDaily(budgetNotifiedAt, pool.base + ':budget', now)) {
47
+ logger?.warn?.(`[dsh-key-rotation] ${pool.base}: cost budget - day $${daily.toFixed(2)}/$${budget.costBudgetDaily} week $${weekly.toFixed(2)}/$${budget.costBudgetWeekly}`);
48
+ if (runtime.notifyWebhook) {
49
+ // #217: budget webhook gains action buttons when a callback token is configured
50
+ const token = runtime.webhookActionToken ?? '';
51
+ webhookSender.send(runtime.notifyWebhook, {
52
+ title: `Cost budget: ${pool.base}`,
53
+ text: `day $${daily.toFixed(2)} of $${budget.costBudgetDaily} · week $${weekly.toFixed(2)} of $${budget.costBudgetWeekly}` + (verdict.exceeded || wVerdict.exceeded ? ' · EXCEEDED' : ''),
54
+ provider: pool.base,
55
+ kind: 'budget',
56
+ spend: { daily, weekly },
57
+ actionToken: token || undefined,
58
+ actions: token ? [
59
+ { id: `pause-${pool.base}`, label: 'Pause 1h' },
60
+ { id: `reset-${pool.base}`, label: 'Reset cooldown' },
61
+ ] : undefined,
62
+ });
63
+ }
62
64
  }
63
- }
64
- if ((verdict.exceeded || wVerdict.exceeded) && budget.pauseOnBudget) {
65
- const until = now + DAY_MS;
66
- for (const ref of pool.refs) {
67
- if ((pool.state.failedUntil.get(ref) ?? 0) < until) pool.state.failedUntil.set(ref, until);
65
+ if ((verdict.exceeded || wVerdict.exceeded) && budget.pauseOnBudget) {
66
+ const until = now + DAY_MS;
67
+ for (const ref of pool.refs) {
68
+ if ((pool.state.failedUntil.get(ref) ?? 0) < until) pool.state.failedUntil.set(ref, until);
69
+ }
68
70
  }
69
71
  }
70
72
  // #221: pool running low - webhook while healthy < warnBelowHealthy
71
73
  const warnBelow = runtime.warnBelowHealthy ?? 0;
72
74
  if (warnBelow > 0) {
75
+ const quotaAware = hasModelQuotaConfig(pool);
73
76
  let healthy = 0;
74
77
  for (const ref of pool.refs) {
78
+ if (isKeyRevoked(pool, ref) || isKeyPaused(pool, ref)) continue;
75
79
  const fu = pool.state.failedUntil.get(ref);
76
80
  if (fu !== undefined && fu > now) continue;
77
81
  const exp = pool.expiresAt?.[ref];
78
82
  if (exp !== undefined && now >= exp) continue;
83
+ if (quotaAware && !isModelQuotaAvailable(pool, ref, now)) continue;
79
84
  healthy++;
80
85
  }
81
86
  if (healthy < warnBelow && shouldNotifyDaily(lowHealthNotifiedAt, pool.base, now)) {
package/lib/cascade.js CHANGED
@@ -1,15 +1,9 @@
1
- // cascade.js — cross-provider failover cascade (issues #194, #364).
1
+ // cascade.js — cross-provider failover cascade (issues #194, #364, #376).
2
2
  // ponytail: minimal — pick fallback provider with model mapping from config.
3
+ import { isKeyPaused, isKeyRevoked } from './pool.js';
3
4
 
4
5
  export const CASCADE_MAX_DEPTH = 3;
5
6
 
6
- function isKeyPaused(pool, ref) {
7
- if (!pool || !ref) return false;
8
- if (pool.pausedRefs instanceof Set) return pool.pausedRefs.has(ref);
9
- if (Array.isArray(pool.pausedRefs)) return pool.pausedRefs.includes(ref);
10
- return false;
11
- }
12
-
13
7
  export function pickCascadeFallback(provider, cfg, pools, currentModel) {
14
8
  const list = Array.isArray(cfg && cfg.cascade) ? cfg.cascade : [];
15
9
  for (const entry of list) {
@@ -20,7 +14,7 @@ export function pickCascadeFallback(provider, cfg, pools, currentModel) {
20
14
  const now = Date.now();
21
15
  let healthy = 0;
22
16
  for (const ref of pool.refs) {
23
- if (isKeyPaused(pool, ref) || (pool.revokedRefs && pool.revokedRefs.has(ref)) || (pool.state?.revokedRefs && pool.state.revokedRefs.has(ref))) continue;
17
+ if (isKeyPaused(pool, ref) || isKeyRevoked(pool, ref)) continue;
24
18
  const failedUntil = (pool.state && pool.state.failedUntil && pool.state.failedUntil.get(ref)) || 0;
25
19
  if (failedUntil > now) continue;
26
20
  const exp = pool.expiresAt ? pool.expiresAt[ref] : undefined;
package/lib/client.js CHANGED
@@ -120,6 +120,24 @@ window.__ModuleLoader__.load({
120
120
  budgetLabel: 'budget:',
121
121
  exportCsv: 'Export CSV',
122
122
  weightHint: 'round-robin weight: how many times this key joins the cycle (1 = equal share)',
123
+ modelPoolsTitle: 'Per-model token quotas',
124
+ modelPoolsHint: 'Give one credential its own token budget for one model. A key that spends its budget is skipped for that model only — other models keep using it. Leave the limit empty for unlimited.',
125
+ modelPoolsEmpty: 'No model sub-pools configured. Add one to give a model its own keys and token budgets.',
126
+ addModel: '+ Add model',
127
+ modelName: 'Model id',
128
+ modelNamePlaceholder: 'e.g. claude-sonnet',
129
+ removeModel: 'Remove this model pool',
130
+ modelKeys: 'Credential references for this model',
131
+ tokenLimitLabel: 'Token limit',
132
+ tokenLimitPlaceholder: 'unlimited',
133
+ tokenLimitHint: 'Tokens this credential may spend on this model per quota window. Empty = unlimited.',
134
+ mtUnlimited: 'Unlimited',
135
+ mtExhausted: 'Exhausted',
136
+ mtRemaining: 'Remaining: {n}',
137
+ mtResetIn: 'Reset: {t}',
138
+ mtUsedOf: '{u} / {l}',
139
+ mtResetWindowHint: 'Window controlled by quotaResetWindow.',
140
+ mtLocalOnly: 'Tracked locally from provider-reported usage on successful responses.',
123
141
  retestBroken: 'Re-test',
124
142
  retestFail: 're-test failed - key still down',
125
143
  snapshotExport: 'Snapshot ⬇',
@@ -276,6 +294,24 @@ window.__ModuleLoader__.load({
276
294
  budgetLabel: '预算:',
277
295
  exportCsv: '导出 CSV',
278
296
  weightHint: '轮换权重:该密钥参与循环的次数 (1 = 等分)',
297
+ modelPoolsTitle: '按模型设置 Token 额度',
298
+ modelPoolsHint: '为某个凭据在某个模型上单独设置 Token 额度。额度用尽的密钥只会在该模型上被跳过,其他模型仍可继续使用。留空表示不限额。',
299
+ modelPoolsEmpty: '尚未配置模型子池。添加一个即可为该模型单独指定密钥与 Token 额度。',
300
+ addModel: '+ 添加模型',
301
+ modelName: '模型 ID',
302
+ modelNamePlaceholder: '例如 claude-sonnet',
303
+ removeModel: '移除该模型子池',
304
+ modelKeys: '该模型使用的凭据引用',
305
+ tokenLimitLabel: 'Token 额度',
306
+ tokenLimitPlaceholder: '不限额',
307
+ tokenLimitHint: '该凭据在此模型的每个额度周期内可消耗的 Token 数。留空表示不限额。',
308
+ mtUnlimited: '不限额',
309
+ mtExhausted: '已耗尽',
310
+ mtRemaining: '剩余:{n}',
311
+ mtResetIn: '重置:{t}',
312
+ mtUsedOf: '{u} / {l}',
313
+ mtResetWindowHint: '周期由 quotaResetWindow 控制。',
314
+ mtLocalOnly: '仅在成功响应后根据提供商返回的 usage 本地记账。',
279
315
  retestBroken: '重新测试',
280
316
  retestFail: '重新测试失败 - 密钥仍然不可用',
281
317
  snapshotExport: '快照 ⬇',
@@ -522,6 +558,19 @@ window.__ModuleLoader__.load({
522
558
  '.krot-alert-ok{padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent);color:var(--dsw-alias-state-success-primary);font-size:13px;display:flex;align-items:center;gap:10px}',
523
559
  '.krot-alert-bad{padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);color:var(--dsw-alias-state-error-primary);font-size:13px;display:flex;align-items:center;gap:10px}',
524
560
  '.krot-event-stream{display:flex;flex-direction:column;gap:6px;margin-top:8px;padding:10px 12px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:8px}',
561
+ // Per-model token quota editor + runtime meter
562
+ '.krot-models{margin-top:14px;border-top:1px solid var(--dsw-alias-border-l2);padding-top:12px}',
563
+ '.krot-models-title{display:flex;align-items:center;gap:8px;font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);margin-bottom:4px}',
564
+ '.krot-models-hint{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.4;margin:0 0 10px}',
565
+ '.krot-model{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:10px 12px;margin-bottom:10px;background:var(--dsw-alias-bg-layer-2)}',
566
+ '.krot-model-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:8px}',
567
+ '.krot-model-tag{font-size:11px;padding:2px 8px;border-radius:10px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary)}',
568
+ '.krot-limit{width:110px;font-variant-numeric:tabular-nums;text-align:right}',
569
+ '.krot-qbar{display:inline-block;width:64px;height:6px;background:var(--dsw-alias-bg-layer-3);border-radius:3px;overflow:hidden;vertical-align:middle}',
570
+ '.krot-qbar-fill{display:block;height:100%;transition:width .2s ease}',
571
+ '.krot-qmeta{font-size:11px;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums}',
572
+ '.krot-q-exhausted{font-size:11px;font-weight:600;color:var(--dsw-alias-state-error-primary)}',
573
+ '.krot-q-unlimited{font-size:11px;color:var(--dsw-alias-label-tertiary);font-style:italic}',
525
574
  '.krot-event-row{display:flex;align-items:center;gap:8px;font-size:12px;padding:4px 0;border-bottom:1px solid var(--dsw-alias-border-l1)}',
526
575
  '.krot-event-time{font-family:ui-monospace,Menlo,Consolas,monospace;color:var(--dsw-alias-label-tertiary);font-size:11px}',
527
576
  ':root{--krot-chart-4:var(--dsw-alias-state-brand-primary);--krot-chart-5:var(--dsw-alias-state-error-primary);--krot-chart-6:var(--dsw-alias-state-success-primary)}',
@@ -613,7 +662,7 @@ window.__ModuleLoader__.load({
613
662
  }
614
663
 
615
664
 
616
- function Sparkline({ points = [], width = 120, height = 22, stroke = 'var(--dsw-alias-state-brand-primary, #3b82f6)' }) {
665
+ function Sparkline({ points = [], width = 120, height = 22, stroke = 'var(--dsw-alias-state-brand-primary, var(--dsw-alias-color-primary, currentColor))' }) {
617
666
  if (!Array.isArray(points) || points.length < 2) {
618
667
  return h('svg', { width, height, viewBox: `0 0 ${width} ${height}`, style: { opacity: 0.3 } },
619
668
  h('line', { x1: 0, y1: height / 2, x2: width, y2: height / 2, stroke, strokeWidth: 1, strokeDasharray: '3 3' })
@@ -872,6 +921,55 @@ window.__ModuleLoader__.load({
872
921
  return next;
873
922
  }
874
923
 
924
+ function formatCountdown(ms) {
925
+ if (!Number.isFinite(ms) || ms <= 0) return '00:00:00';
926
+ const total = Math.floor(ms / 1000);
927
+ const h = Math.floor(total / 3600);
928
+ const m = Math.floor((total % 3600) / 60);
929
+ const s = total % 60;
930
+ const pad = (n) => String(n).padStart(2, '0');
931
+ return pad(h) + ':' + pad(m) + ':' + pad(s);
932
+ }
933
+
934
+ function quotaMapOf(modelEntry) {
935
+ const out = {};
936
+ const raw = (modelEntry && typeof modelEntry === 'object' && modelEntry.quotas) || {};
937
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
938
+ for (const [ref, value] of Object.entries(raw)) {
939
+ if (['__proto__', 'constructor', 'prototype'].includes(ref)) continue;
940
+ const n = typeof value === 'object' && value !== null ? value.tokenLimit : value;
941
+ const num = typeof n === 'string' ? Number(n.trim() === '' ? NaN : n) : Number(n);
942
+ if (Number.isFinite(num) && num > 0) out[ref] = { tokenLimit: Math.floor(num) };
943
+ }
944
+ return out;
945
+ }
946
+
947
+ function withQuotas(modelEntry, limits) {
948
+ const quotas = {};
949
+ for (const [ref, value] of Object.entries(limits ?? {})) {
950
+ const n = typeof value === 'object' && value !== null ? value.tokenLimit : value;
951
+ const num = typeof n === 'string' ? Number(n.trim() === '' ? NaN : n) : Number(n);
952
+ if (!Number.isFinite(num) || num <= 0) continue;
953
+ quotas[ref] = { tokenLimit: Math.floor(num) };
954
+ }
955
+ const next = { ...modelEntry };
956
+ if (Object.keys(quotas).length === 0) delete next.quotas;
957
+ else next.quotas = quotas;
958
+ return next;
959
+ }
960
+
961
+ function modelEntriesOf(entry) {
962
+ const models = entry && entry.models;
963
+ if (!models || typeof models !== 'object' || Array.isArray(models)) return [];
964
+ return Object.entries(models).filter(([, value]) => value && typeof value === 'object' && !Array.isArray(value));
965
+ }
966
+
967
+ function isValidModelId(id) {
968
+ return typeof id === 'string' && id.trim().length > 0
969
+ && !['__proto__', 'constructor', 'prototype'].includes(id)
970
+ && id.length <= 200;
971
+ }
972
+
875
973
  function validatePoolDraft(providers) {
876
974
  if (!Array.isArray(providers)) throw new Error('Provider pools must be an array');
877
975
  const names = new Set();
@@ -883,6 +981,17 @@ window.__ModuleLoader__.load({
883
981
  throw new Error('Choose a unique provider and valid, unique credential reference names');
884
982
  }
885
983
  names.add(entry.provider);
984
+ for (const [model, mp] of modelEntriesOf(entry)) {
985
+ if (!isValidModelId(model)) throw new Error('Model ids must be non-empty and safe as configuration keys');
986
+ if (!Array.isArray(mp.keys)
987
+ || mp.keys.some(key => typeof key !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
988
+ || new Set(mp.keys).size !== mp.keys.length) {
989
+ throw new Error('Each model pool needs valid, unique credential reference names');
990
+ }
991
+ for (const ref of Object.keys(quotaMapOf(mp))) {
992
+ if (!mp.keys.includes(ref)) throw new Error('A model token limit must reference a credential of that model pool');
993
+ }
994
+ }
886
995
  }
887
996
  }
888
997
 
@@ -1491,6 +1600,79 @@ window.__ModuleLoader__.load({
1491
1600
  providers: [...(Array.isArray(cur.providers) ? cur.providers : []), { provider: '', keys: [] }],
1492
1601
  }));
1493
1602
 
1603
+ // ── per-model sub-pools + token quotas ──
1604
+ const setModels = (pIndex, mutate) => setField((cur) => {
1605
+ const providers = [...(cur.providers ?? [])];
1606
+ const entry = { ...(providers[pIndex] ?? {}) };
1607
+ const models = { ...(entry.models && !Array.isArray(entry.models) ? entry.models : {}) };
1608
+ mutate(models);
1609
+ if (Object.keys(models).length === 0) delete entry.models;
1610
+ else entry.models = models;
1611
+ providers[pIndex] = entry;
1612
+ return { ...cur, providers };
1613
+ });
1614
+
1615
+ const addModel = (pIndex) => setModels(pIndex, (models) => {
1616
+ let name = 'model';
1617
+ let n = 2;
1618
+ while (Object.prototype.hasOwnProperty.call(models, name)) name = 'model-' + n++;
1619
+ const seedKeys = ((draftRef.current ?? stateRef.current.value)?.providers ?? [])[pIndex]?.keys ?? [];
1620
+ models[name] = { keys: [...seedKeys] };
1621
+ });
1622
+
1623
+ const renameModel = (pIndex, from, to) => setModels(pIndex, (models) => {
1624
+ const id = String(to ?? '');
1625
+ if (id === from || !isValidModelId(id)) return;
1626
+ if (Object.prototype.hasOwnProperty.call(models, id)) return;
1627
+ const rebuilt = {};
1628
+ for (const [key, value] of Object.entries(models)) rebuilt[key === from ? id : key] = value;
1629
+ for (const key of Object.keys(models)) delete models[key];
1630
+ Object.assign(models, rebuilt);
1631
+ });
1632
+
1633
+ const removeModel = (pIndex, model) => setModels(pIndex, (models) => {
1634
+ delete models[model];
1635
+ });
1636
+
1637
+ const toggleModelKey = (pIndex, model, ref, on) => setModels(pIndex, (models) => {
1638
+ const mp = { ...(models[model] ?? {}) };
1639
+ const keys = Array.isArray(mp.keys) ? [...mp.keys] : [];
1640
+ const index = keys.indexOf(ref);
1641
+ if (on && index < 0) keys.push(ref);
1642
+ if (!on && index >= 0) keys.splice(index, 1);
1643
+ mp.keys = keys;
1644
+ if (!on) {
1645
+ const limits = quotaMapOf(mp);
1646
+ delete limits[ref];
1647
+ models[model] = withQuotas(mp, limits);
1648
+ } else {
1649
+ models[model] = mp;
1650
+ }
1651
+ });
1652
+
1653
+ const setModelTokenLimit = (pIndex, model, ref, raw) => setModels(pIndex, (models) => {
1654
+ const mp = { ...(models[model] ?? {}) };
1655
+ const limits = quotaMapOf(mp);
1656
+ const text = String(raw ?? '').trim();
1657
+ if (text === '') delete limits[ref];
1658
+ else {
1659
+ const num = Number(text);
1660
+ if (!Number.isFinite(num) || num <= 0) delete limits[ref];
1661
+ else limits[ref] = Math.floor(num);
1662
+ }
1663
+ models[model] = withQuotas(mp, limits);
1664
+ });
1665
+
1666
+ const reorderModelKeys = (pIndex, model, delta, kIndex) => setModels(pIndex, (models) => {
1667
+ const mp = { ...(models[model] ?? {}) };
1668
+ const keys = Array.isArray(mp.keys) ? [...mp.keys] : [];
1669
+ const target = kIndex + delta;
1670
+ if (target < 0 || target >= keys.length) return;
1671
+ [keys[kIndex], keys[target]] = [keys[target], keys[kIndex]];
1672
+ mp.keys = keys;
1673
+ models[model] = mp;
1674
+ });
1675
+
1494
1676
  const save = async () => {
1495
1677
  const savingDraft = draftRef.current;
1496
1678
  const base = draftBaseRef.current;
@@ -1853,6 +2035,100 @@ window.__ModuleLoader__.load({
1853
2035
  );
1854
2036
  })();
1855
2037
 
2038
+ // ── per-model sub-pools: keys + token quotas, plus live usage meters ──
2039
+ const modelPools = (() => {
2040
+ const models = modelEntriesOf(entry);
2041
+ const statusFor = (model) => status[entry.provider + '::' + model] ?? null;
2042
+ const runtimeKeysOf = (model) => {
2043
+ const ps = statusFor(model);
2044
+ return ps && Array.isArray(ps.keys) ? ps.keys : null;
2045
+ };
2046
+ const rendered = models.map(([model, mp]) => {
2047
+ const keys = Array.isArray(mp.keys) ? mp.keys : [];
2048
+ const limits = quotaMapOf(mp);
2049
+ const rtKeys = runtimeKeysOf(model);
2050
+ const rows = keys.map((ref, kIndex) => {
2051
+ const info = rtKeys ? rtKeys.find((x) => x.ref === ref) : null;
2052
+ const quota = info && info.modelQuota ? info.modelQuota : null;
2053
+ const limits2 = { ...limits };
2054
+ const limitValue = limits2[ref]?.tokenLimit;
2055
+ const cells = [
2056
+ h('span', { key: 'r', className: 'krot-tail', title: ref }, ref),
2057
+ h('input', {
2058
+ key: 'l',
2059
+ type: 'number',
2060
+ min: 1,
2061
+ step: 1,
2062
+ className: 'krot-in krot-limit',
2063
+ value: limitValue === undefined ? '' : String(limitValue),
2064
+ placeholder: t('tokenLimitPlaceholder'),
2065
+ title: t('tokenLimitHint'),
2066
+ disabled: !stateRef.current.writable,
2067
+ onChange: (e) => setModelTokenLimit(pIndex, model, ref, e.target.value),
2068
+ }),
2069
+ ];
2070
+ if (!quota) {
2071
+ cells.push(h('span', { key: 'm', className: 'krot-q-unlimited', title: t('mtLocalOnly') }, t('mtUnlimited')));
2072
+ } else {
2073
+ const pct = quota.limit > 0 ? Math.min(100, Math.round((quota.used / quota.limit) * 100)) : 0;
2074
+ const color = quota.exhausted
2075
+ ? 'var(--dsw-alias-state-error-primary)'
2076
+ : pct >= 80 ? 'var(--dsw-alias-state-warning-primary)' : 'var(--dsw-alias-state-success-primary)';
2077
+ const resetIn = quota.resetAt ? formatCountdown(quota.resetAt - Date.now()) : null;
2078
+ cells.push(h('span', { key: 'm', style: { display: 'inline-flex', alignItems: 'center', gap: '6px' } },
2079
+ h('span', { className: 'krot-qbar', title: t('mtUsedOf').replace('{u}', String(quota.used)).replace('{l}', String(quota.limit)) },
2080
+ h('span', { className: 'krot-qbar-fill', style: { width: pct + '%', background: color } })),
2081
+ h('span', {
2082
+ className: quota.exhausted ? 'krot-q-exhausted' : 'krot-qmeta',
2083
+ title: [t('mtUsedOf').replace('{u}', String(quota.used)).replace('{l}', String(quota.limit)),
2084
+ t('mtRemaining').replace('{n}', String(quota.remaining)),
2085
+ resetIn ? t('mtResetIn').replace('{t}', resetIn) : '',
2086
+ t('mtResetWindowHint')].filter(Boolean).join(' · '),
2087
+ }, quota.exhausted
2088
+ ? t('mtExhausted')
2089
+ : (pct >= 1 || quota.used > 0 ? pct + '%' : t('mtRemaining').replace('{n}', String(quota.remaining)))),
2090
+ ));
2091
+ }
2092
+ cells.push(h('span', { key: 'a', className: 'krot-acts' },
2093
+ btn('↑', () => reorderModelKeys(pIndex, model, -1, kIndex), { disabled: kIndex === 0, title: t('moveUp') }),
2094
+ btn('↓', () => reorderModelKeys(pIndex, model, 1, kIndex), { disabled: kIndex === keys.length - 1, title: t('moveDown') }),
2095
+ btn('✕', () => toggleModelKey(pIndex, model, ref, false), { title: t('removeKey') }),
2096
+ ));
2097
+ return h('div', { key: ref, className: 'krot-qrow' }, cells);
2098
+ });
2099
+
2100
+ const available = (entry.keys ?? []).filter((ref) => !keys.includes(ref));
2101
+ return h('div', { key: model, className: 'krot-model' },
2102
+ h('div', { className: 'krot-model-head' },
2103
+ h('input', {
2104
+ className: 'krot-in',
2105
+ defaultValue: model,
2106
+ title: t('modelName'),
2107
+ placeholder: t('modelNamePlaceholder'),
2108
+ disabled: !stateRef.current.writable,
2109
+ onBlur: (e) => renameModel(pIndex, model, e.target.value.trim()),
2110
+ style: { maxWidth: '220px' },
2111
+ }),
2112
+ h('span', { className: 'krot-tail' }, t('modelKeys') + ': ' + keys.length),
2113
+ btn(t('removeModel'), () => removeModel(pIndex, model), { title: t('removeModel') }),
2114
+ ),
2115
+ rows.length > 0 ? h('div', { className: 'krot-keys' }, rows)
2116
+ : h('p', { className: 'krot-hint' }, t('modelPoolsEmpty')),
2117
+ available.length > 0 ? h('div', { className: 'krot-foot', style: { marginTop: '6px' } },
2118
+ h('span', { className: 'krot-hint' }, t('addKey') + ':'),
2119
+ available.map((ref) => btn(ref, () => toggleModelKey(pIndex, model, ref, true), { key: 'add-' + ref, title: ref })),
2120
+ ) : null,
2121
+ );
2122
+ });
2123
+
2124
+ return h('div', { className: 'krot-models' },
2125
+ h('div', { className: 'krot-models-title' }, '🎯 ' + t('modelPoolsTitle')),
2126
+ h('p', { className: 'krot-models-hint' }, t('modelPoolsHint')),
2127
+ rendered.length > 0 ? h('div', null, rendered) : h('p', { className: 'krot-hint' }, t('modelPoolsEmpty')),
2128
+ btn(t('addModel'), () => addModel(pIndex), { title: t('addModel'), disabled: !stateRef.current.writable }),
2129
+ );
2130
+ })();
2131
+
1856
2132
  return h('div', { key: pIndex, className: 'krot-prov' },
1857
2133
  h('div', { className: 'krot-prov-head' },
1858
2134
  h('input', {
@@ -1918,6 +2194,7 @@ window.__ModuleLoader__.load({
1918
2194
  filterBar,
1919
2195
  loadChart,
1920
2196
  h('div', { className: 'krot-keys' }, keyRows),
2197
+ modelPools,
1921
2198
  h(UpdaterSection, { t }),
1922
2199
  h('div', { className: 'krot-foot' },
1923
2200
  btn(t('addKey'), () => addKey(pIndex), { title: t('addKeyTitle'), disabled: !entry.provider.trim() }),
@@ -2384,6 +2661,11 @@ window.__ModuleLoader__.load({
2384
2661
  }
2385
2662
 
2386
2663
  module.exports = { apply, inject: ['slots', 'locale'] };
2664
+ if (typeof globalThis !== 'undefined') {
2665
+ globalThis.__dshKeyRotationClientInternals = {
2666
+ quotaMapOf, withQuotas, modelEntriesOf, isValidModelId, formatCountdown, validatePoolDraft,
2667
+ };
2668
+ }
2387
2669
  return module.exports;
2388
2670
  },
2389
2671
  updateCheck: 'Check for updates',