@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/index.js CHANGED
@@ -4,7 +4,8 @@
4
4
  * Both halves live here, so there is no cross-package contract left to skew:
5
5
  *
6
6
  * - the model tools (`tools.js`) — `tavily_search`, `tavily_extract`,
7
- * `tavily_map`, `tavily_crawl`, with key rotation and failover on 401/429;
7
+ * `tavily_map`, `tavily_crawl`, with key rotation and failover on 401/429
8
+ * and on Tavily's per-key 432/433 usage-limit refusals;
8
9
  * - key and usage management (`backend.js`) — `/api/tavily-usage` and
9
10
  * `/api/tavily-manager`;
10
11
  * - the browser card (`client.js`) — keys, usage, and strategy.
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
@@ -2,7 +2,8 @@
2
2
  * Tool half of `@moguiyu/dsh-tavily`: the advanced `tavily_search` model tool
3
3
  * plus the extra Tavily direct tools (`tavily_extract`, `tavily_map`,
4
4
  * `tavily_crawl`). Keys resolve from the `TAVILY_API_KEYS` credential per
5
- * call; rotation is round-robin with failover on 401/429.
5
+ * call; rotation is round-robin with failover on every per-key refusal
6
+ * (401/429, plus Tavily's 432 plan-limit and 433 pay-as-you-go-limit codes).
6
7
  *
7
8
  * The tools are an OPTION for search, never a replacement: they are
8
9
  * independent of the built-in `web_search` provider, and registering them
@@ -15,10 +16,130 @@
15
16
  * (0.1.6 row restart, then the 0.2.x dependency-range skew). Removing the
16
17
  * capability removes the failure mode; see `docs/agents/verification.md`.
17
18
  */
19
+ import { join } from 'node:path'
20
+ import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
18
21
  import { defineTool } from '@deepseek-ai/dsh-tools'
22
+ import { clampAccountCap, maskValue, readJsonFile } from './lib.js'
19
23
 
20
24
  const TAVILY_BASE_URL = 'https://api.tavily.com'
21
25
 
26
+ /**
27
+ * Statuses that mean "this key cannot serve the request — try another one".
28
+ * Tavily documents 432 (plan limit exceeded) and 433 (pay-as-you-go limit
29
+ * exceeded) as conditions of the key's account, so in a multi-key pool they
30
+ * are failover cases exactly like 401/429. Treating them as fatal made a pool
31
+ * with one exhausted account fail calls that a healthy key could have served.
32
+ */
33
+ const RETRYABLE_STATUS = new Set([401, 429, 432, 433])
34
+
35
+ /** Why a retryable status refused the key; the pool's exhaustion report uses it. */
36
+ const RETRYABLE_REASON = new Map([
37
+ [401, 'invalid or missing key'],
38
+ [429, 'rate limit'],
39
+ [432, 'Tavily plan usage limit exceeded'],
40
+ [433, 'Tavily pay-as-you-go limit exceeded'],
41
+ ])
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
+
22
143
  /** Clamp a number into [min, max]; `fallback` when not finite. */
23
144
  export function clampInt(value, min, max, fallback) {
24
145
  const n = Number(value)
@@ -103,7 +224,7 @@ async function callOperation(operation, body, key, exec, timeoutMs) {
103
224
  body: JSON.stringify(body),
104
225
  signal: combined.signal,
105
226
  })
106
- if (response.status === 401 || response.status === 429) {
227
+ if (RETRYABLE_STATUS.has(response.status)) {
107
228
  return { kind: 'retryable', statusCode: response.status }
108
229
  }
109
230
  if (!response.ok) {
@@ -113,6 +234,8 @@ async function callOperation(operation, body, key, exec, timeoutMs) {
113
234
  if (parsed !== null && typeof parsed === 'object') {
114
235
  if (typeof parsed.error === 'string') message = parsed.error
115
236
  else if (parsed.error !== null && typeof parsed.error === 'object' && typeof parsed.error.message === 'string') message = parsed.error.message
237
+ // 432/433 and other API failures arrive as {"detail":{"error":…}}.
238
+ else if (parsed.detail !== undefined && parsed.detail !== null && typeof parsed.detail === 'object' && typeof parsed.detail.error === 'string') message = parsed.detail.error
116
239
  }
117
240
  } catch {
118
241
  /* keep the status message */
@@ -430,6 +553,7 @@ const NAVIGATION_PARAMETERS = {
430
553
  */
431
554
  export function installTavilyTool(ctx) {
432
555
  let rotation = 0
556
+ const scheduler = createAccountScheduler()
433
557
 
434
558
  // Every registration is collected into one disposer so the fiber tears the
435
559
  // half down cleanly: on row unload, and on the Plugins page's runtime unload
@@ -455,19 +579,66 @@ export function installTavilyTool(ctx) {
455
579
  return { keys: [], source: 'unconfigured' }
456
580
  }
457
581
 
458
- /** Run `body` through the rotation/failover loop; returns the response JSON. */
582
+ /**
583
+ * Run `body` through the rotation/failover loop; returns the response JSON.
584
+ *
585
+ * The first key is picked from the shared round-robin cursor, and each
586
+ * attempt advances by exactly one slot, so one call tries every configured
587
+ * key at most once. Folding the attempt offset into the cursor instead
588
+ * (the earlier shape) skipped keys and repeated others whenever the pool
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.
596
+ */
459
597
  async function requestWithRotation(operation, body, timeoutMs, exec, detail) {
460
598
  const { keys, source } = await resolveKeys()
461
599
  if (keys.length === 0) {
462
600
  throw new Error(`tavily_${operation}: TAVILY_API_KEYS is not configured — add it via the Tavily Search settings card or ~/.dsh/.credentials.yaml (comma-separated)`)
463
601
  }
464
602
  const attempts = keys.length
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
+ }
465
623
  let lastRetryable = 0
466
624
  for (let attempt = 0; attempt < attempts; attempt++) {
467
- const index = (rotation + attempt) % keys.length
625
+ const index = groups === null ? (first + attempt) % keys.length : pickBalancedIndex(attempt)
626
+ tried.add(index)
468
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
+ }
469
635
  const started = Date.now()
470
- 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
+ }
471
642
  if (outcome.kind === 'ok') {
472
643
  rotation = (index + 1) % keys.length
473
644
  const extra = typeof detail === 'function' ? detail(outcome.json) : {}
@@ -478,7 +649,8 @@ export function installTavilyTool(ctx) {
478
649
  rotation = (index + 1) % keys.length
479
650
  ctx.logger.info(`tavily_${operation}: key %d/%d returned HTTP %d, rotating`, index + 1, keys.length, outcome.statusCode)
480
651
  }
481
- throw new Error(`tavily_${operation}: all ${attempts} configured key(s) failed with HTTP ${lastRetryable} (invalid key or rate limit)`)
652
+ const reason = RETRYABLE_REASON.get(lastRetryable) ?? 'invalid key or rate limit'
653
+ throw new Error(`tavily_${operation}: all ${attempts} configured key(s) failed with HTTP ${lastRetryable} (${reason})`)
482
654
  }
483
655
 
484
656
  tools.register(defineTool({