@moguiyu/dsh-tavily 0.3.2 → 0.3.4

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/src/backend.js CHANGED
@@ -11,11 +11,26 @@
11
11
  import { readFileSync, writeFileSync } from 'node:fs'
12
12
  import { join } from 'node:path'
13
13
  import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
14
- import { STRATEGIES, isValidStrategy, maskValue, parseKeyList, orderKeys, readJsonFile } from './lib.js'
14
+ import { STRATEGIES, accountFingerprint, clampAccountCap, groupAccounts, isValidStrategy, maskValue, parseKeyList, orderKeys, readJsonFile } from './lib.js'
15
15
 
16
- const USAGE_TTL_MS = 60000
16
+ /**
17
+ * Tavily allows only **10 `/usage` requests per 10 minutes** per key, so the
18
+ * card's refreshes must not each become an upstream call: entries are served
19
+ * for two minutes, and a 429 stops asking for as long as `Retry-After` says
20
+ * (ten minutes by default) while the numbers already loaded keep being served.
21
+ */
22
+ const USAGE_TTL_MS = 120000
23
+ const USAGE_BACKOFF_MS = 600000
17
24
  const usageCache = new Map()
18
25
 
26
+ /**
27
+ * Test seam: age every cache entry so the next read is a real upstream call.
28
+ * The cache is module-scoped and time-based, which no test can otherwise reach.
29
+ */
30
+ export function ageUsageCache(ms) {
31
+ for (const [key, entry] of usageCache) usageCache.set(key, { ...entry, at: entry.at - ms })
32
+ }
33
+
19
34
  const MANAGER_STATE = 'tavily-manager.json'
20
35
  const LEGACY_STATE = 'tavily-settings.json'
21
36
  const REFS = ['TAVILY_API_KEYS', 'TAVILY_API_KEY']
@@ -50,7 +65,11 @@ function readBody(req) {
50
65
 
51
66
  async function fetchUsageDetailsFor(key) {
52
67
  const cached = usageCache.get(key)
53
- if (cached !== undefined && Date.now() - cached.at < USAGE_TTL_MS) return cached.details
68
+ const now = Date.now()
69
+ if (cached !== undefined) {
70
+ if (cached.until !== undefined && now < cached.until) return cached.details
71
+ if (now - cached.at < USAGE_TTL_MS) return cached.details
72
+ }
54
73
  try {
55
74
  const controller = new AbortController()
56
75
  const timer = setTimeout(() => controller.abort(), 15000)
@@ -63,7 +82,18 @@ async function fetchUsageDetailsFor(key) {
63
82
  } finally {
64
83
  clearTimeout(timer)
65
84
  }
66
- if (!response.ok) return null
85
+ if (!response.ok) {
86
+ if (response.status === 429) {
87
+ const header = response.headers !== undefined && typeof response.headers.get === 'function'
88
+ ? Number(response.headers.get('retry-after'))
89
+ : NaN
90
+ const waitMs = Number.isFinite(header) && header > 0 ? header * 1000 : USAGE_BACKOFF_MS
91
+ const details = cached !== undefined ? cached.details : null
92
+ usageCache.set(key, { at: cached !== undefined ? cached.at : now, details, until: now + waitMs })
93
+ return details
94
+ }
95
+ return null
96
+ }
67
97
  const json = await response.json()
68
98
  const account = json !== null && typeof json === 'object' ? json.account : undefined
69
99
  const keyUsage = json !== null && typeof json === 'object' ? json.key : undefined
@@ -81,7 +111,7 @@ async function fetchUsageDetailsFor(key) {
81
111
  ? account.plan
82
112
  : (keyUsage !== undefined && typeof keyUsage.plan === 'string' ? keyUsage.plan : null)
83
113
  if (usage === null && planUsage === null) return null
84
- const details = { usage, planUsage, planLimit, currentPlan }
114
+ const details = { usage, planUsage, planLimit, currentPlan, accountId: accountFingerprint(account) }
85
115
  usageCache.set(key, { at: Date.now(), details })
86
116
  return details
87
117
  } catch {
@@ -166,6 +196,35 @@ async function syncPrimary(credentials, value) {
166
196
  }
167
197
  }
168
198
 
199
+ /**
200
+ * Apply the card's per-key account choices to the stored grouping.
201
+ *
202
+ * `auto` drops the entry so the next usage scan infers it again, `own` pins the
203
+ * key as its own account (the escape hatch when the inference wrongly merges two
204
+ * accounts), and any other string joins that group. Unknown keys are ignored.
205
+ */
206
+ function applyAccountOverrides(previous, patch, keys) {
207
+ const next = previous !== null && previous !== undefined && typeof previous === 'object' && !Array.isArray(previous)
208
+ ? { ...previous }
209
+ : {}
210
+ const known = new Set(keys.map((key) => maskValue(key)))
211
+ for (const [masked, choice] of Object.entries(patch)) {
212
+ if (!known.has(masked)) continue
213
+ if (choice === null || choice === undefined || choice === 'auto') {
214
+ delete next[masked]
215
+ continue
216
+ }
217
+ if (choice === 'own') {
218
+ next[masked] = { group: 'own:' + masked, manual: true }
219
+ continue
220
+ }
221
+ if (typeof choice === 'string' && choice.length > 0) {
222
+ next[masked] = { group: choice, manual: true }
223
+ }
224
+ }
225
+ return next
226
+ }
227
+
169
228
  /**
170
229
  * Register the Tavily key/usage routes on `ctx.webServer`:
171
230
  * `/api/tavily-usage` and `/api/tavily-manager`.
@@ -229,6 +288,12 @@ export function installBackend(ctx) {
229
288
  removable,
230
289
  })),
231
290
  strategy: isValidStrategy(state.strategy) ? state.strategy : 'rotate',
291
+ // The tools half reads these from the state file; the card needs them to
292
+ // show which keys share an account and to offer the per-key override.
293
+ accounts: state.accounts !== null && state.accounts !== undefined && typeof state.accounts === 'object' && !Array.isArray(state.accounts)
294
+ ? state.accounts
295
+ : {},
296
+ accountCap: clampAccountCap(state.accountCap),
232
297
  source,
233
298
  writable: { keys: keysWritable, primary: primaryWritable },
234
299
  }
@@ -253,20 +318,44 @@ export function installBackend(ctx) {
253
318
  return am < bm ? -1 : 1
254
319
  })
255
320
  const perKey = []
321
+ const entries = []
256
322
  for (const key of orderedKeys) {
323
+ const masked = maskValue(key)
257
324
  try {
258
325
  const usage = await fetchUsageDetailsFor(key)
259
- perKey.push(usage !== null
260
- ? { ok: true, masked: maskValue(key), ...usage }
261
- : { ok: false, masked: maskValue(key), error: 'usage unavailable' })
326
+ if (usage !== null) {
327
+ perKey.push({
328
+ ok: true,
329
+ masked,
330
+ usage: usage.usage,
331
+ planUsage: usage.planUsage,
332
+ planLimit: usage.planLimit,
333
+ currentPlan: usage.currentPlan,
334
+ })
335
+ entries.push({ masked, fingerprint: typeof usage.accountId === 'string' ? usage.accountId : null })
336
+ } else {
337
+ perKey.push({ ok: false, masked, error: 'usage unavailable' })
338
+ entries.push({ masked, fingerprint: null })
339
+ }
262
340
  } catch (error) {
263
- perKey.push({ ok: false, masked: maskValue(key), error: String(error && error.message ? error.message : error) })
341
+ perKey.push({ ok: false, masked, error: String(error && error.message ? error.message : error) })
342
+ entries.push({ masked, fingerprint: null })
264
343
  }
265
344
  }
345
+ // Tavily has no account id, so the account block each key reports is the
346
+ // only grouping signal there is. Recompute it here (the one place that
347
+ // already pays for `/usage`), persist it, and hand it to the card.
348
+ const accounts = groupAccounts(entries, state.accounts)
349
+ const accountCap = clampAccountCap(state.accountCap)
350
+ if (JSON.stringify(state.accounts ?? null) !== JSON.stringify(accounts)) {
351
+ writeState(MANAGER_STATE, { ...state, accounts, accountCap })
352
+ }
266
353
  const okRows = perKey.filter((row) => row.ok)
267
354
  return send(res, 200, {
268
355
  ok: true,
269
356
  perKey,
357
+ accounts,
358
+ accountCap,
270
359
  totals: {
271
360
  keys: keys.length,
272
361
  okKeys: okRows.length,
@@ -343,7 +432,10 @@ export function installBackend(ctx) {
343
432
  await credentials.unset('TAVILY_API_KEYS')
344
433
  await syncPrimary(credentials, null)
345
434
  } else {
346
- if (strategy !== 'rotate') {
435
+ // Only the usage-ordered strategies need Tavily's counters, and
436
+ // `/usage` allows just 10 requests per 10 minutes: never spend one
437
+ // on a strategy that keeps the list order anyway.
438
+ if (strategy === 'low-usage-first' || strategy === 'high-usage-first') {
347
439
  const usageRows = await Promise.all(values.map(async (value) => ({ value, usage: await fetchUsageFor(value) })))
348
440
  const usageOf = (value) => {
349
441
  const row = usageRows.find((r) => r.value === value)
@@ -362,7 +454,14 @@ export function installBackend(ctx) {
362
454
  const masked = maskValue(value)
363
455
  if (keySavedAt[masked] === undefined) keySavedAt[masked] = now
364
456
  }
365
- writeState(MANAGER_STATE, { keySavedAt, strategy })
457
+ // Spread the previous state: writing only these two keys used to drop
458
+ // every other one — including the account grouping and the cap.
459
+ const nextState = { ...state, keySavedAt, strategy }
460
+ if (body.accountCap !== undefined) nextState.accountCap = clampAccountCap(body.accountCap)
461
+ if (body.accounts !== null && body.accounts !== undefined && typeof body.accounts === 'object' && !Array.isArray(body.accounts)) {
462
+ nextState.accounts = applyAccountOverrides(state.accounts, body.accounts, values)
463
+ }
464
+ writeState(MANAGER_STATE, nextState)
366
465
 
367
466
  return send(res, 200, { ok: true, ...(await buildManagerPayload(values, source)) })
368
467
  }
package/src/client.js CHANGED
@@ -11,11 +11,18 @@ export function factory(require) {
11
11
  const inject = ['slots']
12
12
 
13
13
  const STRATEGIES = [
14
- { id: 'rotate', label: 'Round-robin', hint: 'Use keys in turn; on HTTP 401/429 the next key is tried automatically.' },
14
+ { id: 'rotate', label: 'Round-robin', hint: 'Use keys in turn; on HTTP 401/429/432/433 the next key is tried automatically.' },
15
15
  { id: 'low-usage-first', label: 'Lowest usage first', hint: 'Re-orders keys by current Tavily usage, least-used first. The first key becomes primary.' },
16
- { id: 'high-usage-first', label: 'Highest usage first', hint: 'Re-orders keys by current Tavily usage, most-used first. The first key becomes primary.' }
16
+ { id: 'high-usage-first', label: 'Highest usage first', hint: 'Re-orders keys by current Tavily usage, most-used first. The first key becomes primary.' },
17
+ {
18
+ id: 'load-balance',
19
+ label: 'Load balance',
20
+ hint: 'Keeps at most the set number of requests in flight per Tavily account and spreads concurrent calls across accounts, so one free account is never hit in parallel. Accounts are inferred from live Tavily usage data — pin a key below if that is wrong. Keys on one account share its 100 requests/minute.'
21
+ }
17
22
  ]
18
23
 
24
+ const ACCOUNT_CAPS = [1, 2, 3, 4, 5, 6, 7, 8]
25
+
19
26
  const ICONS = {
20
27
  eye: ['M1.5 8C1.5 8 4 3.5 8 3.5S14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8Z', 'M8 5.8a2.2 2.2 0 1 0 0 4.4 2.2 2.2 0 0 0 0-4.4Z'],
21
28
  eyeOff: ['M1.5 8C1.5 8 4 3.5 8 3.5c1.6 0 3 .5 4.1 1.2', 'M14.5 8C14.5 8 12 12.5 8 12.5c-1.6 0-3-.5-4.1-1.2', 'M2 2l12 12', 'M8 5.8a2.2 2.2 0 0 1 2.2 2.2'],
@@ -37,6 +44,113 @@ export function factory(require) {
37
44
  return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
38
45
  }
39
46
 
47
+ function formatClock(iso) {
48
+ if (!iso) return null
49
+ const d = new Date(iso)
50
+ if (isNaN(d.getTime())) return null
51
+ const pad = (n) => String(n).padStart(2, '0')
52
+ return pad(d.getHours()) + ':' + pad(d.getMinutes())
53
+ }
54
+
55
+ // `/api/tavily-usage` asks Tavily for every key's counters, so it can take a
56
+ // while. Two rules follow: the cell must never look *empty* while that is in
57
+ // flight, and the last values already shown must survive a page reload. Only
58
+ // masked names and counters are stored — never key material.
59
+ const USAGE_SNAPSHOT_KEY = 'dsh-tavily:usage-snapshot:v1'
60
+ const RING_GROWTH_MS = 650
61
+
62
+ function usageStorage() {
63
+ try {
64
+ if (typeof window === 'undefined' || window === null || !window.localStorage) return null
65
+ return window.localStorage
66
+ } catch (error) {
67
+ return null
68
+ }
69
+ }
70
+
71
+ function readUsageSnapshot() {
72
+ const storage = usageStorage()
73
+ if (storage === null) return null
74
+ try {
75
+ const parsed = JSON.parse(storage.getItem(USAGE_SNAPSHOT_KEY))
76
+ if (parsed === null || typeof parsed !== 'object') return null
77
+ if (parsed.version !== 1) return null
78
+ if (parsed.rows === null || typeof parsed.rows !== 'object') return null
79
+ return parsed
80
+ } catch (error) {
81
+ return null
82
+ }
83
+ }
84
+
85
+ function writeUsageSnapshot(data) {
86
+ const storage = usageStorage()
87
+ if (storage === null) return
88
+ try {
89
+ const perKey = data !== null && data !== undefined && Array.isArray(data.perKey) ? data.perKey : []
90
+ const rows = {}
91
+ for (const row of perKey) {
92
+ if (row === null || typeof row !== 'object' || typeof row.masked !== 'string') continue
93
+ rows[row.masked] = {
94
+ ok: row.ok === true,
95
+ usage: typeof row.usage === 'number' ? row.usage : null,
96
+ planUsage: typeof row.planUsage === 'number' ? row.planUsage : null,
97
+ planLimit: typeof row.planLimit === 'number' ? row.planLimit : null
98
+ }
99
+ }
100
+ if (Object.keys(rows).length === 0) return
101
+ storage.setItem(USAGE_SNAPSHOT_KEY, JSON.stringify({ version: 1, at: new Date().toISOString(), rows: rows }))
102
+ } catch (error) {
103
+ /* storage is best-effort; the card still works without it */
104
+ }
105
+ }
106
+
107
+ function usagePercent(row) {
108
+ if (row === null || row === undefined || row.ok !== true) return null
109
+ if (row.planLimit === null || row.planLimit === undefined || row.planLimit <= 0) return null
110
+ if (row.planUsage === null || row.planUsage === undefined) return null
111
+ return Math.min(100, Math.round((row.planUsage / row.planLimit) * 100))
112
+ }
113
+
114
+ /**
115
+ * Count a number up to `target` instead of swapping it. Returns the value to
116
+ * paint now. A first-ever value (or a non-number) is set instantly: there is
117
+ * nothing to grow from.
118
+ */
119
+ function useAnimatedNumber(target, durationMs) {
120
+ const [display, setDisplay] = react.useState(target)
121
+ const displayRef = react.useRef(target)
122
+ const frameRef = react.useRef(0)
123
+ react.useEffect(() => {
124
+ const from = typeof displayRef.current === 'number' ? displayRef.current : null
125
+ const settle = (value) => { displayRef.current = value; setDisplay(value) }
126
+ if (typeof target !== 'number' || from === null || from === target) {
127
+ settle(typeof target === 'number' ? target : null)
128
+ return undefined
129
+ }
130
+ if (typeof requestAnimationFrame !== 'function') {
131
+ settle(target)
132
+ return undefined
133
+ }
134
+ let started = null
135
+ const tick = (now) => {
136
+ const stamp = typeof now === 'number' ? now : Date.now()
137
+ if (started === null) started = stamp
138
+ const elapsed = durationMs > 0 ? (stamp - started) / durationMs : 1
139
+ const t = Math.min(1, Math.max(0, elapsed))
140
+ const eased = 1 - Math.pow(1 - t, 3)
141
+ const next = t >= 1 ? target : Math.round(from + (target - from) * eased)
142
+ displayRef.current = next
143
+ setDisplay(next)
144
+ if (t < 1) frameRef.current = requestAnimationFrame(tick)
145
+ }
146
+ frameRef.current = requestAnimationFrame(tick)
147
+ return () => {
148
+ if (typeof cancelAnimationFrame === 'function' && frameRef.current) cancelAnimationFrame(frameRef.current)
149
+ }
150
+ }, [target, durationMs])
151
+ return display
152
+ }
153
+
40
154
  function SvgIcon({ name, size, style }) {
41
155
  const paths = ICONS[name] || []
42
156
  return react.createElement('svg', { width: size || 14, height: size || 14, viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true', style: style },
@@ -56,17 +170,54 @@ export function factory(require) {
56
170
  }, react.createElement(SvgIcon, { name: icon, size: 13 }))
57
171
  }
58
172
 
59
- function UsageCircle({ percent, size, label, onClick }) {
173
+ function UsageCircle({ percent, number, loading, stale, staleAt, onClick }) {
60
174
  const radius = 15
61
175
  const circumference = 2 * Math.PI * radius
62
176
  const pct = percent != null ? Math.min(100, Math.max(0, percent)) : 0
63
- const dim = size || 44
64
- return react.createElement('button', { type: 'button', title: 'Reload usage', 'aria-label': 'Reload usage', onClick: onClick, style: { position: 'relative', width: dim, height: dim, border: 'none', background: 'transparent', padding: 0, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' } },
65
- react.createElement('svg', { width: dim, height: dim, viewBox: '0 0 40 40' },
177
+ const dim = 44
178
+ const animated = useAnimatedNumber(percent != null ? percent : (number != null ? number : null), RING_GROWTH_MS)
179
+ const shown = animated != null ? animated : (number != null ? number : null)
180
+ const offset = circumference - (pct / 100) * circumference
181
+ const label = percent != null
182
+ ? (shown != null ? shown + '%' : '—')
183
+ : (shown != null ? String(shown) : '—')
184
+ const clock = formatClock(staleAt)
185
+ const title = loading
186
+ ? (stale && clock !== null ? 'Updating usage… showing the values loaded at ' + clock : 'Updating usage…')
187
+ : (stale && clock !== null ? 'Showing the values loaded at ' + clock + ' — click to refresh' : 'Reload usage')
188
+ return react.createElement('button', {
189
+ type: 'button',
190
+ title: title,
191
+ 'aria-label': 'Reload usage',
192
+ 'aria-busy': loading ? 'true' : 'false',
193
+ 'data-loading': loading ? 'true' : 'false',
194
+ 'data-stale': stale ? 'true' : 'false',
195
+ onClick: onClick,
196
+ style: { position: 'relative', width: dim, height: dim, border: 'none', background: 'transparent', padding: 0, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }
197
+ },
198
+ react.createElement('svg', { width: dim, height: dim, viewBox: '0 0 40 40', className: loading ? 'dts-ring dts-ring-loading' : 'dts-ring' },
66
199
  react.createElement('circle', { cx: 20, cy: 20, r: radius, fill: 'none', stroke: 'var(--dsw-alias-border-l2)', strokeWidth: 4 }),
67
- react.createElement('circle', { cx: 20, cy: 20, r: radius, fill: 'none', stroke: 'var(--dsw-alias-state-success-primary)', strokeWidth: 4, strokeLinecap: 'round', strokeDasharray: circumference, strokeDashoffset: circumference - (pct / 100) * circumference, transform: 'rotate(-90 20 20)' })
200
+ react.createElement('circle', {
201
+ className: 'dts-ring-progress',
202
+ cx: 20, cy: 20, r: radius, fill: 'none',
203
+ stroke: 'var(--dsw-alias-state-success-primary)', strokeWidth: 4, strokeLinecap: 'round',
204
+ strokeDasharray: circumference,
205
+ strokeDashoffset: offset,
206
+ transform: 'rotate(-90 20 20)',
207
+ // The offset is carried as a style value as well as an attribute:
208
+ // presentation attributes alone do not reliably transition.
209
+ style: { strokeDashoffset: offset, transition: 'stroke-dashoffset ' + RING_GROWTH_MS + 'ms cubic-bezier(.22,.61,.36,1)' }
210
+ }),
211
+ loading && react.createElement('g', { className: 'dts-ring-sweep', style: { transformOrigin: '20px 20px' } },
212
+ react.createElement('circle', {
213
+ cx: 20, cy: 20, r: radius, fill: 'none',
214
+ stroke: 'var(--dsw-alias-state-success-primary)', strokeWidth: 4, strokeLinecap: 'round',
215
+ strokeDasharray: (circumference * 0.22) + ' ' + (circumference * 0.78),
216
+ opacity: 0.45
217
+ })
218
+ )
68
219
  ),
69
- react.createElement('span', { style: { position: 'absolute', fontSize: 11, fontWeight: 600, color: 'var(--dsw-alias-label-primary)' } }, label != null ? label : (percent != null ? percent + '%' : '—'))
220
+ react.createElement('span', { className: 'dts-ring-label', style: { position: 'absolute', fontSize: 11, fontWeight: 600, color: 'var(--dsw-alias-label-primary)' } }, label)
70
221
  )
71
222
  }
72
223
 
@@ -84,39 +235,98 @@ export function factory(require) {
84
235
  const [notice, setNotice] = react.useState(null)
85
236
  const [usage, setUsage] = react.useState(null)
86
237
  const [usageError, setUsageError] = react.useState(null)
238
+ const [usageLoading, setUsageLoading] = react.useState(true)
239
+ const [snapshot, setSnapshot] = react.useState(() => readUsageSnapshot())
87
240
 
88
241
  const refresh = react.useCallback(async () => {
242
+ setUsageLoading(true)
243
+ // The two endpoints are independent and the usage one is the slow half,
244
+ // so they run together: the keys must not wait for Tavily's counters.
245
+ const keys = (async () => {
246
+ try {
247
+ const response = await fetch('/api/tavily-manager', { cache: 'no-store' })
248
+ const data = await response.json()
249
+ if (data.ok) { setServer(data); setStrategy(data.strategy) } else { setLoadError(data.error || 'Failed to load keys') }
250
+ } catch (error) {
251
+ setLoadError(String(error && error.message ? error.message : error))
252
+ }
253
+ })()
254
+ const counters = (async () => {
255
+ try {
256
+ const response = await fetch('/api/tavily-usage', { cache: 'no-store' })
257
+ const data = await response.json()
258
+ setUsage(data)
259
+ setUsageError(null)
260
+ if (data.ok) {
261
+ writeUsageSnapshot(data)
262
+ setSnapshot(readUsageSnapshot())
263
+ }
264
+ } catch (error) {
265
+ setUsageError(String(error && error.message ? error.message : error))
266
+ } finally {
267
+ setUsageLoading(false)
268
+ }
269
+ })()
270
+ await Promise.all([keys, counters])
271
+ }, [])
272
+
273
+ react.useEffect(() => { refresh() }, [refresh])
274
+
275
+ const saveStrategy = async (next) => {
276
+ setBusy(true)
277
+ setNotice(null)
89
278
  try {
90
- const response = await fetch('/api/tavily-manager', { cache: 'no-store' })
279
+ const response = await fetch('/api/tavily-manager', {
280
+ method: 'POST',
281
+ headers: { 'content-type': 'application/json' },
282
+ body: JSON.stringify({ add: [], remove: [], strategy: next })
283
+ })
91
284
  const data = await response.json()
92
- if (data.ok) { setServer(data); setStrategy(data.strategy) } else { setLoadError(data.error || 'Failed to load keys') }
285
+ if (!data.ok) { setNotice({ error: data.error || 'Strategy save failed' }); return }
286
+ setNotice({ ok: 'Strategy saved — effective on the next search.' })
287
+ await refresh()
93
288
  } catch (error) {
94
- setLoadError(String(error && error.message ? error.message : error))
289
+ setNotice({ error: String(error && error.message ? error.message : error) })
290
+ } finally {
291
+ setBusy(false)
95
292
  }
293
+ }
294
+
295
+ // The account grouping is inferred from Tavily's usage counters, which is
296
+ // the only account-level signal the API offers; these two handlers are the
297
+ // human override for when the inference is wrong or the cap needs raising.
298
+ const saveAccount = async (masked, choice) => {
299
+ setBusy(true)
300
+ setNotice(null)
96
301
  try {
97
- const response = await fetch('/api/tavily-usage', { cache: 'no-store' })
302
+ const response = await fetch('/api/tavily-manager', {
303
+ method: 'POST',
304
+ headers: { 'content-type': 'application/json' },
305
+ body: JSON.stringify({ add: [], remove: [], strategy, accounts: { [masked]: choice } })
306
+ })
98
307
  const data = await response.json()
99
- setUsage(data)
100
- setUsageError(null)
308
+ if (!data.ok) { setNotice({ error: data.error || 'Account change failed' }); return }
309
+ setNotice({ ok: choice === 'auto' ? 'Account detected automatically again.' : 'Account pinned for this key.' })
310
+ await refresh()
101
311
  } catch (error) {
102
- setUsageError(String(error && error.message ? error.message : error))
312
+ setNotice({ error: String(error && error.message ? error.message : error) })
313
+ } finally {
314
+ setBusy(false)
103
315
  }
104
- }, [])
105
-
106
- react.useEffect(() => { refresh() }, [refresh])
316
+ }
107
317
 
108
- const saveStrategy = async (next) => {
318
+ const saveCap = async (value) => {
109
319
  setBusy(true)
110
320
  setNotice(null)
111
321
  try {
112
322
  const response = await fetch('/api/tavily-manager', {
113
323
  method: 'POST',
114
324
  headers: { 'content-type': 'application/json' },
115
- body: JSON.stringify({ add: [], remove: [], strategy: next })
325
+ body: JSON.stringify({ add: [], remove: [], strategy, accountCap: Number(value) })
116
326
  })
117
327
  const data = await response.json()
118
- if (!data.ok) { setNotice({ error: data.error || 'Strategy save failed' }); return }
119
- setNotice({ ok: 'Strategy saved — effective on the next search.' })
328
+ if (!data.ok) { setNotice({ error: data.error || 'Cap save failed' }); return }
329
+ setNotice({ ok: 'Concurrency cap saved — effective on the next search.' })
120
330
  await refresh()
121
331
  } catch (error) {
122
332
  setNotice({ error: String(error && error.message ? error.message : error) })
@@ -244,12 +454,45 @@ export function factory(require) {
244
454
 
245
455
  const headStyle = { textAlign: 'left', padding: '6px 8px', borderBottom: '1px solid var(--dsw-alias-border-l2)', fontSize: 12, color: 'var(--dsw-alias-label-tertiary)' }
246
456
  const cellStyle = { padding: '8px', verticalAlign: 'top' }
457
+ const snapshotRows = snapshot !== null && typeof snapshot === 'object' && snapshot.rows !== null && typeof snapshot.rows === 'object'
458
+ ? snapshot.rows
459
+ : null
460
+ const snapshotAt = snapshot !== null && typeof snapshot === 'object' ? snapshot.at : null
461
+
462
+ // The usage response carries the freshest inference (it is the route that
463
+ // runs it); the manager payload is the fallback, and the only source of the
464
+ // policy before the first usage answer arrives.
465
+ const accountsMap = usage !== null && usage.ok === true && usage.accounts !== null && typeof usage.accounts === 'object'
466
+ ? usage.accounts
467
+ : (server !== null && server.accounts !== null && server.accounts !== undefined && typeof server.accounts === 'object' ? server.accounts : {})
468
+ const accountCap = usage !== null && usage.ok === true && typeof usage.accountCap === 'number'
469
+ ? usage.accountCap
470
+ : (server !== null && typeof server.accountCap === 'number' ? server.accountCap : 1)
471
+ const accountOf = (masked) => {
472
+ const entry = accountsMap[masked]
473
+ const group = entry !== null && entry !== undefined && typeof entry === 'object' && typeof entry.group === 'string' ? entry.group : null
474
+ return {
475
+ group,
476
+ own: group !== null && group.indexOf('own:') === 0,
477
+ manual: entry !== null && entry !== undefined && entry.manual === true,
478
+ }
479
+ }
480
+ const accountLabel = (group) => {
481
+ const name = /^acct-(\d+)$/.exec(group)
482
+ return name === null ? group : 'Account ' + name[1]
483
+ }
484
+ const groupChoices = []
485
+ for (const key of (server !== null ? server.keys : [])) {
486
+ const { group, own } = accountOf(key.masked)
487
+ if (group === null || own) continue
488
+ if (!groupChoices.includes(group)) groupChoices.push(group)
489
+ }
247
490
 
248
491
  // The Plugins page draws the title, the icon, and the crumb — the
249
492
  // `plugins.bundle.config` contract puts the form alone in the entry — so
250
493
  // this renders no card chrome, no second heading, and no collapse.
251
494
  return react.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 16, minWidth: 0 } },
252
- react.createElement('style', null, '.dts-icon-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}.dts-icon-btn:disabled{opacity:.4;cursor:default}.dts-icon-btn-danger:hover{background:var(--dsw-alias-interactive-bg-hover-danger)}'),
495
+ react.createElement('style', null, '.dts-icon-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}.dts-icon-btn:disabled{opacity:.4;cursor:default}.dts-icon-btn-danger:hover{background:var(--dsw-alias-interactive-bg-hover-danger)}@keyframes dts-ring-spin{to{transform:rotate(360deg)}}@keyframes dts-ring-pulse{0%,100%{opacity:1}50%{opacity:.45}}.dts-ring-loading{animation:dts-ring-pulse 1.6s ease-in-out infinite}.dts-ring-sweep{animation:dts-ring-spin 1.1s linear infinite}@media (prefers-reduced-motion: reduce){.dts-ring-loading,.dts-ring-sweep{animation:none}.dts-ring-progress{transition:none!important}}'),
253
496
  react.createElement(react.Fragment, null,
254
497
  react.createElement('p', { style: { margin: 0, fontSize: 13, color: 'var(--dsw-alias-label-tertiary)' } },
255
498
  'All keys are listed below; the green dot marks the first (primary) key. The tavily_search tool uses all keys according to the strategy. Built-in web_search is unaffected.'
@@ -263,6 +506,7 @@ export function factory(require) {
263
506
  react.createElement('thead', null,
264
507
  react.createElement('tr', null,
265
508
  react.createElement('th', { style: headStyle }, 'Key'),
509
+ react.createElement('th', { style: headStyle }, 'Account'),
266
510
  react.createElement('th', { style: headStyle }, 'Usage'),
267
511
  react.createElement('th', { style: headStyle }, 'Saved'),
268
512
  react.createElement('th', { style: headStyle }, 'Actions')
@@ -274,12 +518,27 @@ export function factory(require) {
274
518
  const isRemoved = removing[masked] === true
275
519
  const isReplacing = replacing[masked] === true
276
520
  const isRevealed = typeof revealed[masked] === 'string'
277
- const usageRow = usage !== null && usage.ok === true
521
+ // Fresh counters win; until they arrive the last stored row is
522
+ // shown so the cell reads as "updating", never as "empty".
523
+ const freshRow = usage !== null && usage.ok === true
278
524
  ? (usage.perKey.find((row) => row.masked === masked) || usage.perKey[index])
279
525
  : null
280
- const pct = usageRow && usageRow.ok && usageRow.planLimit != null && usageRow.planLimit > 0 && usageRow.planUsage != null
281
- ? Math.min(100, Math.round((usageRow.planUsage / usageRow.planLimit) * 100))
526
+ const storedRow = snapshotRows !== null && Object.prototype.hasOwnProperty.call(snapshotRows, masked)
527
+ ? snapshotRows[masked]
528
+ : null
529
+ const usageRow = freshRow !== null && freshRow !== undefined ? freshRow : storedRow
530
+ const pct = usagePercent(usageRow)
531
+ const rawNumber = pct === null && usageRow !== null && usageRow !== undefined && usageRow.ok === true && usageRow.planUsage != null
532
+ ? usageRow.planUsage
282
533
  : null
534
+ const isStale = (freshRow === null || freshRow === undefined) && storedRow !== null
535
+ // Which Tavily account this key belongs to, and the override.
536
+ const account = accountOf(masked)
537
+ const currentChoice = account.manual ? (account.own ? 'own' : account.group) : 'auto'
538
+ const accountChoices = ['auto'].concat(groupChoices).concat(['own'])
539
+ if (currentChoice !== 'auto' && !accountChoices.includes(currentChoice)) {
540
+ accountChoices.splice(1, 0, currentChoice)
541
+ }
283
542
  return react.createElement('tr', { key: masked, style: { borderBottom: '1px solid var(--dsw-alias-border-l2)', opacity: isRemoved ? 0.45 : 1 } },
284
543
  react.createElement('td', { style: Object.assign({}, cellStyle, { fontFamily: 'var(--ds-font-family-code, monospace)', fontSize: 12, wordBreak: 'break-all' }) },
285
544
  isReplacing
@@ -299,7 +558,34 @@ export function factory(require) {
299
558
  )
300
559
  ),
301
560
  react.createElement('td', { style: cellStyle },
302
- react.createElement(UsageCircle, { percent: pct, label: pct != null ? pct + '%' : (usageRow && usageRow.ok && usageRow.planUsage != null ? String(usageRow.planUsage) : '—'), onClick: refresh })
561
+ react.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 4 } },
562
+ react.createElement('span', {
563
+ className: 'dts-account-chip',
564
+ style: { fontSize: 11, color: account.group === null ? 'var(--dsw-alias-label-tertiary)' : 'var(--dsw-alias-label-secondary)' }
565
+ }, account.group === null ? 'Ungrouped' : (account.own ? 'Separate' : accountLabel(account.group))),
566
+ react.createElement('select', {
567
+ className: 'dts-account',
568
+ 'data-masked': masked,
569
+ title: 'Which Tavily account this key counts against. Auto follows the grouping inferred from usage; Separate gives the key its own account, so it is never held back by another key.',
570
+ value: currentChoice,
571
+ disabled: busy,
572
+ onChange: (event) => saveAccount(masked, event.target.value),
573
+ style: { height: 26, borderRadius: 6, border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-1)', color: 'var(--dsw-alias-label-primary)', fontSize: 11, maxWidth: 130 }
574
+ },
575
+ accountChoices.map((choice) => react.createElement('option', { key: choice, value: choice },
576
+ choice === 'auto' ? 'Auto' : (choice === 'own' ? 'Separate' : accountLabel(choice))))
577
+ )
578
+ )
579
+ ),
580
+ react.createElement('td', { style: cellStyle },
581
+ react.createElement(UsageCircle, {
582
+ percent: pct,
583
+ number: rawNumber,
584
+ loading: usageLoading,
585
+ stale: isStale,
586
+ staleAt: snapshotAt,
587
+ onClick: refresh
588
+ })
303
589
  ),
304
590
  react.createElement('td', { style: Object.assign({}, cellStyle, { color: 'var(--dsw-alias-label-tertiary)', whiteSpace: 'nowrap' }) }, formatDate(key.savedAt)),
305
591
  react.createElement('td', { style: cellStyle },
@@ -340,6 +626,7 @@ export function factory(require) {
340
626
  ),
341
627
  react.createElement('td', { style: cellStyle }, '—'),
342
628
  react.createElement('td', { style: cellStyle }, '—'),
629
+ react.createElement('td', { style: cellStyle }, '—'),
343
630
  react.createElement('td', { style: cellStyle },
344
631
  react.createElement('span', { style: { display: 'inline-flex', gap: 2 } },
345
632
  react.createElement(IconButton, { icon: 'check', title: 'Save key', onClick: () => saveAdd(item), disabled: busy || item.value.trim().length === 0 }),
@@ -356,11 +643,21 @@ export function factory(require) {
356
643
  react.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
357
644
  react.createElement('label', { style: { fontWeight: 500, fontSize: 13 } }, 'Key usage strategy'),
358
645
  react.createElement('select', {
646
+ className: 'dts-strategy',
359
647
  style: { maxWidth: 280, height: 32, border: '1px solid var(--dsw-alias-border-l2)', borderRadius: 8, background: 'var(--dsw-alias-bg-layer-1)', color: 'var(--dsw-alias-label-primary)', padding: '0 10px', fontSize: 13 },
360
648
  value: strategy,
361
649
  onChange: (event) => saveStrategy(event.target.value),
362
650
  disabled: busy
363
651
  }, STRATEGIES.map((option) => react.createElement('option', { key: option.id, value: option.id }, option.label))),
652
+ strategy === 'load-balance' && react.createElement('label', { style: { fontWeight: 500, fontSize: 13, marginLeft: 8 } }, 'Per account'),
653
+ strategy === 'load-balance' && react.createElement('select', {
654
+ className: 'dts-account-cap',
655
+ title: 'How many requests may be in flight for one Tavily account at a time. One suits a free key.',
656
+ style: { height: 32, border: '1px solid var(--dsw-alias-border-l2)', borderRadius: 8, background: 'var(--dsw-alias-bg-layer-1)', color: 'var(--dsw-alias-label-primary)', padding: '0 10px', fontSize: 13 },
657
+ value: String(accountCap),
658
+ onChange: (event) => saveCap(event.target.value),
659
+ disabled: busy
660
+ }, ACCOUNT_CAPS.map((n) => react.createElement('option', { key: n, value: String(n) }, String(n) + (n === 1 ? ' request' : ' requests')))),
364
661
  notice !== null && react.createElement('span', { style: { fontSize: 13, color: notice.ok ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-error-primary)' } }, notice.ok ? notice.ok : String(notice.error))
365
662
  ),
366
663
  react.createElement('p', { style: { margin: 0, fontSize: 12, color: 'var(--dsw-alias-label-tertiary)' } },