@goodandready/dsh-key-rotation 0.8.76 → 0.8.78

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.
@@ -0,0 +1,590 @@
1
+ const searchInput = h('input', { className: 'krot-in', placeholder: 'Search providers…', value: search, onChange: (e) => setSearch(e.target.value), style: { marginBottom: '8px' } });
2
+ const providerRows = providerList.map(({ entry, pIndex }) => {
3
+ const options = [h('option', { key: '', value: '' }, t('chooseProvider'))];
4
+ if (entry.provider && !providerById.has(entry.provider)) {
5
+ options.push(h('option', { key: entry.provider, value: entry.provider }, entry.provider + ' (' + (catalog.status === 'ready' ? t('notRegistered')
6
+ : catalog.status === 'loading' ? t('loading') : t('providerCatalogUnavailable')) + ')'));
7
+ }
8
+ options.push(...providers.map((prov) =>
9
+ h('option', { key: prov.id, value: prov.id }, prov.name + (prov.id !== prov.name ? ' — ' + prov.id : ''))));
10
+
11
+ const keys = entry.keys ?? [];
12
+ const entryWeights = entry.weights ?? [];
13
+ const entryPaused = entry.paused ?? [];
14
+
15
+ let readyCount = 0, cooldownCount = 0, errorCount = 0;
16
+ for (let idx = 0; idx < keys.length; idx++) {
17
+ const k = keys[idx];
18
+ const st = keyStatus(entry.provider, k, Boolean(entryPaused[idx]));
19
+ const isCool = (st && st.cooling) && !optimisticReset[k];
20
+ const tr = testResult[k];
21
+ if (tr && !tr.ok) errorCount++;
22
+ if (isCool) cooldownCount++;
23
+ if (st && st.ready) readyCount++;
24
+ }
25
+
26
+ const filteredIndices = keys.map((k, idx) => ({ key: k, kIndex: idx })).filter(({ key: k, kIndex: idx }) => {
27
+ if (statusFilter === 'all') return true;
28
+ const st = keyStatus(entry.provider, k, Boolean(entryPaused[idx]));
29
+ const isCool = (st && st.cooling) && !optimisticReset[k];
30
+ const tr = testResult[k];
31
+ if (statusFilter === 'ready') return Boolean(st && st.ready);
32
+ if (statusFilter === 'cooldown') return isCool;
33
+ if (statusFilter === 'error') return tr && !tr.ok;
34
+ return true;
35
+ });
36
+
37
+ const filterBar = keys.length > 1 ? h('div', { className: 'krot-filter-bar' },
38
+ h('button', { type: 'button', className: 'krot-pill' + (statusFilter === 'all' ? ' krot-pill-active' : ''), onClick: () => setStatusFilter('all') }, t('filterAll') + ' (' + keys.length + ')'),
39
+ h('button', { type: 'button', className: 'krot-pill' + (statusFilter === 'ready' ? ' krot-pill-active' : ''), onClick: () => setStatusFilter('ready') }, t('filterReady') + ' (' + readyCount + ')'),
40
+ cooldownCount > 0 ? h('button', { type: 'button', className: 'krot-pill krot-pill-warn' + (statusFilter === 'cooldown' ? ' krot-pill-active' : ''), onClick: () => setStatusFilter('cooldown') }, t('filterCooldown') + ' (' + cooldownCount + ')') : null,
41
+ errorCount > 0 ? h('button', { type: 'button', className: 'krot-pill krot-pill-err' + (statusFilter === 'error' ? ' krot-pill-active' : ''), onClick: () => setStatusFilter('error') }, t('filterErrors') + ' (' + errorCount + ')') : null,
42
+ ) : null;
43
+
44
+ const keyRows = filteredIndices.map(({ key, kIndex }) => {
45
+ const isP = Boolean(entryPaused[kIndex]);
46
+ const st = keyStatus(entry.provider, key, isP);
47
+ const info = keyInfo(entry.provider, key);
48
+ const rowKey = entry.provider + '/' + key;
49
+ const typed = secretDraft[rowKey];
50
+ const fromEnv = Boolean(info && info.source === 'env');
51
+
52
+ // Key name owns a full row so neighbouring keys stay distinguishable.
53
+ const nameRow = [
54
+ h('span', { className: 'krot-num', key: 'n' }, String(kIndex + 1)),
55
+ h('span', { key: 'i', className: 'krot-name', title: key + ' (click to copy)', style: { cursor: 'copy' }, onClick: () => {
56
+ if (navigator.clipboard) navigator.clipboard.writeText(key).then(() => setSecretDraft((cur) => ({ ...cur, ['copied:' + key]: true }))).catch(() => {});
57
+ setTimeout(() => setSecretDraft((cur) => ({ ...cur, ['copied:' + key]: false })), 1500);
58
+ } },
59
+ t('keyLabel').replace('{n}', String(kIndex + 1)),
60
+ h('span', null, secretDraft['copied:' + key] ? ' ✓' : '')),
61
+ ];
62
+
63
+ const meta = [
64
+ h('span', { key: 'd', className: 'krot-dot', style: { background: st ? st.color : 'var(--dsw-alias-border-l2)' } }),
65
+ h('span', { key: 's', className: 'krot-state' }, st ? st.text : ''),
66
+ ];
67
+ if (info && info.pinned) {
68
+ meta.push(h('span', { key: 'pin', className: 'krot-state', title: 'Pinned key' }, '📌'));
69
+ }
70
+ if (info && info.label) {
71
+ meta.push(h('span', { key: 'lbl', className: 'krot-tag', title: 'Key label' }, info.label));
72
+ }
73
+ if (fromEnv) {
74
+ meta.push(h('span', { key: 'v', className: 'krot-tail', title: t('keyFromEnv') },
75
+ info.tail ? '••••' + info.tail : t('keyFromEnv')));
76
+ } else {
77
+ meta.push(h('input', {
78
+ key: 'v',
79
+ type: 'password',
80
+ className: 'krot-in krot-secret',
81
+ value: typed ?? '',
82
+ placeholder: info && info.tail ? '••••' + info.tail : t('keyValuePlaceholder'),
83
+ onChange: (e) => setSecretDraft((cur) => ({ ...cur, [rowKey]: e.target.value })),
84
+ }));
85
+ if (typed) meta.push(btn('✓', () => saveSecret(key, rowKey), { title: t('keySave'), key: 'save-secret' }));
86
+ }
87
+ if (info && typeof info.usage === 'number' && info.usage > 0) {
88
+ let tip = 'requests through this key';
89
+ if (info.byModel && Object.keys(info.byModel).length > 0) {
90
+ tip = Object.entries(info.byModel).map(([m, c]) => m + ': ' + c).join('\n');
91
+ }
92
+ meta.push(h('span', { key: 'u', className: 'krot-tail', title: tip }, String(info.usage)));
93
+ if (info.usageDays && Object.keys(info.usageDays).length > 0) {
94
+ const days = Object.entries(info.usageDays);
95
+ const max = Math.max(1, ...days.map(([, c]) => c));
96
+ meta.push(h('span', { key: 'g', className: 'krot-graph', title: days.map(([d, c]) => d + ': ' + c).join('\n'), style: { display: 'inline-flex', gap: '1px', alignItems: 'flex-end', height: '12px' } },
97
+ days.slice(-14).map(([d, c]) => h('span', { key: d, style: { width: '3px', height: Math.max(2, (c / max) * 12) + 'px', background: 'var(--dsw-alias-state-success-primary)', borderRadius: '1px' } }))
98
+ ));
99
+ }
100
+ }
101
+ if (info && info.lastUsedAt) meta.push(h('span', { key: 'lu', className: 'krot-tail', title: 'last used' }, formatAgo((k)=>t(k), info.lastUsedAt)));
102
+ // (issue 215): per-key weight input (default 1)
103
+ meta.push(h('input', { key: 'w', type: 'number', min: 1, max: 1000, className: 'krot-in krot-weight',
104
+ value: (entryWeights[kIndex] ?? info?.weight ?? 1),
105
+ title: t('weightHint'),
106
+ onChange: (e) => setKeyWeight(pIndex, kIndex, e.target.value),
107
+ style: { width: '52px', padding: '2px 6px', fontSize: '12px' } }));
108
+ meta.push(h('button', {
109
+ key: 'pause-btn',
110
+ type: 'button',
111
+ className: 'krot-btn' + (isP ? ' krot-pill-warn' : ''),
112
+ title: isP ? t('resumeKey') : t('pauseKey'),
113
+ style: { padding: '2px 6px', fontSize: '11px' },
114
+ onClick: () => togglePauseKey(pIndex, kIndex),
115
+ }, isP ? '⏸ ' + t('keyPaused') : '▶'));
116
+ // (issue 210): RPM capacity indicator (only when rpmLimit is active)
117
+ if (info && info.rpm) meta.push(h('span', { key: 'rpm', className: 'krot-tail',
118
+ title: t('rpmTitle').replace('{u}', String(info.rpm.used)).replace('{r}', String(info.rpm.remaining)),
119
+ style: info.rpm.remaining === 0 ? { color: 'var(--dsw-alias-state-error-primary)', fontWeight: 700 } : undefined },
120
+ '⏱' + info.rpm.remaining));
121
+ // TPM capacity indicator (only when tpmLimit is active)
122
+ if (info && info.tpm) meta.push(h('span', { key: 'tpm', className: 'krot-tail',
123
+ title: t('tpmTitle').replace('{u}', String(info.tpm.used)).replace('{r}', String(info.tpm.remaining)),
124
+ style: info.tpm.remaining === 0 ? { color: 'var(--dsw-alias-state-error-primary)', fontWeight: 700 } : undefined },
125
+ '⚡' + info.tpm.remaining));
126
+ if (info && typeof info.cost === 'number' && info.cost > 0) meta.push(h('span', { key: 'c', className: 'krot-tail', title: 'cost' }, '$' + info.cost.toFixed(2)));
127
+ const tr = testResult[key];
128
+ if (tr) meta.push(h('span', { key: 'tr', className: 'krot-tail',
129
+ title: (tr.message || (tr.ok ? t('testOk') : t('testFail')))
130
+ + (!tr.ok && tr.code ? ' · ' + tr.code : '')
131
+ + (tr.ok && tr.modelsCount ? ' · ' + tr.modelsCount + ' models' : '')
132
+ + (tr.ok && tr.latencyMs ? ' · ' + tr.latencyMs + 'ms' : ''),
133
+ style: { color: tr.ok ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-error-primary)', fontWeight: 700 } },
134
+ tr.ok ? (tr.modelsCount ? tr.modelsCount + 'm' : '✓') : ('✕' + (tr.code ? ' ' + tr.code : ''))));
135
+ // (issue 219): last probe from the sandbox cache, greyed when older than 24h
136
+ else if (!Object.prototype.hasOwnProperty.call(testResult, key) && probeCache && probeCache[key]) {
137
+ const pc = probeCache[key];
138
+ const stale = Date.now() - (pc.at ?? 0) > 86400000;
139
+ meta.push(h('span', { key: 'pc', className: 'krot-tail',
140
+ title: 'last probe ' + (pc.at ? new Date(pc.at).toLocaleTimeString() : '') + (pc.ok ? ' ok' : ' ' + (pc.code ?? 'fail')),
141
+ style: { opacity: stale ? 0.4 : 0.7, color: pc.ok ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-error-primary)' } },
142
+ (pc.ok ? '✓' : '✕') + (pc.latencyMs ? ' ' + pc.latencyMs + 'ms' : '')));
143
+ }
144
+ meta.push(h('button', { key: 't', className: 'krot-btn', onClick: () => doTest(key), disabled: Boolean(testing || testAllProvider), title: t('testKey') }, testing === key ? '…' : t('testKey')));
145
+ // (issue 223): broken keys get a one-click live re-test + auto-unbreak
146
+ if (info && info.broken) {
147
+ meta.push(h('button', { key: 'rt', className: 'krot-btn', onClick: () => retestBroken(key), title: t('retestBroken') }, t('retestBroken')));
148
+ }
149
+ if (info && info.balance) {
150
+ const b = info.balance;
151
+ const isZero = b.isAvailable === false || (typeof b.total === 'number' && b.total <= 0);
152
+ const cur = b.currency || '';
153
+ const amtStr = typeof b.total === 'number' ? b.total.toFixed(2) : String(b.total || 0);
154
+ meta.push(h('span', {
155
+ key: 'bal',
156
+ className: 'krot-tail' + (isZero ? ' krot-badge-bad' : ''),
157
+ title: isZero ? t('balanceEmpty') : ('Upstream balance: ' + amtStr + ' ' + cur),
158
+ style: isZero ? { color: 'var(--dsw-alias-state-error-primary)', fontWeight: 700 } : undefined,
159
+ }, '💰 ' + amtStr + (cur ? (' ' + cur) : '')));
160
+ }
161
+ if (info && info.detectedLimits) {
162
+ const dl = info.detectedLimits;
163
+ const parts = [];
164
+ if (dl.rpmLimit) parts.push(dl.rpmLimit + ' RPM');
165
+ if (dl.tpmLimit) parts.push(dl.tpmLimit + ' TPM');
166
+ if (parts.length > 0) {
167
+ meta.push(h('span', {
168
+ key: 'dl',
169
+ className: 'krot-tail',
170
+ title: 'Live upstream limits:\n' + JSON.stringify(dl, null, 2),
171
+ style: { fontSize: '11px', opacity: 0.8, color: 'var(--dsw-alias-brand-primary)' }
172
+ }, '⚡ ' + parts.join(' · ')));
173
+ }
174
+ }
175
+ if (info && info.modelCapabilities && info.modelCapabilities.length > 0) {
176
+ meta.push(h('span', {
177
+ key: 'caps',
178
+ className: 'krot-tail',
179
+ title: 'Discovered models (' + info.modelCapabilities.length + '):\n' + info.modelCapabilities.join('\n'),
180
+ style: { fontSize: '11px', opacity: 0.85 }
181
+ }, '🤖 ' + info.modelCapabilities.length + 'm'));
182
+ }
183
+ meta.push(h('span', { key: 'a', className: 'krot-acts' },
184
+ btn('↑', () => moveKey(pIndex, kIndex, -1), { disabled: kIndex === 0, title: t('moveUp') }),
185
+ btn('↓', () => moveKey(pIndex, kIndex, 1), { disabled: kIndex === keys.length - 1, title: t('moveDown') }),
186
+ btn('✕', () => removeKey(pIndex, kIndex), { title: t('removeKey') }),
187
+ ));
188
+
189
+ return h('div', { key, className: 'krot-key' },
190
+ nameRow,
191
+ h('div', { className: 'krot-meta' }, meta),
192
+ );
193
+ });
194
+
195
+ const providerStatus = status[entry.provider];
196
+ const switchesLine = h('p', { className: 'krot-hint' },
197
+ providerStatus && providerStatus.switches > 0
198
+ ? t('switchesSome')
199
+ .replace('{n}', String(providerStatus.switches))
200
+ .replace('{reason}', String(providerStatus.lastReason || '—'))
201
+ .replace('{ago}', formatAgo(t, providerStatus.lastSwitchAt))
202
+ : t('switchesNone'));
203
+ const exhaustionWarning = providerStatus && providerStatus.lastExhaustionAt && (Date.now() - providerStatus.lastExhaustionAt) < 3600000
204
+ ? h('p', { className: 'krot-err' }, t('poolExhausted') + ' (' + formatAgo(t, providerStatus.lastExhaustionAt) + ')')
205
+ : null;
206
+ // (issue 208): budget line (warn color at >=80%, red at 100%)
207
+ const budgetLine = providerStatus && (providerStatus.budgetDaily > 0 || providerStatus.budgetWeekly > 0)
208
+ ? (() => {
209
+ const dayRatio = providerStatus.budgetDaily > 0 ? providerStatus.todayCost / providerStatus.budgetDaily : 0;
210
+ const weekRatio = providerStatus.budgetWeekly > 0 ? providerStatus.weeklyCost / providerStatus.budgetWeekly : 0;
211
+ const worst = Math.max(dayRatio, weekRatio);
212
+ const color = worst >= 1 ? 'var(--dsw-alias-state-error-primary)' : worst >= 0.8 ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-label-tertiary)';
213
+ const parts = [];
214
+ if (providerStatus.budgetDaily > 0) parts.push('$' + (providerStatus.todayCost ?? 0).toFixed(2) + '/' + '$' + providerStatus.budgetDaily);
215
+ if (providerStatus.budgetWeekly > 0) parts.push('week $' + (providerStatus.weeklyCost ?? 0).toFixed(2) + '/' + '$' + providerStatus.budgetWeekly);
216
+ if (worst >= 1 && providerStatus.pauseOnBudget) parts.push('· ' + t('pausedBudget'));
217
+ return h('p', { className: 'krot-hint', style: { color } }, t('budgetLabel') + ' ' + parts.join(' · '));
218
+ })()
219
+ : null;
220
+ // (issue 225): provider p95 latency + SLO marker
221
+ const sloLine = providerStatus && providerStatus.p95 != null
222
+ ? (() => {
223
+ const over = providerStatus.latencySloMs && providerStatus.p95 > providerStatus.latencySloMs;
224
+ return h('p', { className: 'krot-hint', style: over ? { color: 'var(--dsw-alias-state-warn-primary)' } : undefined },
225
+ 'p95 ' + providerStatus.p95 + 'ms' + (providerStatus.latencySloMs ? ' / ' + providerStatus.latencySloMs + 'ms SLO' : ''));
226
+ })()
227
+ : null;
228
+ // (issue 209): CSV export for this provider's usage (last 7 days)
229
+ const exportCsv = h('button', { className: 'krot-btn', title: t('exportCsv'),
230
+ onClick: () => {
231
+ const url = '/dsh-key-rotation/usage?format=csv&days=7&provider=' + encodeURIComponent(entry.provider);
232
+ const a = document.createElement('a');
233
+ a.href = url; a.download = 'usage-' + entry.provider + '.csv';
234
+ document.body.appendChild(a); a.click(); a.remove();
235
+ } }, t('exportCsv'));
236
+
237
+ const loadChart = (() => {
238
+ if (keys.length === 0) return null;
239
+ const ps = status[entry.provider];
240
+ const keyUsages = keys.map((k, idx) => {
241
+ const hit = ps && Array.isArray(ps.keys) ? ps.keys.find((x) => x.ref === k) : null;
242
+ const u = hit && typeof hit.usage === 'number' ? hit.usage : 0;
243
+ return { ref: k, index: idx, usage: u };
244
+ });
245
+ const total = keyUsages.reduce((acc, x) => acc + x.usage, 0);
246
+ const palette = [
247
+ 'var(--dsw-alias-brand-primary)',
248
+ 'var(--dsw-alias-state-success-primary)',
249
+ 'var(--dsw-alias-state-warn-primary)',
250
+ 'var(--dsw-alias-brand-primary)',
251
+ 'var(--krot-chart-4)',
252
+ 'var(--krot-chart-5)',
253
+ 'var(--krot-chart-6)',
254
+ ];
255
+ return h('div', { className: 'krot-load-chart' },
256
+ h('div', { className: 'krot-load-header' },
257
+ h('span', { style: { fontWeight: 600 } }, '📊 ' + t('loadDistribution')),
258
+ h('span', { className: 'krot-sr-only', style: { position: 'absolute', width: 1, height: 1, padding: 0, margin: -1, overflow: 'hidden', clip: 'rect(0,0,0,0)', border: 0 } },
259
+ t('loadChartAria') + ': ' + (total > 0 ? keyUsages.map((x) => t('keyLabel').replace('{n}', String(x.index + 1)) + ' ' + x.usage).join(', ') : t('noTrafficYet'))),
260
+ h('span', null, total > 0 ? (total + ' ' + t('statRequests')) : t('noTrafficYet'))
261
+ ),
262
+ h('div', { className: 'krot-load-bar' },
263
+ total > 0
264
+ ? keyUsages.map((x, i) => {
265
+ if (x.usage === 0) return null;
266
+ const pct = Math.max(1, Math.round((x.usage / total) * 100));
267
+ const color = palette[i % palette.length];
268
+ return h('div', {
269
+ key: x.ref,
270
+ className: 'krot-load-segment',
271
+ style: { width: pct + '%', background: color },
272
+ title: x.ref + ': ' + x.usage + ' (' + pct + '%)',
273
+ });
274
+ })
275
+ : h('div', { className: 'krot-load-segment', style: { width: '100%', background: 'var(--dsw-alias-bg-layer-2)', opacity: 0.6 } })
276
+ ),
277
+ total > 0 ? h('div', { className: 'krot-load-legend' },
278
+ keyUsages.map((x, i) => {
279
+ const pct = total > 0 ? Math.round((x.usage / total) * 100) : 0;
280
+ const color = palette[i % palette.length];
281
+ return h('span', { key: x.ref, className: 'krot-load-item' },
282
+ h('span', { className: 'krot-load-dot', style: { background: color } }),
283
+ h('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t('keyLabel').replace('{n}', String(x.index + 1)) + ': ' + x.usage + ' (' + pct + '%)')
284
+ );
285
+ })
286
+ ) : null,
287
+ total > 0 && keyUsages.length > 1 ? h('div', { style: { marginTop: '8px', display: 'flex', alignItems: 'center', gap: '8px' } },
288
+ h('span', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } }, '📈 ' + t('sparklineTraffic') + ':'),
289
+ h(Sparkline, { points: keyUsages.map(x => x.usage), width: 140, height: 20 })
290
+ ) : null,
291
+ (() => {
292
+ const an = ps && ps.analytics24h ? ps.analytics24h : null;
293
+ if (!an || an.totalRequests === 0) return null;
294
+ const hColor = an.healthIndexPct >= 80 ? 'var(--dsw-alias-state-success-primary)' : an.healthIndexPct >= 50 ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-state-error-primary)';
295
+ return h('div', { style: { marginTop: '8px', display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: '6px' } },
296
+ h('span', { className: 'krot-badge', style: { color: hColor, fontWeight: 700 } }, '🛡️ ' + t('healthIndex') + ': ' + an.healthIndexPct + '% (' + an.firstAttemptSuccesses + '/' + an.totalRequests + ' ' + t('cleanRequests') + ')'),
297
+ an.errorBreakdown.rateLimit > 0 ? h('span', { className: 'krot-badge krot-badge-warn' }, '429: ' + an.errorBreakdown.rateLimit) : null,
298
+ an.errorBreakdown.server > 0 ? h('span', { className: 'krot-badge krot-badge-warn' }, '5xx: ' + an.errorBreakdown.server) : null,
299
+ an.errorBreakdown.timeout > 0 ? h('span', { className: 'krot-badge krot-badge-warn' }, 'Timeout: ' + an.errorBreakdown.timeout) : null,
300
+ an.errorBreakdown.quota > 0 ? h('span', { className: 'krot-badge krot-badge-warn' }, 'Quota: ' + an.errorBreakdown.quota) : null,
301
+ an.cascades > 0 ? h('span', { className: 'krot-badge' }, '🔀 Cascades: ' + an.cascades) : null,
302
+ an.avgCooldownRecoveryMs > 0 ? h('span', { className: 'krot-badge' }, '⏱️ ' + t('avgRecovery') + ': ' + Math.round(an.avgCooldownRecoveryMs / 1000) + 's') : null
303
+ );
304
+ })()
305
+ );
306
+ })();
307
+
308
+ const promptCacheWidget = (() => {
309
+ const ps = status[entry.provider];
310
+ const pc = ps && ps.promptCache ? ps.promptCache : null;
311
+ if (!pc || (!pc.d30?.cachedTokens && !pc.allTime?.cachedTokens)) return null;
312
+ const d1 = pc.d1 || { cachedTokens: 0, savingsUsd: 0, hitRatioPct: 0 };
313
+ const d7 = pc.d7 || { cachedTokens: 0, savingsUsd: 0, hitRatioPct: 0 };
314
+ const d30 = pc.d30 || { cachedTokens: 0, savingsUsd: 0, hitRatioPct: 0 };
315
+ const savedStr = d30.savingsUsd > 0 ? '$' + d30.savingsUsd.toFixed(2) : '$0.00';
316
+ return h('div', {
317
+ className: 'krot-prompt-cache-card',
318
+ style: {
319
+ marginTop: '6px',
320
+ marginBottom: '6px',
321
+ padding: '8px 10px',
322
+ background: 'var(--dsw-alias-bg-layer-2)',
323
+ borderRadius: '6px',
324
+ border: '1px solid var(--dsw-alias-border-subtle)',
325
+ display: 'flex',
326
+ flexDirection: 'column',
327
+ gap: '4px'
328
+ }
329
+ },
330
+ h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' } },
331
+ h('span', { style: { fontWeight: 600, fontSize: '12px' } }, '⚡ ' + t('promptCacheTitle')),
332
+ h('span', { className: 'krot-badge krot-badge-ok', style: { fontSize: '11px', fontWeight: 600 } }, savedStr + ' ' + t('promptCacheSaved'))
333
+ ),
334
+ h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: '10px', fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } },
335
+ h('span', null, h('strong', { style: { color: 'var(--dsw-alias-label-primary)' } }, '24h: '), (d1.cachedTokens || 0).toLocaleString() + ' tok (' + d1.hitRatioPct + '%)'),
336
+ h('span', null, h('strong', { style: { color: 'var(--dsw-alias-label-primary)' } }, '7d: '), (d7.cachedTokens || 0).toLocaleString() + ' tok (' + d7.hitRatioPct + '%)'),
337
+ h('span', null, h('strong', { style: { color: 'var(--dsw-alias-label-primary)' } }, '30d: '), (d30.cachedTokens || 0).toLocaleString() + ' tok (' + d30.hitRatioPct + '%)')
338
+ )
339
+ );
340
+ })();
341
+
342
+ // ── per-model sub-pools: keys + token quotas, plus live usage meters ──
343
+ const modelPools = (() => {
344
+ const models = modelEntriesOf(entry);
345
+ const statusFor = (model) => status[entry.provider + '::' + model] ?? null;
346
+ const runtimeKeysOf = (model) => {
347
+ const ps = statusFor(model);
348
+ return ps && Array.isArray(ps.keys) ? ps.keys : null;
349
+ };
350
+ const rendered = models.map(([model, mp]) => {
351
+ const keys = Array.isArray(mp.keys) ? mp.keys : [];
352
+ const limits = quotaMapOf(mp);
353
+ const rtKeys = runtimeKeysOf(model);
354
+ const rows = keys.map((ref, kIndex) => {
355
+ const info = rtKeys ? rtKeys.find((x) => x.ref === ref) : null;
356
+ const quota = info && info.modelQuota ? info.modelQuota : null;
357
+ const limits2 = { ...limits };
358
+ const limitValue = limits2[ref]?.tokenLimit;
359
+ const cells = [
360
+ h('span', { key: 'r', className: 'krot-tail', title: ref }, ref),
361
+ h('input', {
362
+ key: 'l',
363
+ type: 'number',
364
+ min: 1,
365
+ step: 1,
366
+ className: 'krot-in krot-limit',
367
+ value: limitValue === undefined ? '' : String(limitValue),
368
+ placeholder: t('tokenLimitPlaceholder'),
369
+ title: t('tokenLimitHint'),
370
+ disabled: !stateRef.current.writable,
371
+ onChange: (e) => setModelTokenLimit(pIndex, model, ref, e.target.value),
372
+ }),
373
+ ];
374
+ if (!quota) {
375
+ cells.push(h('span', { key: 'm', className: 'krot-q-unlimited', title: t('mtLocalOnly') }, t('mtUnlimited')));
376
+ } else {
377
+ const pct = quota.limit > 0 ? Math.min(100, Math.round((quota.used / quota.limit) * 100)) : 0;
378
+ const color = quota.exhausted
379
+ ? 'var(--dsw-alias-state-error-primary)'
380
+ : pct >= 80 ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-state-success-primary)';
381
+ const resetIn = quota.resetAt ? formatCountdown(quota.resetAt - Date.now()) : null;
382
+ cells.push(h('span', { key: 'm', style: { display: 'inline-flex', alignItems: 'center', gap: '6px' } },
383
+ h('span', { className: 'krot-qbar', title: t('mtUsedOf').replace('{u}', String(quota.used)).replace('{l}', String(quota.limit)) },
384
+ h('span', { className: 'krot-qbar-fill', style: { width: pct + '%', background: color } })),
385
+ h('span', {
386
+ className: quota.exhausted ? 'krot-q-exhausted' : 'krot-qmeta',
387
+ title: [t('mtUsedOf').replace('{u}', String(quota.used)).replace('{l}', String(quota.limit)),
388
+ t('mtRemaining').replace('{n}', String(quota.remaining)),
389
+ resetIn ? t('mtResetIn').replace('{t}', resetIn) : '',
390
+ t('mtResetWindowHint')].filter(Boolean).join(' · '),
391
+ }, quota.exhausted
392
+ ? t('mtExhausted')
393
+ : (pct >= 1 || quota.used > 0 ? pct + '%' : t('mtRemaining').replace('{n}', String(quota.remaining)))),
394
+ ));
395
+ }
396
+ cells.push(h('span', { key: 'a', className: 'krot-acts' },
397
+ btn('↑', () => reorderModelKeys(pIndex, model, -1, kIndex), { disabled: kIndex === 0, title: t('moveUp') }),
398
+ btn('↓', () => reorderModelKeys(pIndex, model, 1, kIndex), { disabled: kIndex === keys.length - 1, title: t('moveDown') }),
399
+ btn('✕', () => toggleModelKey(pIndex, model, ref, false), { title: t('removeKey') }),
400
+ ));
401
+ return h('div', { key: ref, className: 'krot-qrow' }, cells);
402
+ });
403
+
404
+ const available = (entry.keys ?? []).filter((ref) => !keys.includes(ref));
405
+ return h('div', { key: model, className: 'krot-model' },
406
+ h('div', { className: 'krot-model-head' },
407
+ h('input', {
408
+ className: 'krot-in',
409
+ defaultValue: model,
410
+ title: t('modelName'),
411
+ placeholder: t('modelNamePlaceholder'),
412
+ disabled: !stateRef.current.writable,
413
+ onBlur: (e) => renameModel(pIndex, model, e.target.value.trim()),
414
+ style: { maxWidth: '220px' },
415
+ }),
416
+ h('span', { className: 'krot-tail' }, t('modelKeys') + ': ' + keys.length),
417
+ btn(t('removeModel'), () => removeModel(pIndex, model), { title: t('removeModel') }),
418
+ ),
419
+ rows.length > 0 ? h('div', { className: 'krot-keys' }, rows)
420
+ : h('p', { className: 'krot-hint' }, t('modelPoolsEmpty')),
421
+ available.length > 0 ? h('div', { className: 'krot-foot', style: { marginTop: '6px' } },
422
+ h('span', { className: 'krot-hint' }, t('addKey') + ':'),
423
+ available.map((ref) => btn(ref, () => toggleModelKey(pIndex, model, ref, true), { key: 'add-' + ref, title: ref })),
424
+ ) : null,
425
+ );
426
+ });
427
+
428
+ return h('div', { className: 'krot-models' },
429
+ h('div', { className: 'krot-models-title' }, '🎯 ' + t('modelPoolsTitle')),
430
+ h('p', { className: 'krot-models-hint' }, t('modelPoolsHint')),
431
+ rendered.length > 0 ? h('div', null, rendered) : h('p', { className: 'krot-hint' }, t('modelPoolsEmpty')),
432
+ btn(t('addModel'), () => addModel(pIndex), { title: t('addModel'), disabled: !stateRef.current.writable }),
433
+ );
434
+ })();
435
+
436
+ return h('div', { key: pIndex, className: 'krot-prov' },
437
+ h('div', { className: 'krot-prov-head' },
438
+ h('input', {
439
+ type: 'checkbox',
440
+ 'aria-label': t('selectProvider').replace('{p}', entry.provider),
441
+ checked: selected.has(entry.provider),
442
+ onChange: (e) => { const ns = new Set(selected); if (e.target.checked) ns.add(entry.provider); else ns.delete(entry.provider); setSelected(ns); },
443
+ }),
444
+ btn(t('exportOne'), () => {
445
+ const data = JSON.stringify([entry], null, 2);
446
+ const blob = new Blob([data], { type: 'application/json' });
447
+ const url = URL.createObjectURL(blob);
448
+ const a = document.createElement('a'); a.href = url; a.download = entry.provider + '.json'; a.click(); URL.revokeObjectURL(url);
449
+ }, { title: t('exportProvider') }),
450
+ btn('⇅', () => {
451
+ const ps = status[entry.provider];
452
+ if (!ps || !Array.isArray(ps.keys)) return;
453
+ const usageOf = (ref) => { const hit = ps.keys.find((k) => k.ref === ref); return hit && typeof hit.usage === 'number' ? hit.usage : 0; };
454
+ setField((cur) => {
455
+ const next = [...(cur.providers ?? [])];
456
+ if (!next[pIndex]) return cur;
457
+ const entry = next[pIndex];
458
+ const order = entry.keys.map((_, index) => index).sort((a, b) => usageOf(entry.keys[b]) - usageOf(entry.keys[a]));
459
+ next[pIndex] = reorderKeys(entry, order);
460
+ return { ...cur, providers: next };
461
+ });
462
+ }, { title: t('sortUsage') }),
463
+ h('select', { className: 'krot-in', value: entry.provider, onChange: (e) => setProvider(pIndex, e.target.value) }, options),
464
+ btn(t('presetLimitsBtn') || '⚡ Preset Limits', () => applyPreset(pIndex, entry.provider), { title: t('presetLimitsBtn') || '⚡ Preset Limits', disabled: !entry.provider.trim() || !stateRef.current.writable }),
465
+ presetNotice ? h('span', { className: 'krot-badge krot-badge-ok', style: { marginLeft: '4px' } }, presetNotice) : null,
466
+ (() => {
467
+ const ps = status[entry.provider];
468
+ const score = ps && typeof ps.healthScore === 'number' ? ps.healthScore : null;
469
+ if (score === null) return null;
470
+ const color = score > 80 ? 'var(--dsw-alias-state-success-primary)' : score >= 50 ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-state-error-primary)';
471
+ return h('span', { className: 'krot-tail', title: t('healthScoreTitle'), style: { flex: 'none', color, fontWeight: 700 } }, String(score));
472
+ })(),
473
+ (() => { const ps = status[entry.provider]; const tot = ps && typeof ps.totalUsage === 'number' ? ps.totalUsage : null; return tot !== null ? h('span', { className: 'krot-tail', title: t('totalRequestsTitle'), style: { flex: 'none' } }, String(tot)) : null; })(),
474
+ (() => {
475
+ const q = status.quota || (providerStatus && providerStatus.quota);
476
+ let nearestReset = null;
477
+ if (q && typeof q === 'object') {
478
+ for (const k of (entry.keys || [])) {
479
+ const entryQ = q[k];
480
+ if (entryQ && typeof entryQ.reset === 'number' && entryQ.reset > Date.now()) {
481
+ if (!nearestReset || entryQ.reset < nearestReset) nearestReset = entryQ.reset;
482
+ }
483
+ }
484
+ }
485
+ if (!nearestReset) return null;
486
+ const diffMs = nearestReset - Date.now();
487
+ if (diffMs <= 0) return null;
488
+ const mins = Math.ceil(diffMs / 60000);
489
+ const timeStr = mins > 60 ? Math.floor(mins / 60) + 'h ' + (mins % 60) + 'm' : mins + 'm';
490
+ return h('span', { className: 'krot-badge krot-badge-warn', title: 'Quota window reset' }, t('quotaResetIn').replace('{time}', timeStr));
491
+ })(),
492
+ btn('✕', () => setConfirmModal({
493
+ title: t('confirmRemoveProvTitle').replace('{p}', entry.provider),
494
+ desc: t('confirmRemoveProvDesc'),
495
+ actionLabel: t('removeProvider'),
496
+ danger: true,
497
+ onConfirm: () => removeProvider(pIndex),
498
+ }), { title: t('removeProvider') }),
499
+ ),
500
+ filterBar,
501
+ loadChart,
502
+ promptCacheWidget,
503
+ h('div', { className: 'krot-keys' }, keyRows),
504
+ modelPools,
505
+ h('div', { className: 'krot-foot' },
506
+ btn(t('addKey'), () => addKey(pIndex), { title: t('addKeyTitle'), disabled: !entry.provider.trim() }),
507
+ btn('📋 ' + t('bulkImport'), () => setBulkOpen(cur => ({ ...cur, [pIndex]: !cur[pIndex] })), { disabled: !entry.provider.trim(), title: t('bulkImport') }),
508
+ bulkOpen[pIndex] ? h('div', { className: 'krot-bulk-import-box', style: { width: '100%', marginTop: '8px', padding: '8px', background: 'var(--dsw-alias-bg-layer-2)', borderRadius: '6px' } },
509
+ h('p', { style: { margin: '0 0 6px 0', fontSize: '12px', color: 'var(--dsw-alias-label-secondary)' } }, t('bulkImportDesc')),
510
+ h('textarea', {
511
+ className: 'krot-in',
512
+ rows: 4,
513
+ style: { width: '100%', fontFamily: 'monospace', fontSize: '11px', resize: 'vertical' },
514
+ placeholder: 'KEY_1\nKEY_2, weight=5\nOPENAI_KEY=sk-...',
515
+ value: bulkText[pIndex] || '',
516
+ onChange: (e) => setBulkText(cur => ({ ...cur, [pIndex]: e.target.value })),
517
+ }),
518
+ h('div', { style: { display: 'flex', gap: '8px', marginTop: '6px' } },
519
+ btn(t('bulkImportBtn'), () => applyBulkImport(pIndex), { primary: true }),
520
+ btn(t('bulkImportCancel'), () => setBulkOpen(cur => ({ ...cur, [pIndex]: false })))
521
+ )
522
+ ) : null,
523
+ switchesLine,
524
+ budgetLine,
525
+ sloLine,
526
+ exhaustionWarning,
527
+ (providerStatus && Array.isArray(providerStatus.events) && providerStatus.events.length > 0 ? h('div', { style: { display: 'flex', gap: '2px', alignItems: 'end', height: '24px', marginTop: '4px' } }, (() => { const now = Date.now(); const buckets = Array(24).fill(0); for (const ev of providerStatus.events) { const h = Math.floor((now - ev.at) / 3600000); if (h >= 0 && h < 24) buckets[23 - h]++; } const max = Math.max(1, ...buckets); return buckets.map((c, i) => h('div', { key: i, title: c + ' switches', style: { flex: 1, background: c ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-border-l2)', height: (c / max * 24) + 'px', minHeight: '2px', borderRadius: '2px' } })); })()) : null),
528
+ // (issue 224): 7-day switches per day (client-side, from the same events)
529
+ (providerStatus && Array.isArray(providerStatus.events) && providerStatus.events.length > 0 ? h('div', { style: { display: 'flex', gap: '2px', alignItems: 'end', height: '16px', marginTop: '2px' } }, (() => { const now = Date.now(); const days = Array(7).fill(0); for (const ev of providerStatus.events) { const d = Math.floor((now - ev.at) / 86400000); if (d >= 0 && d < 7) days[6 - d]++; } const max = Math.max(1, ...days); return days.map((c, i) => h('div', { key: i, title: c + ' switches · day -' + (6 - i), style: { flex: 1, background: c ? 'var(--dsw-alias-brand-primary, var(--dsw-alias-state-warn-primary))' : 'var(--dsw-alias-border-l2)', height: (c / max * 16) + 'px', minHeight: '2px', borderRadius: '2px' } })); })()) : null),
530
+ (providerStatus && Array.isArray(providerStatus.events) && providerStatus.events.length > 0 ? h('details', { className: 'krot-event-stream' },
531
+ h('summary', { style: { display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer', fontWeight: 600, fontSize: '12px' } },
532
+ h('span', null, '📜 ' + t('liveEventStream')),
533
+ h('span', { className: 'krot-badge' }, String(providerStatus.events.length))
534
+ ),
535
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: '4px', marginTop: '8px' } },
536
+ providerStatus.events.slice().reverse().map((ev, i) => {
537
+ const isHeal = ev.type === 'heal' || ev.reason === 'auto-unbreak' || ev.reason === 'self-heal';
538
+ const isBad = ev.code === 'AUTH' || ev.reason === 'AUTH' || ev.type === 'broken';
539
+ const badgeClass = isHeal ? 'krot-badge-ok' : isBad ? 'krot-badge-bad' : 'krot-badge-warn';
540
+ const timeStr = ev.at ? new Date(ev.at).toLocaleTimeString() : '';
541
+ return h('div', { key: i, className: 'krot-event-row' },
542
+ h('span', { className: 'krot-event-time' }, timeStr),
543
+ h('span', { className: 'krot-tail' }, ev.ref || 'pool'),
544
+ h('span', { className: 'krot-badge ' + badgeClass }, String(ev.reason || ev.code || ev.type)),
545
+ ev.cooldownMs > 0 ? h('span', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-tertiary)' } }, 'cd: ' + Math.round(ev.cooldownMs / 1000) + 's') : null
546
+ );
547
+ })
548
+ )
549
+ ) : null),
550
+ btn(t('resetCooldown'), () => setConfirmModal({
551
+ title: t('confirmResetTitle').replace('{p}', entry.provider),
552
+ desc: t('confirmResetDesc'),
553
+ actionLabel: t('resetCooldown'),
554
+ danger: false,
555
+ onConfirm: () => doReset(entry.provider),
556
+ }), { disabled: !(providerStatus && providerStatus.switches > 0) || resetting === entry.provider, title: t('resetCooldown') }),
557
+ btn(checkingBalance === entry.provider ? t('checkingBalance') : ('💰 ' + t('balanceCheckBtn')), () => checkBalances(pIndex, entry.provider), {
558
+ disabled: Boolean(checkingBalance) || !entry.provider.trim(),
559
+ title: t('balanceCheckBtn'),
560
+ }),
561
+ btn(testAllProvider === entry.provider ? (testingProgress[entry.provider] ? t('testingAllProgress').replace('{n}', testingProgress[entry.provider].split('/')[0]).replace('{total}', testingProgress[entry.provider].split('/')[1]) : t('testing')) : t('testAll'), () => doTestAll(entry.provider), { disabled: Boolean(testing || testAllProvider), title: t('testAll') }),
562
+ exportCsv,
563
+ ),
564
+ );
565
+ });
566
+
567
+ const noProviders = catalog.status === 'error'
568
+ ? h('div', { className: 'krot-hint', role: 'status' }, t('providerCatalogUnavailable'),
569
+ btn(t('retry'), catalog.reload))
570
+ : catalog.status === 'ready' && providers.length === 0
571
+ ? h('div', { className: 'krot-empty', role: 'status', style: { display: 'flex', flexDirection: 'column', gap: '6px', padding: '16px 14px', border: '1px dashed var(--dsw-alias-border-l2)', borderRadius: 10, background: 'var(--dsw-alias-bg-layer-2)' } },
572
+ h('p', { style: { margin: 0, fontWeight: 600, fontSize: 14, color: 'var(--dsw-alias-label-primary)' } }, t('emptyTitle')),
573
+ h('p', { className: 'krot-hint', style: { margin: 0 } }, t('emptyDesc')),
574
+ h('p', { className: 'krot-hint', style: { margin: 0 } }, t('noProviders')),
575
+ )
576
+ : null;
577
+
578
+ const allConfiguredProviders = Array.isArray(val?.providers) ? val.providers : [];
579
+ const totalPoolsCount = allConfiguredProviders.length;
580
+ let totalKeysCount = 0;
581
+ let healthyKeysCount = 0;
582
+ for (const prov of allConfiguredProviders) {
583
+ const pKeys = Array.isArray(prov.keys) ? prov.keys : [];
584
+ totalKeysCount += pKeys.length;
585
+ for (const k of pKeys) {
586
+ const st = keyStatus(prov.provider, k);
587
+ if (st && st.ready) healthyKeysCount++;
588
+ }
589
+ }
590
+