@moguiyu/dsh-tavily 0.3.3 → 0.3.5

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
@@ -1,13 +1,19 @@
1
1
  # @moguiyu/dsh-tavily
2
2
 
3
3
  Everything in one package: the advanced Tavily model tools (`tavily_search`, `tavily_extract`,
4
- `tavily_map`, `tavily_crawl`), the key/usage HTTP routes, and the browser card on the **Plugins**
5
- page.
4
+ `tavily_map`, `tavily_crawl`, `tavily_research`, `tavily_get_research`), the key/usage HTTP
5
+ routes, and the browser card on the **Plugins** page.
6
6
 
7
7
  - **`tavily_search`** — full Tavily surface (`max_results`, `search_depth`, `topic`, `days`,
8
8
  `include_answer`, `include_raw_content`, `include_domains`, `exclude_domains`).
9
9
  - **`tavily_extract` / `tavily_map` / `tavily_crawl`** — read a known URL's content, map a
10
10
  site's links, or crawl a site and return its pages. Same key-rotation/failover as search.
11
+ - **`tavily_research` / `tavily_get_research`** — Tavily's Research endpoint: queue a task
12
+ (`model`: `mini` / `pro` / `auto`, plus `citation_format`, `output_length`, domain filters and an
13
+ optional `output_schema`), wait for it, and get a synthesized, cited report; the second tool
14
+ collects a task that outlived the first call. Research is billed per request and far above
15
+ search — **mini 4-110 credits, pro 15-250**, against 1-2 for a search — so it is a per-call
16
+ choice, never a default. `timeout_seconds: 0` queues the task and returns its id immediately.
11
17
  - **Card** — key list, usage gauge, and the strategy selector. It registers into the Plugins
12
18
  page's `plugins.bundle.config` slot, keyed `@moguiyu/dsh-tavily`, and needs DSH
13
19
  **0.1.6-alpha.2 or newer** — the release that retired `settings.plugin.item` and moved plugin
@@ -29,4 +35,5 @@ which is exactly how the old tool-level switch broke. Both old packages are depr
29
35
  There is no tool-level on/off switch. Composing the plugin registers the tools, and the Plugins
30
36
  page's own bundle toggle is the only on/off.
31
37
 
32
- See the [workspace README](../../README.md) for install and configuration.
38
+ See the [workspace README](https://github.com/moguiyu/dsh-tavily#readme) for install and
39
+ 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,19 +1,21 @@
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.5",
4
+ "description": "Advanced Tavily model tools for DeepSeek Harness (tavily_search, tavily_extract, tavily_map, tavily_crawl, tavily_research, tavily_get_research) 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 轮换、故障转移与按账号并发上限,另有 research 综合调研报告,插件配置页含密钥与用量管理卡片;内置 web_search 永不替换。",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "exports": {
8
8
  ".": "./src/index.js",
9
- "./invariant": "./src/invariant.js",
10
9
  "./client": "./lib/client.js",
11
- "./src/*": "./src/*",
12
10
  "./package.json": "./package.json"
13
11
  },
14
12
  "files": [
15
- "src",
16
- "lib",
13
+ "src/index.js",
14
+ "src/tools.js",
15
+ "src/backend.js",
16
+ "src/lib.js",
17
+ "src/client.js",
18
+ "lib/client.js",
17
19
  "cordis.patch.yml",
18
20
  "README.md",
19
21
  "LICENSE"
@@ -40,7 +42,9 @@
40
42
  "0.1.6-alpha.2": "compatible",
41
43
  "0.1.7-rc.2": "compatible",
42
44
  "0.2.0-rc.1": "compatible",
43
- "0.2.0-rc.2": "compatible"
45
+ "0.2.0-rc.2": "compatible",
46
+ "0.2.1-alpha.1": "compatible",
47
+ "0.2.1-alpha.2": "compatible"
44
48
  }
45
49
  },
46
50
  "client": {
@@ -64,12 +68,13 @@
64
68
  ],
65
69
  "repository": {
66
70
  "type": "git",
67
- "url": "https://github.com/moguiyu/dsh-tavily.git"
71
+ "url": "git+https://github.com/moguiyu/dsh-tavily.git",
72
+ "directory": "packages/dsh-tavily"
68
73
  },
69
74
  "peerDependencies": {
70
- "@deepseek-ai/cordis": "^4.0.1",
71
- "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.7 || ^0.1.1-rc.1 || ^0.1.2-alpha.2 || ^0.1.3-alpha.1 || ^0.1.5-0 || ^0.1.6-0 || ^0.1.7-0 || ^0.2.0-0",
72
- "@deepseek-ai/dsh-tools": "^0.1.0-rc.7 || ^0.1.1-rc.1 || ^0.1.2-alpha.2 || ^0.1.3-alpha.1 || ^0.1.5-0 || ^0.1.6-0 || ^0.1.7-0 || ^0.2.0-0"
75
+ "@deepseek-ai/cordis": "^4.0.1 || ^4.0.5-0",
76
+ "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.7 || ^0.1.1-rc.1 || ^0.1.2-alpha.2 || ^0.1.3-alpha.1 || ^0.1.5-0 || ^0.1.6-0 || ^0.1.7-0 || ^0.2.0-0 || ^0.2.1-0",
77
+ "@deepseek-ai/dsh-tools": "^0.1.0-rc.7 || ^0.1.1-rc.1 || ^0.1.2-alpha.2 || ^0.1.3-alpha.1 || ^0.1.5-0 || ^0.1.6-0 || ^0.1.7-0 || ^0.2.0-0 || ^0.2.1-0"
73
78
  },
74
79
  "devDependencies": {
75
80
  "@deepseek-ai/dsh-system-prompt": "^0.1.2-alpha.2"
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)' } },