@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 +2 -1
- package/lib/client.js +120 -2
- package/package.json +4 -3
- package/src/backend.js +110 -11
- package/src/client.js +120 -2
- package/src/lib.js +123 -3
- package/src/tools.js +144 -2
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](
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) : {}
|