@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/README.md +2 -1
- package/lib/client.js +324 -27
- package/package.json +4 -3
- package/src/backend.js +110 -11
- package/src/client.js +324 -27
- package/src/index.js +2 -1
- package/src/lib.js +123 -3
- package/src/tools.js +178 -6
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
|
|
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 (
|
|
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
|
-
/**
|
|
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 = (
|
|
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
|
-
|
|
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
|
-
|
|
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({
|