@moguiyu/dsh-tavily 0.3.3 → 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/README.md CHANGED
@@ -29,4 +29,5 @@ which is exactly how the old tool-level switch broke. Both old packages are depr
29
29
  There is no tool-level on/off switch. Composing the plugin registers the tools, and the Plugins
30
30
  page's own bundle toggle is the only on/off.
31
31
 
32
- See the [workspace README](../../README.md) for install and configuration.
32
+ See the [workspace README](https://github.com/moguiyu/dsh-tavily#readme) for install and
33
+ configuration.
package/lib/client.js CHANGED
@@ -6,11 +6,18 @@ window.__ModuleLoader__.load({
6
6
  const inject = ['slots']
7
7
 
8
8
  const STRATEGIES = [
9
- { id: 'rotate', label: 'Round-robin', hint: 'Use keys in turn; on HTTP 401/429 the next key is tried automatically.' },
9
+ { id: 'rotate', label: 'Round-robin', hint: 'Use keys in turn; on HTTP 401/429/432/433 the next key is tried automatically.' },
10
10
  { id: 'low-usage-first', label: 'Lowest usage first', hint: 'Re-orders keys by current Tavily usage, least-used first. The first key becomes primary.' },
11
- { id: 'high-usage-first', label: 'Highest usage first', hint: 'Re-orders keys by current Tavily usage, most-used first. The first key becomes primary.' }
11
+ { id: 'high-usage-first', label: 'Highest usage first', hint: 'Re-orders keys by current Tavily usage, most-used first. The first key becomes primary.' },
12
+ {
13
+ id: 'load-balance',
14
+ label: 'Load balance',
15
+ 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.'
16
+ }
12
17
  ]
13
18
 
19
+ const ACCOUNT_CAPS = [1, 2, 3, 4, 5, 6, 7, 8]
20
+
14
21
  const ICONS = {
15
22
  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'],
16
23
  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'],
@@ -280,6 +287,49 @@ window.__ModuleLoader__.load({
280
287
  }
281
288
  }
282
289
 
290
+ // The account grouping is inferred from Tavily's usage counters, which is
291
+ // the only account-level signal the API offers; these two handlers are the
292
+ // human override for when the inference is wrong or the cap needs raising.
293
+ const saveAccount = async (masked, choice) => {
294
+ setBusy(true)
295
+ setNotice(null)
296
+ try {
297
+ const response = await fetch('/api/tavily-manager', {
298
+ method: 'POST',
299
+ headers: { 'content-type': 'application/json' },
300
+ body: JSON.stringify({ add: [], remove: [], strategy, accounts: { [masked]: choice } })
301
+ })
302
+ const data = await response.json()
303
+ if (!data.ok) { setNotice({ error: data.error || 'Account change failed' }); return }
304
+ setNotice({ ok: choice === 'auto' ? 'Account detected automatically again.' : 'Account pinned for this key.' })
305
+ await refresh()
306
+ } catch (error) {
307
+ setNotice({ error: String(error && error.message ? error.message : error) })
308
+ } finally {
309
+ setBusy(false)
310
+ }
311
+ }
312
+
313
+ const saveCap = async (value) => {
314
+ setBusy(true)
315
+ setNotice(null)
316
+ try {
317
+ const response = await fetch('/api/tavily-manager', {
318
+ method: 'POST',
319
+ headers: { 'content-type': 'application/json' },
320
+ body: JSON.stringify({ add: [], remove: [], strategy, accountCap: Number(value) })
321
+ })
322
+ const data = await response.json()
323
+ if (!data.ok) { setNotice({ error: data.error || 'Cap save failed' }); return }
324
+ setNotice({ ok: 'Concurrency cap saved — effective on the next search.' })
325
+ await refresh()
326
+ } catch (error) {
327
+ setNotice({ error: String(error && error.message ? error.message : error) })
328
+ } finally {
329
+ setBusy(false)
330
+ }
331
+ }
332
+
283
333
  const saveAdd = async (item) => {
284
334
  const value = item.value.trim()
285
335
  if (!value) return
@@ -404,6 +454,35 @@ window.__ModuleLoader__.load({
404
454
  : null
405
455
  const snapshotAt = snapshot !== null && typeof snapshot === 'object' ? snapshot.at : null
406
456
 
457
+ // The usage response carries the freshest inference (it is the route that
458
+ // runs it); the manager payload is the fallback, and the only source of the
459
+ // policy before the first usage answer arrives.
460
+ const accountsMap = usage !== null && usage.ok === true && usage.accounts !== null && typeof usage.accounts === 'object'
461
+ ? usage.accounts
462
+ : (server !== null && server.accounts !== null && server.accounts !== undefined && typeof server.accounts === 'object' ? server.accounts : {})
463
+ const accountCap = usage !== null && usage.ok === true && typeof usage.accountCap === 'number'
464
+ ? usage.accountCap
465
+ : (server !== null && typeof server.accountCap === 'number' ? server.accountCap : 1)
466
+ const accountOf = (masked) => {
467
+ const entry = accountsMap[masked]
468
+ const group = entry !== null && entry !== undefined && typeof entry === 'object' && typeof entry.group === 'string' ? entry.group : null
469
+ return {
470
+ group,
471
+ own: group !== null && group.indexOf('own:') === 0,
472
+ manual: entry !== null && entry !== undefined && entry.manual === true,
473
+ }
474
+ }
475
+ const accountLabel = (group) => {
476
+ const name = /^acct-(\d+)$/.exec(group)
477
+ return name === null ? group : 'Account ' + name[1]
478
+ }
479
+ const groupChoices = []
480
+ for (const key of (server !== null ? server.keys : [])) {
481
+ const { group, own } = accountOf(key.masked)
482
+ if (group === null || own) continue
483
+ if (!groupChoices.includes(group)) groupChoices.push(group)
484
+ }
485
+
407
486
  // The Plugins page draws the title, the icon, and the crumb — the
408
487
  // `plugins.bundle.config` contract puts the form alone in the entry — so
409
488
  // this renders no card chrome, no second heading, and no collapse.
@@ -422,6 +501,7 @@ window.__ModuleLoader__.load({
422
501
  react.createElement('thead', null,
423
502
  react.createElement('tr', null,
424
503
  react.createElement('th', { style: headStyle }, 'Key'),
504
+ react.createElement('th', { style: headStyle }, 'Account'),
425
505
  react.createElement('th', { style: headStyle }, 'Usage'),
426
506
  react.createElement('th', { style: headStyle }, 'Saved'),
427
507
  react.createElement('th', { style: headStyle }, 'Actions')
@@ -447,6 +527,13 @@ window.__ModuleLoader__.load({
447
527
  ? usageRow.planUsage
448
528
  : null
449
529
  const isStale = (freshRow === null || freshRow === undefined) && storedRow !== null
530
+ // Which Tavily account this key belongs to, and the override.
531
+ const account = accountOf(masked)
532
+ const currentChoice = account.manual ? (account.own ? 'own' : account.group) : 'auto'
533
+ const accountChoices = ['auto'].concat(groupChoices).concat(['own'])
534
+ if (currentChoice !== 'auto' && !accountChoices.includes(currentChoice)) {
535
+ accountChoices.splice(1, 0, currentChoice)
536
+ }
450
537
  return react.createElement('tr', { key: masked, style: { borderBottom: '1px solid var(--dsw-alias-border-l2)', opacity: isRemoved ? 0.45 : 1 } },
451
538
  react.createElement('td', { style: Object.assign({}, cellStyle, { fontFamily: 'var(--ds-font-family-code, monospace)', fontSize: 12, wordBreak: 'break-all' }) },
452
539
  isReplacing
@@ -465,6 +552,26 @@ window.__ModuleLoader__.load({
465
552
  })
466
553
  )
467
554
  ),
555
+ react.createElement('td', { style: cellStyle },
556
+ react.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 4 } },
557
+ react.createElement('span', {
558
+ className: 'dts-account-chip',
559
+ style: { fontSize: 11, color: account.group === null ? 'var(--dsw-alias-label-tertiary)' : 'var(--dsw-alias-label-secondary)' }
560
+ }, account.group === null ? 'Ungrouped' : (account.own ? 'Separate' : accountLabel(account.group))),
561
+ react.createElement('select', {
562
+ className: 'dts-account',
563
+ 'data-masked': masked,
564
+ 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.',
565
+ value: currentChoice,
566
+ disabled: busy,
567
+ onChange: (event) => saveAccount(masked, event.target.value),
568
+ 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 }
569
+ },
570
+ accountChoices.map((choice) => react.createElement('option', { key: choice, value: choice },
571
+ choice === 'auto' ? 'Auto' : (choice === 'own' ? 'Separate' : accountLabel(choice))))
572
+ )
573
+ )
574
+ ),
468
575
  react.createElement('td', { style: cellStyle },
469
576
  react.createElement(UsageCircle, {
470
577
  percent: pct,
@@ -514,6 +621,7 @@ window.__ModuleLoader__.load({
514
621
  ),
515
622
  react.createElement('td', { style: cellStyle }, '—'),
516
623
  react.createElement('td', { style: cellStyle }, '—'),
624
+ react.createElement('td', { style: cellStyle }, '—'),
517
625
  react.createElement('td', { style: cellStyle },
518
626
  react.createElement('span', { style: { display: 'inline-flex', gap: 2 } },
519
627
  react.createElement(IconButton, { icon: 'check', title: 'Save key', onClick: () => saveAdd(item), disabled: busy || item.value.trim().length === 0 }),
@@ -530,11 +638,21 @@ window.__ModuleLoader__.load({
530
638
  react.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
531
639
  react.createElement('label', { style: { fontWeight: 500, fontSize: 13 } }, 'Key usage strategy'),
532
640
  react.createElement('select', {
641
+ className: 'dts-strategy',
533
642
  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 },
534
643
  value: strategy,
535
644
  onChange: (event) => saveStrategy(event.target.value),
536
645
  disabled: busy
537
646
  }, STRATEGIES.map((option) => react.createElement('option', { key: option.id, value: option.id }, option.label))),
647
+ strategy === 'load-balance' && react.createElement('label', { style: { fontWeight: 500, fontSize: 13, marginLeft: 8 } }, 'Per account'),
648
+ strategy === 'load-balance' && react.createElement('select', {
649
+ className: 'dts-account-cap',
650
+ title: 'How many requests may be in flight for one Tavily account at a time. One suits a free key.',
651
+ 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 },
652
+ value: String(accountCap),
653
+ onChange: (event) => saveCap(event.target.value),
654
+ disabled: busy
655
+ }, ACCOUNT_CAPS.map((n) => react.createElement('option', { key: n, value: String(n) }, String(n) + (n === 1 ? ' request' : ' requests')))),
538
656
  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))
539
657
  ),
540
658
  react.createElement('p', { style: { margin: 0, fontSize: 12, color: 'var(--dsw-alias-label-tertiary)' } },
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@moguiyu/dsh-tavily",
3
- "version": "0.3.3",
4
- "description": "Advanced Tavily model tools for DeepSeek Harness (tavily_search, tavily_extract, tavily_map, tavily_crawl) with a key and usage management card on the Plugins page. The built-in web_search is never replaced.",
3
+ "version": "0.3.4",
4
+ "description": "Advanced Tavily model tools for DeepSeek Harness (tavily_search, tavily_extract, tavily_map, tavily_crawl) with multi-key rotation, failover and a per-account in-flight cap, plus a key and usage management card on the Plugins page. The built-in web_search is never replaced. | DSH Tavily 可选高级搜索工具:多 Key 轮换、故障转移与按账号并发上限,插件配置页含密钥与用量管理卡片;内置 web_search 永不替换。",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "exports": {
@@ -64,7 +64,8 @@
64
64
  ],
65
65
  "repository": {
66
66
  "type": "git",
67
- "url": "https://github.com/moguiyu/dsh-tavily.git"
67
+ "url": "git+https://github.com/moguiyu/dsh-tavily.git",
68
+ "directory": "packages/dsh-tavily"
68
69
  },
69
70
  "peerDependencies": {
70
71
  "@deepseek-ai/cordis": "^4.0.1",
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'],
@@ -285,6 +292,49 @@ export function factory(require) {
285
292
  }
286
293
  }
287
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)
301
+ try {
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
+ })
307
+ const data = await response.json()
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()
311
+ } catch (error) {
312
+ setNotice({ error: String(error && error.message ? error.message : error) })
313
+ } finally {
314
+ setBusy(false)
315
+ }
316
+ }
317
+
318
+ const saveCap = async (value) => {
319
+ setBusy(true)
320
+ setNotice(null)
321
+ try {
322
+ const response = await fetch('/api/tavily-manager', {
323
+ method: 'POST',
324
+ headers: { 'content-type': 'application/json' },
325
+ body: JSON.stringify({ add: [], remove: [], strategy, accountCap: Number(value) })
326
+ })
327
+ const data = await response.json()
328
+ if (!data.ok) { setNotice({ error: data.error || 'Cap save failed' }); return }
329
+ setNotice({ ok: 'Concurrency cap saved — effective on the next search.' })
330
+ await refresh()
331
+ } catch (error) {
332
+ setNotice({ error: String(error && error.message ? error.message : error) })
333
+ } finally {
334
+ setBusy(false)
335
+ }
336
+ }
337
+
288
338
  const saveAdd = async (item) => {
289
339
  const value = item.value.trim()
290
340
  if (!value) return
@@ -409,6 +459,35 @@ export function factory(require) {
409
459
  : null
410
460
  const snapshotAt = snapshot !== null && typeof snapshot === 'object' ? snapshot.at : null
411
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
+ }
490
+
412
491
  // The Plugins page draws the title, the icon, and the crumb — the
413
492
  // `plugins.bundle.config` contract puts the form alone in the entry — so
414
493
  // this renders no card chrome, no second heading, and no collapse.
@@ -427,6 +506,7 @@ export function factory(require) {
427
506
  react.createElement('thead', null,
428
507
  react.createElement('tr', null,
429
508
  react.createElement('th', { style: headStyle }, 'Key'),
509
+ react.createElement('th', { style: headStyle }, 'Account'),
430
510
  react.createElement('th', { style: headStyle }, 'Usage'),
431
511
  react.createElement('th', { style: headStyle }, 'Saved'),
432
512
  react.createElement('th', { style: headStyle }, 'Actions')
@@ -452,6 +532,13 @@ export function factory(require) {
452
532
  ? usageRow.planUsage
453
533
  : null
454
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
+ }
455
542
  return react.createElement('tr', { key: masked, style: { borderBottom: '1px solid var(--dsw-alias-border-l2)', opacity: isRemoved ? 0.45 : 1 } },
456
543
  react.createElement('td', { style: Object.assign({}, cellStyle, { fontFamily: 'var(--ds-font-family-code, monospace)', fontSize: 12, wordBreak: 'break-all' }) },
457
544
  isReplacing
@@ -470,6 +557,26 @@ export function factory(require) {
470
557
  })
471
558
  )
472
559
  ),
560
+ react.createElement('td', { style: cellStyle },
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
+ ),
473
580
  react.createElement('td', { style: cellStyle },
474
581
  react.createElement(UsageCircle, {
475
582
  percent: pct,
@@ -519,6 +626,7 @@ export function factory(require) {
519
626
  ),
520
627
  react.createElement('td', { style: cellStyle }, '—'),
521
628
  react.createElement('td', { style: cellStyle }, '—'),
629
+ react.createElement('td', { style: cellStyle }, '—'),
522
630
  react.createElement('td', { style: cellStyle },
523
631
  react.createElement('span', { style: { display: 'inline-flex', gap: 2 } },
524
632
  react.createElement(IconButton, { icon: 'check', title: 'Save key', onClick: () => saveAdd(item), disabled: busy || item.value.trim().length === 0 }),
@@ -535,11 +643,21 @@ export function factory(require) {
535
643
  react.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
536
644
  react.createElement('label', { style: { fontWeight: 500, fontSize: 13 } }, 'Key usage strategy'),
537
645
  react.createElement('select', {
646
+ className: 'dts-strategy',
538
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 },
539
648
  value: strategy,
540
649
  onChange: (event) => saveStrategy(event.target.value),
541
650
  disabled: busy
542
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')))),
543
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))
544
662
  ),
545
663
  react.createElement('p', { style: { margin: 0, fontSize: 12, color: 'var(--dsw-alias-label-tertiary)' } },
package/src/lib.js CHANGED
@@ -3,12 +3,130 @@
3
3
  */
4
4
  import { readFileSync } from 'node:fs'
5
5
 
6
- export const STRATEGIES = ['rotate', 'low-usage-first', 'high-usage-first']
6
+ export const STRATEGIES = ['rotate', 'low-usage-first', 'high-usage-first', 'load-balance']
7
7
 
8
8
  export function isValidStrategy(value) {
9
9
  return STRATEGIES.includes(value)
10
10
  }
11
11
 
12
+ /**
13
+ * Concurrent requests allowed per Tavily account in `load-balance` mode. One is
14
+ * the safe default: Tavily's published limits are per environment/key
15
+ * (100 requests/minute on a free Development key) and every key of one account
16
+ * shares that budget and its credit pool.
17
+ */
18
+ export const DEFAULT_ACCOUNT_CAP = 1
19
+ export const MAX_ACCOUNT_CAP = 8
20
+
21
+ export function clampAccountCap(value) {
22
+ const n = Number(value)
23
+ if (!Number.isFinite(n)) return DEFAULT_ACCOUNT_CAP
24
+ return Math.min(MAX_ACCOUNT_CAP, Math.max(DEFAULT_ACCOUNT_CAP, Math.round(n)))
25
+ }
26
+
27
+ /** The `/usage` `account` fields that identify an account; order is canonical. */
28
+ const ACCOUNT_FIELDS = [
29
+ 'current_plan', 'plan', 'plan_limit', 'plan_usage', 'paygo_limit', 'paygo_usage',
30
+ 'search_usage', 'extract_usage', 'map_usage', 'crawl_usage', 'research_usage',
31
+ ]
32
+
33
+ /**
34
+ * Fingerprint a Tavily `/usage` `account` block. Tavily publishes no account id,
35
+ * but every key of one account reports the identical account block at the same
36
+ * moment (observed live: two keys both at search_usage 846 / extract 129 / map
37
+ * 23), so an equal fingerprint is the only account-level signal available.
38
+ * Returns `null` when the block carries no recognizable field, which means
39
+ * "makes no claim about the account" rather than "its own account".
40
+ */
41
+ export function accountFingerprint(account) {
42
+ if (account === null || account === undefined || typeof account !== 'object' || Array.isArray(account)) return null
43
+ const parts = []
44
+ for (const field of ACCOUNT_FIELDS) {
45
+ const value = account[field]
46
+ if (value === undefined || value === null) continue
47
+ parts.push(field + '=' + String(value))
48
+ }
49
+ return parts.length === 0 ? null : parts.join('|')
50
+ }
51
+
52
+ /**
53
+ * Assign stable account group ids to the stored keys.
54
+ *
55
+ * `entries` is `[{ masked, fingerprint }]` in credential-list order; `previous`
56
+ * is the persisted `accounts` map. A `manual: true` entry is kept verbatim —
57
+ * that is the user's override — and offers its group id to keys that turn out
58
+ * to share its account. Inferred ids survive a re-run: a cluster carries over
59
+ * the id its own members already had instead of being renumbered, so usage
60
+ * drifting between fetches cannot reshuffle the groups.
61
+ */
62
+ export function groupAccounts(entries, previous) {
63
+ const prior = previous !== null && previous !== undefined && typeof previous === 'object' && !Array.isArray(previous)
64
+ ? previous
65
+ : {}
66
+ const assigned = {}
67
+ const used = new Set()
68
+ const manualGroupByFingerprint = new Map()
69
+
70
+ for (const entry of entries) {
71
+ const before = prior[entry.masked]
72
+ if (before === null || before === undefined || typeof before !== 'object') continue
73
+ if (before.manual !== true) continue
74
+ if (typeof before.group !== 'string' || before.group.length === 0) continue
75
+ assigned[entry.masked] = { group: before.group, manual: true }
76
+ used.add(before.group)
77
+ if (typeof entry.fingerprint === 'string' && entry.fingerprint.length > 0 && !manualGroupByFingerprint.has(entry.fingerprint)) {
78
+ manualGroupByFingerprint.set(entry.fingerprint, before.group)
79
+ }
80
+ }
81
+
82
+ const clusters = new Map()
83
+ for (const entry of entries) {
84
+ if (assigned[entry.masked] !== undefined) continue
85
+ const fingerprint = typeof entry.fingerprint === 'string' && entry.fingerprint.length > 0 ? entry.fingerprint : null
86
+ if (fingerprint === null) continue
87
+ const members = clusters.get(fingerprint)
88
+ if (members === undefined) clusters.set(fingerprint, [entry.masked])
89
+ else members.push(entry.masked)
90
+ }
91
+
92
+ let next = 1
93
+ for (const [fingerprint, members] of clusters) {
94
+ const carried = new Map()
95
+ for (const masked of members) {
96
+ const before = prior[masked]
97
+ if (before !== null && before !== undefined && typeof before === 'object' &&
98
+ typeof before.group === 'string' && before.group.length > 0) {
99
+ carried.set(before.group, (carried.get(before.group) ?? 0) + 1)
100
+ }
101
+ }
102
+ let group = manualGroupByFingerprint.get(fingerprint)
103
+ if (group === undefined) {
104
+ let best = null
105
+ let bestCount = 0
106
+ for (const [id, count] of carried) {
107
+ if (count > bestCount) {
108
+ best = id
109
+ bestCount = count
110
+ }
111
+ }
112
+ if (best !== null) group = best
113
+ }
114
+ if (group === undefined) {
115
+ while (used.has('acct-' + next)) next += 1
116
+ group = 'acct-' + next
117
+ next += 1
118
+ }
119
+ used.add(group)
120
+ for (const masked of members) assigned[masked] = { group, manual: false }
121
+ }
122
+
123
+ const out = {}
124
+ for (const entry of entries) {
125
+ if (assigned[entry.masked] !== undefined) out[entry.masked] = assigned[entry.masked]
126
+ }
127
+ return out
128
+ }
129
+
12
130
  /**
13
131
  * Mask a key for display: `first12…last4`; comma-separated lists are masked
14
132
  * per part. Never reveals more than a fingerprint.
@@ -39,10 +157,12 @@ export function parseKeyList(value) {
39
157
 
40
158
  /**
41
159
  * Order keys according to a strategy. `usageOf(key)` returns a number or null
42
- * (unknown usage is treated as 0). `rotate` keeps the given order.
160
+ * (unknown usage is treated as 0). `rotate` keeps the given order, and so does
161
+ * `load-balance`: that mode is about how many requests may be in flight per
162
+ * account, not about which key comes first.
43
163
  */
44
164
  export function orderKeys(values, strategy, usageOf) {
45
- if (strategy === 'rotate') return [...values]
165
+ if (strategy === 'rotate' || strategy === 'load-balance') return [...values]
46
166
  const usageOfKey = (value) => {
47
167
  const usage = usageOf(value)
48
168
  return usage !== null && typeof usage === 'number' ? usage : 0
package/src/tools.js CHANGED
@@ -16,7 +16,10 @@
16
16
  * (0.1.6 row restart, then the 0.2.x dependency-range skew). Removing the
17
17
  * capability removes the failure mode; see `docs/agents/verification.md`.
18
18
  */
19
+ import { join } from 'node:path'
20
+ import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
19
21
  import { defineTool } from '@deepseek-ai/dsh-tools'
22
+ import { clampAccountCap, maskValue, readJsonFile } from './lib.js'
20
23
 
21
24
  const TAVILY_BASE_URL = 'https://api.tavily.com'
22
25
 
@@ -37,6 +40,106 @@ const RETRYABLE_REASON = new Map([
37
40
  [433, 'Tavily pay-as-you-go limit exceeded'],
38
41
  ])
39
42
 
43
+ const MANAGER_STATE = 'tavily-manager.json'
44
+
45
+ /**
46
+ * The concurrency policy the card writes: the strategy, the per-account cap,
47
+ * and the account grouping the usage route inferred (or the user pinned). Read
48
+ * per call, so a card change takes effect on the next search, and tolerant of a
49
+ * missing or half-written file.
50
+ */
51
+ function readConcurrencyPolicy() {
52
+ const state = readJsonFile(join(resolveDshHome(), MANAGER_STATE), {})
53
+ const accounts = state.accounts !== null && state.accounts !== undefined &&
54
+ typeof state.accounts === 'object' && !Array.isArray(state.accounts)
55
+ ? state.accounts
56
+ : {}
57
+ return {
58
+ balanced: state.strategy === 'load-balance',
59
+ cap: clampAccountCap(state.accountCap),
60
+ accounts,
61
+ }
62
+ }
63
+
64
+ /**
65
+ * The account one key belongs to. A key the usage route could not group is
66
+ * treated as its own account: the cap still applies to it, and it stays
67
+ * parallel-safe with keys of other accounts.
68
+ */
69
+ function accountOfKey(key, accounts) {
70
+ const masked = maskValue(key)
71
+ const entry = accounts[masked]
72
+ if (entry !== null && entry !== undefined && typeof entry === 'object' &&
73
+ typeof entry.group === 'string' && entry.group.length > 0) {
74
+ return entry.group
75
+ }
76
+ return 'key:' + masked
77
+ }
78
+
79
+ /**
80
+ * Per-account in-flight limiter. One instance per installed tool half, so every
81
+ * tool call in the process shares it — which is the point: parallel calls queue
82
+ * behind one free account instead of hitting it at the same time.
83
+ */
84
+ function createAccountScheduler() {
85
+ const active = new Map()
86
+ const waiters = []
87
+ const activeIn = (group) => active.get(group) ?? 0
88
+
89
+ const detach = (waiter) => {
90
+ if (waiter.signal !== null && waiter.signal !== undefined && waiter.onAbort !== undefined) {
91
+ waiter.signal.removeEventListener('abort', waiter.onAbort)
92
+ }
93
+ }
94
+
95
+ function wake() {
96
+ for (let index = 0; index < waiters.length; index += 1) {
97
+ const waiter = waiters[index]
98
+ if (activeIn(waiter.group) >= waiter.cap) continue
99
+ waiters.splice(index, 1)
100
+ index -= 1
101
+ detach(waiter)
102
+ // Reserve the slot here: waking two waiters must never hand out one slot.
103
+ active.set(waiter.group, activeIn(waiter.group) + 1)
104
+ waiter.resolve()
105
+ }
106
+ }
107
+
108
+ return {
109
+ activeIn,
110
+ /** Resolves `true` when the caller had to wait for capacity. */
111
+ async acquire(group, cap, signal) {
112
+ if (activeIn(group) < cap) {
113
+ active.set(group, activeIn(group) + 1)
114
+ return false
115
+ }
116
+ const aborted = () => new Error('tavily: request aborted while waiting for account capacity')
117
+ if (signal !== null && signal !== undefined && signal.aborted === true) throw aborted()
118
+ await new Promise((resolve, reject) => {
119
+ const waiter = { group, cap, resolve, signal }
120
+ if (signal !== null && signal !== undefined) {
121
+ waiter.onAbort = () => {
122
+ const index = waiters.indexOf(waiter)
123
+ if (index >= 0) waiters.splice(index, 1)
124
+ detach(waiter)
125
+ reject(aborted())
126
+ }
127
+ signal.addEventListener('abort', waiter.onAbort, { once: true })
128
+ }
129
+ waiters.push(waiter)
130
+ })
131
+ // The waker already reserved this slot.
132
+ return true
133
+ },
134
+ release(group) {
135
+ const next = activeIn(group) - 1
136
+ if (next <= 0) active.delete(group)
137
+ else active.set(group, next)
138
+ wake()
139
+ },
140
+ }
141
+ }
142
+
40
143
  /** Clamp a number into [min, max]; `fallback` when not finite. */
41
144
  export function clampInt(value, min, max, fallback) {
42
145
  const n = Number(value)
@@ -450,6 +553,7 @@ const NAVIGATION_PARAMETERS = {
450
553
  */
451
554
  export function installTavilyTool(ctx) {
452
555
  let rotation = 0
556
+ const scheduler = createAccountScheduler()
453
557
 
454
558
  // Every registration is collected into one disposer so the fiber tears the
455
559
  // half down cleanly: on row unload, and on the Plugins page's runtime unload
@@ -483,6 +587,12 @@ export function installTavilyTool(ctx) {
483
587
  * key at most once. Folding the attempt offset into the cursor instead
484
588
  * (the earlier shape) skipped keys and repeated others whenever the pool
485
589
  * size was even.
590
+ *
591
+ * Under `load-balance` the slot is chosen by account capacity instead: the
592
+ * first key, from the cursor, whose account is below the cap — so parallel
593
+ * calls spread across accounts, and a call queues rather than opening a
594
+ * second connection to one free account. Every other strategy keeps the
595
+ * plain order above, untouched.
486
596
  */
487
597
  async function requestWithRotation(operation, body, timeoutMs, exec, detail) {
488
598
  const { keys, source } = await resolveKeys()
@@ -491,12 +601,44 @@ export function installTavilyTool(ctx) {
491
601
  }
492
602
  const attempts = keys.length
493
603
  const first = rotation
604
+ const policy = readConcurrencyPolicy()
605
+ const groups = policy.balanced ? keys.map((key) => accountOfKey(key, policy.accounts)) : null
606
+ const signal = exec !== null && exec !== undefined ? exec.signal : undefined
607
+ const tried = new Set()
608
+ const pickBalancedIndex = (fallbackAttempt) => {
609
+ let leastBusy = null
610
+ let leastLoad = Infinity
611
+ for (let step = 0; step < keys.length; step += 1) {
612
+ const candidate = (first + step) % keys.length
613
+ if (tried.has(candidate)) continue
614
+ const load = scheduler.activeIn(groups[candidate])
615
+ if (load < policy.cap) return candidate
616
+ if (load < leastLoad) {
617
+ leastBusy = candidate
618
+ leastLoad = load
619
+ }
620
+ }
621
+ return leastBusy === null ? (first + fallbackAttempt) % keys.length : leastBusy
622
+ }
494
623
  let lastRetryable = 0
495
624
  for (let attempt = 0; attempt < attempts; attempt++) {
496
- const index = (first + attempt) % keys.length
625
+ const index = groups === null ? (first + attempt) % keys.length : pickBalancedIndex(attempt)
626
+ tried.add(index)
497
627
  const key = keys[index]
628
+ const group = groups === null ? null : groups[index]
629
+ if (group !== null) {
630
+ const waited = await scheduler.acquire(group, policy.cap, signal)
631
+ if (waited) {
632
+ ctx.logger.info(`tavily_${operation}: waited for account capacity %j`, { group, cap: policy.cap, keys: keys.length })
633
+ }
634
+ }
498
635
  const started = Date.now()
499
- const outcome = await callOperation(operation, body, key, exec, timeoutMs)
636
+ let outcome
637
+ try {
638
+ outcome = await callOperation(operation, body, key, exec, timeoutMs)
639
+ } finally {
640
+ if (group !== null) scheduler.release(group)
641
+ }
500
642
  if (outcome.kind === 'ok') {
501
643
  rotation = (index + 1) % keys.length
502
644
  const extra = typeof detail === 'function' ? detail(outcome.json) : {}