@goodandready/dsh-key-rotation 0.8.29 → 0.8.31
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 +28 -0
- package/README.md +10 -0
- package/README.ru.md +10 -0
- package/README.zh.md +10 -0
- package/lib/budget-monitor.js +33 -29
- package/lib/client.js +283 -1
- package/lib/http-bridge.js +69 -6
- package/lib/index.js +31 -139
- package/lib/model-quota.js +298 -0
- package/lib/ops-keys.js +4 -1
- package/lib/ops-status.js +21 -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 +25 -5
- package/lib/resolver.js +159 -0
- package/lib/rotate.js +85 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,34 @@
|
|
|
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.31 - 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
|
+
|
|
6
34
|
## 0.8.29 - 2026-09-27
|
|
7
35
|
|
|
8
36
|
### Fixed & Hardened (#376)
|
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
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,41 +36,43 @@ 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) {
|
|
75
78
|
if (isKeyRevoked(pool, ref) || isKeyPaused(pool, ref)) continue;
|
|
@@ -77,6 +80,7 @@ export function checkBudgetAndHealthAlerts({
|
|
|
77
80
|
if (fu !== undefined && fu > now) continue;
|
|
78
81
|
const exp = pool.expiresAt?.[ref];
|
|
79
82
|
if (exp !== undefined && now >= exp) continue;
|
|
83
|
+
if (quotaAware && !isModelQuotaAvailable(pool, ref, now)) continue;
|
|
80
84
|
healthy++;
|
|
81
85
|
}
|
|
82
86
|
if (healthy < warnBelow && shouldNotifyDaily(lowHealthNotifiedAt, pool.base, now)) {
|
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',
|
package/lib/http-bridge.js
CHANGED
|
@@ -93,15 +93,78 @@ export async function writeSection(ctx, ns, section, expectedRevision, res, rese
|
|
|
93
93
|
json(res, 200, viewOf(descriptor, { writable: settings.writable, hasDocument: settings.documentPath !== void 0 }));
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Normalise one provider entry into `{ id, name }`, or null when unusable.
|
|
98
|
+
* Provider info shapes differ across core generations: `listProviders()` yields
|
|
99
|
+
* `{ id, name }`, while `listConfigurableProviders()` yields
|
|
100
|
+
* `{ provider, displayName, ... }`.
|
|
101
|
+
*/
|
|
102
|
+
function toCatalogEntry(info) {
|
|
103
|
+
if (!info || typeof info !== 'object') return null;
|
|
104
|
+
const id = typeof info.id === 'string' && info.id
|
|
105
|
+
? info.id
|
|
106
|
+
: (typeof info.provider === 'string' && info.provider ? info.provider : null);
|
|
107
|
+
if (!id) return null;
|
|
108
|
+
const name = typeof info.name === 'string' && info.name ? info.name
|
|
109
|
+
: (typeof info.displayName === 'string' && info.displayName ? info.displayName : id);
|
|
110
|
+
return { id, name };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Registered providers for the Settings card's picker (#379).
|
|
115
|
+
*
|
|
116
|
+
* `llm` is a service whose members are resolved through the host's service
|
|
117
|
+
* layer, and on newer cores some of them are *remote* (RPC-backed). A remote
|
|
118
|
+
* member may legitimately return a promise, and an older core may not expose
|
|
119
|
+
* `listProviders` at all. Both cases used to throw out of the GET handler and
|
|
120
|
+
* surface to the user as an unavailable provider list, so every call is
|
|
121
|
+
* guarded and `listConfigurableProviders()` is used as a fallback source.
|
|
122
|
+
*
|
|
123
|
+
* Never throws: an empty list is a valid answer, and the caller reports
|
|
124
|
+
* availability separately.
|
|
125
|
+
*/
|
|
96
126
|
export function providerCatalog(ctx, cloneIds) {
|
|
97
|
-
const seen = new Set();
|
|
98
127
|
const out = [];
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
if (
|
|
102
|
-
|
|
103
|
-
|
|
128
|
+
const seen = new Set();
|
|
129
|
+
const push = (infos) => {
|
|
130
|
+
if (!Array.isArray(infos)) return;
|
|
131
|
+
for (const raw of infos) {
|
|
132
|
+
const entry = toCatalogEntry(raw);
|
|
133
|
+
if (!entry || seen.has(entry.id) || cloneIds?.has(entry.id)) continue;
|
|
134
|
+
seen.add(entry.id);
|
|
135
|
+
out.push(entry);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
let llm = null;
|
|
140
|
+
try {
|
|
141
|
+
llm = ctx?.get?.('llm') ?? null;
|
|
142
|
+
} catch (_) {
|
|
143
|
+
llm = null;
|
|
104
144
|
}
|
|
145
|
+
|
|
146
|
+
const listProviders = llm?.listProviders;
|
|
147
|
+
if (typeof listProviders === 'function') {
|
|
148
|
+
try {
|
|
149
|
+
const infos = listProviders.call(llm);
|
|
150
|
+
if (infos && typeof infos.then !== 'function') push(infos);
|
|
151
|
+
} catch (_) {
|
|
152
|
+
/* fall through to configurable-provider directory */
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (out.length === 0) {
|
|
157
|
+
const listConfigurable = llm?.listConfigurableProviders;
|
|
158
|
+
if (typeof listConfigurable === 'function') {
|
|
159
|
+
try {
|
|
160
|
+
const infos = listConfigurable.call(llm);
|
|
161
|
+
if (infos && typeof infos.then !== 'function') push(infos);
|
|
162
|
+
} catch (_) {
|
|
163
|
+
/* an unavailable catalog is reported by the card, not thrown here */
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
105
168
|
return out;
|
|
106
169
|
}
|
|
107
170
|
|