@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 +49 -0
- package/README.md +10 -0
- package/README.ru.md +10 -0
- package/README.zh.md +10 -0
- package/lib/budget-monitor.js +35 -30
- package/lib/cascade.js +3 -9
- package/lib/client.js +283 -1
- package/lib/http-bridge.js +69 -6
- package/lib/index.js +35 -128
- package/lib/model-quota.js +298 -0
- package/lib/ops-keys.js +8 -1
- package/lib/ops-metrics.js +6 -1
- package/lib/ops-status.js +26 -5
- package/lib/persistence.js +25 -1
- package/lib/pool-builder.js +96 -6
- package/lib/pool-index.js +143 -0
- package/lib/pool-state.js +1 -0
- package/lib/pool.js +33 -10
- package/lib/resolver.js +159 -0
- package/lib/rotate.js +85 -1
- package/lib/webhook.js +9 -1
- package/package.json +1 -1
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
|
package/lib/budget-monitor.js
CHANGED
|
@@ -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
|
|
39
|
-
if (
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
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,
|
|
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',
|