@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/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