@moguiyu/dsh-tavily 0.1.3
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/LICENSE +21 -0
- package/README.md +18 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +434 -0
- package/package.json +58 -0
- package/src/client.js +438 -0
- package/src/index.js +541 -0
- package/src/invariant.js +18 -0
- package/src/lib.js +65 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@moguiyu/dsh-tavily` combined host half: `tavily_search` model tool plus
|
|
3
|
+
* the local HTTP backend for usage, key management, and the web provider toggle.
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
|
|
8
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
9
|
+
import { STRATEGIES, maskValue, parseKeyList, orderKeys, readJsonFile } from './lib.js'
|
|
10
|
+
|
|
11
|
+
export const name = 'dsh-tavily'
|
|
12
|
+
|
|
13
|
+
export const inject = ['tools', 'webServer', 'credentials']
|
|
14
|
+
|
|
15
|
+
/** Clamp a number into [min, max]; `fallback` when not finite. */
|
|
16
|
+
export function clampInt(value, min, max, fallback) {
|
|
17
|
+
const n = Number(value)
|
|
18
|
+
if (!Number.isFinite(n)) return fallback
|
|
19
|
+
return Math.min(max, Math.max(min, Math.round(n)))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Normalize and validate model arguments (exported for tests). */
|
|
23
|
+
export function normalizeArgs(args) {
|
|
24
|
+
const query = typeof args.query === 'string' ? args.query.trim() : ''
|
|
25
|
+
if (query.length === 0) throw new Error('tavily_search: query must be a non-empty string')
|
|
26
|
+
const maxResults = clampInt(args.max_results, 1, 20, 5)
|
|
27
|
+
const searchDepth = args.search_depth === 'advanced' ? 'advanced' : 'basic'
|
|
28
|
+
const topic = args.topic === 'news' ? 'news' : 'general'
|
|
29
|
+
const days = args.days === undefined ? undefined : clampInt(args.days, 1, 30, undefined)
|
|
30
|
+
const includeAnswer = args.include_answer === true
|
|
31
|
+
const includeRawContent = args.include_raw_content === true
|
|
32
|
+
const includeDomains = Array.isArray(args.include_domains)
|
|
33
|
+
? args.include_domains.filter((d) => typeof d === 'string' && d.length > 0)
|
|
34
|
+
: []
|
|
35
|
+
const excludeDomains = Array.isArray(args.exclude_domains)
|
|
36
|
+
? args.exclude_domains.filter((d) => typeof d === 'string' && d.length > 0)
|
|
37
|
+
: []
|
|
38
|
+
return { query, maxResults, searchDepth, topic, days, includeAnswer, includeRawContent, includeDomains, excludeDomains }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function hostnameOf(url) {
|
|
42
|
+
try {
|
|
43
|
+
return new URL(url).hostname
|
|
44
|
+
} catch {
|
|
45
|
+
return url
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Combine a caller signal with a hard timeout; call dispose() after the request. */
|
|
50
|
+
function combineSignals(signal, ms) {
|
|
51
|
+
const controller = new AbortController()
|
|
52
|
+
const timer = setTimeout(() => controller.abort(), ms)
|
|
53
|
+
const onAbort = () => controller.abort()
|
|
54
|
+
if (signal !== undefined && signal !== null) {
|
|
55
|
+
if (signal.aborted) controller.abort()
|
|
56
|
+
else signal.addEventListener('abort', onAbort, { once: true })
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
signal: controller.signal,
|
|
60
|
+
dispose() {
|
|
61
|
+
clearTimeout(timer)
|
|
62
|
+
if (signal !== undefined && signal !== null) signal.removeEventListener('abort', onAbort)
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function buildBody(input) {
|
|
68
|
+
const body = {
|
|
69
|
+
query: input.query,
|
|
70
|
+
max_results: input.maxResults,
|
|
71
|
+
search_depth: input.searchDepth,
|
|
72
|
+
}
|
|
73
|
+
if (input.topic === 'news') {
|
|
74
|
+
body.topic = 'news'
|
|
75
|
+
if (input.days !== undefined) body.days = input.days
|
|
76
|
+
}
|
|
77
|
+
if (input.includeAnswer) body.include_answer = true
|
|
78
|
+
if (input.includeRawContent) body.include_raw_content = true
|
|
79
|
+
if (input.includeDomains.length > 0) body.include_domains = input.includeDomains
|
|
80
|
+
if (input.excludeDomains.length > 0) body.exclude_domains = input.excludeDomains
|
|
81
|
+
return body
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function callTavily(input, key, exec) {
|
|
85
|
+
const combined = combineSignals(exec.signal, 30000)
|
|
86
|
+
try {
|
|
87
|
+
const response = await fetch('https://api.tavily.com/search', {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
headers: {
|
|
90
|
+
'content-type': 'application/json',
|
|
91
|
+
accept: 'application/json',
|
|
92
|
+
authorization: 'Bearer ' + key,
|
|
93
|
+
'user-agent': 'dsh-tool-tavily-search/0.1.0',
|
|
94
|
+
},
|
|
95
|
+
body: JSON.stringify(buildBody(input)),
|
|
96
|
+
signal: combined.signal,
|
|
97
|
+
})
|
|
98
|
+
if (response.status === 401 || response.status === 429) {
|
|
99
|
+
return { kind: 'retryable', statusCode: response.status }
|
|
100
|
+
}
|
|
101
|
+
if (!response.ok) {
|
|
102
|
+
let message = 'HTTP ' + response.status
|
|
103
|
+
try {
|
|
104
|
+
const parsed = await response.json()
|
|
105
|
+
if (parsed !== null && typeof parsed === 'object') {
|
|
106
|
+
if (typeof parsed.error === 'string') message = parsed.error
|
|
107
|
+
else if (parsed.error !== null && typeof parsed.error === 'object' && typeof parsed.error.message === 'string') message = parsed.error.message
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
/* keep the status message */
|
|
111
|
+
}
|
|
112
|
+
throw new Error('tavily_search: ' + message)
|
|
113
|
+
}
|
|
114
|
+
return { kind: 'ok', statusCode: response.status, json: await response.json() }
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
117
|
+
throw new Error('tavily_search: request timed out or was aborted')
|
|
118
|
+
}
|
|
119
|
+
throw error
|
|
120
|
+
} finally {
|
|
121
|
+
combined.dispose()
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function projectResults(json) {
|
|
126
|
+
const results = Array.isArray(json.results) ? json.results : []
|
|
127
|
+
const mapped = results.map((r) => {
|
|
128
|
+
const item = { url: String(r.url !== undefined && r.url !== null ? r.url : '') }
|
|
129
|
+
if (typeof r.title === 'string' && r.title.length > 0) item.title = r.title
|
|
130
|
+
if (typeof r.content === 'string' && r.content.length > 0) item.content = r.content
|
|
131
|
+
if (typeof r.score === 'number') item.score = r.score
|
|
132
|
+
if (typeof r.published_date === 'string' && r.published_date.length > 0) item.publishedAt = r.published_date
|
|
133
|
+
if (typeof r.raw_content === 'string' && r.raw_content.length > 0) {
|
|
134
|
+
item.rawContent = r.raw_content.length > 4000 ? r.raw_content.slice(0, 4000) + '…' : r.raw_content
|
|
135
|
+
}
|
|
136
|
+
return item
|
|
137
|
+
})
|
|
138
|
+
const value = { results: mapped }
|
|
139
|
+
if (typeof json.answer === 'string' && json.answer.length > 0) value.answer = json.answer
|
|
140
|
+
return value
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function formatOutput(value) {
|
|
144
|
+
const parts = []
|
|
145
|
+
if (typeof value.answer === 'string' && value.answer.length > 0) parts.push(value.answer)
|
|
146
|
+
if (value.results.length > 0) {
|
|
147
|
+
const lines = value.results.map((r) => {
|
|
148
|
+
const label = typeof r.title === 'string' && r.title.length > 0 ? r.title : hostnameOf(r.url)
|
|
149
|
+
const meta = []
|
|
150
|
+
if (typeof r.content === 'string' && r.content.length > 0) meta.push(r.content)
|
|
151
|
+
if (typeof r.publishedAt === 'string' && r.publishedAt.length > 0) meta.push('(' + r.publishedAt + ')')
|
|
152
|
+
if (typeof r.score === 'number') meta.push('(relevance ' + r.score.toFixed(2) + ')')
|
|
153
|
+
const suffix = meta.length > 0 ? ' — ' + meta.join(' ') : ''
|
|
154
|
+
return '- [' + label + '](' + r.url + ')' + suffix
|
|
155
|
+
})
|
|
156
|
+
parts.push('Sources:\n' + lines.join('\n'))
|
|
157
|
+
} else {
|
|
158
|
+
parts.push('No results found.')
|
|
159
|
+
}
|
|
160
|
+
parts.push('Cite the relevant URLs above as markdown links in your answer.')
|
|
161
|
+
return parts.join('\n\n')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function apply(ctx) {
|
|
165
|
+
applyBackend(ctx)
|
|
166
|
+
|
|
167
|
+
let rotation = 0
|
|
168
|
+
|
|
169
|
+
async function resolveKeys() {
|
|
170
|
+
const credentials = ctx.get('credentials')
|
|
171
|
+
if (credentials !== undefined) {
|
|
172
|
+
try {
|
|
173
|
+
const resolved = await credentials.resolve('TAVILY_API_KEYS')
|
|
174
|
+
if (resolved !== undefined && typeof resolved.value === 'string' && resolved.value.length > 0) {
|
|
175
|
+
const list = resolved.value.split(',').map((s) => s.trim()).filter((s) => s.length > 0)
|
|
176
|
+
if (list.length > 0) return { keys: list, source: resolved.source }
|
|
177
|
+
}
|
|
178
|
+
} catch (error) {
|
|
179
|
+
ctx.logger.warn('tavily_search: credential resolution failed: %s', error instanceof Error ? error.message : String(error))
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return { keys: [], source: 'unconfigured' }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function execute(args, exec) {
|
|
186
|
+
const input = normalizeArgs(args)
|
|
187
|
+
const { keys, source } = await resolveKeys()
|
|
188
|
+
if (keys.length === 0) {
|
|
189
|
+
throw new Error('tavily_search: TAVILY_API_KEYS is not configured — add it via the Tavily Search settings card or ~/.dsh/.credentials.yaml (comma-separated)')
|
|
190
|
+
}
|
|
191
|
+
const attempts = keys.length
|
|
192
|
+
let lastRetryable = 0
|
|
193
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
194
|
+
const index = (rotation + attempt) % keys.length
|
|
195
|
+
const key = keys[index]
|
|
196
|
+
const started = Date.now()
|
|
197
|
+
const outcome = await callTavily(input, key, exec)
|
|
198
|
+
if (outcome.kind === 'ok') {
|
|
199
|
+
rotation = (index + 1) % keys.length
|
|
200
|
+
const count = Array.isArray(outcome.json.results) ? outcome.json.results.length : 0
|
|
201
|
+
ctx.logger.info('tavily_search: ok %j', { query: input.query, status: outcome.statusCode, results: count, ms: Date.now() - started, keys: keys.length, source })
|
|
202
|
+
return projectResults(outcome.json)
|
|
203
|
+
}
|
|
204
|
+
lastRetryable = outcome.statusCode
|
|
205
|
+
rotation = (index + 1) % keys.length
|
|
206
|
+
ctx.logger.info('tavily_search: key %d/%d returned HTTP %d, rotating', index + 1, keys.length, outcome.statusCode)
|
|
207
|
+
}
|
|
208
|
+
throw new Error('tavily_search: all ' + attempts + ' configured key(s) failed with HTTP ' + lastRetryable + ' (invalid key or rate limit)')
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
ctx.tools.register(defineTool({
|
|
212
|
+
name: 'tavily_search',
|
|
213
|
+
description: 'Search the web through the Tavily API. Returns ranked results with titles, URLs, snippets, relevance scores, and an optional generated answer. Supports basic/advanced depth, news topic with a freshness window, and domain allow/deny filters.',
|
|
214
|
+
parameters: {
|
|
215
|
+
query: { type: 'string', required: true, description: 'The search query.' },
|
|
216
|
+
max_results: { type: 'number', description: 'Number of results to return, 1-20 (default 5).' },
|
|
217
|
+
search_depth: { type: 'string', enum: ['basic', 'advanced'], description: 'basic is faster and cheaper; advanced returns deeper analysis (default basic).' },
|
|
218
|
+
topic: { type: 'string', enum: ['general', 'news'], description: 'Search scope: general web or news (default general).' },
|
|
219
|
+
days: { type: 'number', description: 'News freshness window in days, e.g. 7 for the last week; only applies when topic is news.' },
|
|
220
|
+
include_answer: { type: 'boolean', description: 'Ask Tavily to generate a concise answer to the query (default false).' },
|
|
221
|
+
include_raw_content: { type: 'boolean', description: 'Include raw page content for each result (default false; capped per result).' },
|
|
222
|
+
include_domains: { type: 'array', items: { type: 'string' }, description: 'Only search within these domains.' },
|
|
223
|
+
exclude_domains: { type: 'array', items: { type: 'string' }, description: 'Exclude these domains from results.' },
|
|
224
|
+
},
|
|
225
|
+
output: {
|
|
226
|
+
schema: {
|
|
227
|
+
type: 'object',
|
|
228
|
+
additionalProperties: false,
|
|
229
|
+
properties: {
|
|
230
|
+
answer: { type: 'string' },
|
|
231
|
+
results: {
|
|
232
|
+
type: 'array',
|
|
233
|
+
required: true,
|
|
234
|
+
items: {
|
|
235
|
+
type: 'object',
|
|
236
|
+
additionalProperties: false,
|
|
237
|
+
properties: {
|
|
238
|
+
url: { type: 'string', required: true },
|
|
239
|
+
title: { type: 'string' },
|
|
240
|
+
content: { type: 'string' },
|
|
241
|
+
score: { type: 'number' },
|
|
242
|
+
publishedAt: { type: 'string' },
|
|
243
|
+
rawContent: { type: 'string' },
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
render: (_args, value) => [{ type: 'text', text: formatOutput(value) }],
|
|
250
|
+
},
|
|
251
|
+
timeoutMs: 30000,
|
|
252
|
+
isConcurrencySafe: () => true,
|
|
253
|
+
execute,
|
|
254
|
+
}))
|
|
255
|
+
|
|
256
|
+
const systemPrompt = ctx.get('systemPrompt')
|
|
257
|
+
if (systemPrompt !== undefined) {
|
|
258
|
+
systemPrompt.section({
|
|
259
|
+
name: 'tool:tavily_search',
|
|
260
|
+
order: 111,
|
|
261
|
+
text: 'Use the tavily_search tool for web search powered by the Tavily API. It supports result count, search depth, news topic with a freshness window, domain allow/deny filters, and an optional generated answer (include_answer). Cite the returned URLs as markdown links in your answer.',
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const credentials = ctx.get('credentials')
|
|
266
|
+
if (credentials !== undefined) {
|
|
267
|
+
credentials.describe('TAVILY_API_KEYS').then((info) => {
|
|
268
|
+
if (!info.configured) {
|
|
269
|
+
ctx.logger.warn('tavily_search: TAVILY_API_KEYS credential is not configured; add it via the Tavily Search settings card or ~/.dsh/.credentials.yaml')
|
|
270
|
+
}
|
|
271
|
+
}).catch((error) => {
|
|
272
|
+
ctx.logger.warn('tavily_search: credential describe failed: %s', error instanceof Error ? error.message : String(error))
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
const MANAGER_STATE = 'tavily-manager.json'
|
|
277
|
+
const TOGGLE_STATE = 'tavily-toggle.json'
|
|
278
|
+
const LEGACY_STATE = 'tavily-settings.json'
|
|
279
|
+
const REFS = ['TAVILY_API_KEYS', 'TAVILY_API_KEY']
|
|
280
|
+
|
|
281
|
+
function statePath(file) {
|
|
282
|
+
return join(resolveDshHome(), file)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function readState(file) {
|
|
286
|
+
return readJsonFile(statePath(file), {})
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function writeState(file, state) {
|
|
290
|
+
writeFileSync(statePath(file), JSON.stringify(state, null, 2) + '\n', { mode: 0o600 })
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function readBody(req) {
|
|
294
|
+
return new Promise((resolve, reject) => {
|
|
295
|
+
const chunks = []
|
|
296
|
+
req.on('data', (chunk) => chunks.push(chunk))
|
|
297
|
+
req.on('end', () => {
|
|
298
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
299
|
+
try {
|
|
300
|
+
resolve(text.length > 0 ? JSON.parse(text) : {})
|
|
301
|
+
} catch {
|
|
302
|
+
reject(new Error('invalid JSON body'))
|
|
303
|
+
}
|
|
304
|
+
})
|
|
305
|
+
req.on('error', reject)
|
|
306
|
+
})
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function fetchUsageFor(key) {
|
|
310
|
+
try {
|
|
311
|
+
const controller = new AbortController()
|
|
312
|
+
const timer = setTimeout(() => controller.abort(), 15000)
|
|
313
|
+
let response
|
|
314
|
+
try {
|
|
315
|
+
response = await fetch('https://api.tavily.com/usage', {
|
|
316
|
+
headers: { authorization: 'Bearer ' + key, accept: 'application/json' },
|
|
317
|
+
signal: controller.signal,
|
|
318
|
+
})
|
|
319
|
+
} finally {
|
|
320
|
+
clearTimeout(timer)
|
|
321
|
+
}
|
|
322
|
+
if (!response.ok) return null
|
|
323
|
+
const json = await response.json()
|
|
324
|
+
const account = json !== null && typeof json === 'object' ? json.account : undefined
|
|
325
|
+
const keyUsage = json !== null && typeof json === 'object' ? json.key : undefined
|
|
326
|
+
if (account !== undefined && typeof account.plan_usage === 'number') return account.plan_usage
|
|
327
|
+
if (keyUsage !== undefined && typeof keyUsage.usage === 'number') return keyUsage.usage
|
|
328
|
+
return null
|
|
329
|
+
} catch {
|
|
330
|
+
return null
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function collectStoredKeys(credentials) {
|
|
335
|
+
const out = []
|
|
336
|
+
const seen = new Set()
|
|
337
|
+
for (const ref of REFS) {
|
|
338
|
+
let hit
|
|
339
|
+
try {
|
|
340
|
+
hit = await credentials.resolve(ref)
|
|
341
|
+
} catch {
|
|
342
|
+
continue
|
|
343
|
+
}
|
|
344
|
+
if (hit === undefined || typeof hit.value !== 'string') continue
|
|
345
|
+
for (const key of parseKeyList(hit.value)) {
|
|
346
|
+
if (!seen.has(key)) {
|
|
347
|
+
seen.add(key)
|
|
348
|
+
out.push(key)
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return out
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function applyBackend(ctx) {
|
|
356
|
+
const credentials = ctx.get('credentials')
|
|
357
|
+
const loader = ctx.get('loader')
|
|
358
|
+
|
|
359
|
+
const send = (res, status, payload) => {
|
|
360
|
+
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' })
|
|
361
|
+
res.end(JSON.stringify(payload))
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ── key manager payload ────────────────────────────────────────────────────
|
|
365
|
+
async function buildManagerPayload(keys) {
|
|
366
|
+
const state = readState(MANAGER_STATE)
|
|
367
|
+
const keySavedAt = state.keySavedAt !== undefined && typeof state.keySavedAt === 'object' ? state.keySavedAt : {}
|
|
368
|
+
if (Object.keys(keySavedAt).length === 0 && credentials !== undefined) {
|
|
369
|
+
// Migrate dates recorded by the pre-0.2 layout (ref-keyed legacy file).
|
|
370
|
+
const legacy = readJsonFile(statePath(LEGACY_STATE), null)
|
|
371
|
+
if (legacy !== null && typeof legacy === 'object') {
|
|
372
|
+
for (const ref of REFS) {
|
|
373
|
+
const entry = legacy[ref]
|
|
374
|
+
if (entry !== null && typeof entry === 'object' && typeof entry.savedAt === 'string') {
|
|
375
|
+
try {
|
|
376
|
+
const hit = await credentials.resolve(ref)
|
|
377
|
+
if (hit !== undefined) keySavedAt[maskValue(hit.value)] = entry.savedAt
|
|
378
|
+
} catch {
|
|
379
|
+
/* keep going */
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return {
|
|
386
|
+
keys: keys.map((value, index) => ({
|
|
387
|
+
masked: maskValue(value),
|
|
388
|
+
savedAt: keySavedAt[maskValue(value)] !== undefined ? keySavedAt[maskValue(value)] : null,
|
|
389
|
+
primary: index === 0,
|
|
390
|
+
})),
|
|
391
|
+
strategy: isValidStrategy(state.strategy) ? state.strategy : 'rotate',
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ── persisted on/off + live provider flip ───────────────────────────────────
|
|
396
|
+
async function applyProvider(enabled) {
|
|
397
|
+
if (loader === undefined) return
|
|
398
|
+
try {
|
|
399
|
+
const entry = loader.resolve('include:web')
|
|
400
|
+
if (entry !== undefined && entry !== null && entry.fiber !== undefined) {
|
|
401
|
+
await entry.fiber.update({ searchProvider: enabled ? 'tavily' : 'deepseek-official' }, true)
|
|
402
|
+
}
|
|
403
|
+
} catch (error) {
|
|
404
|
+
ctx.logger.warn('tavily-backend: web provider flip failed: %s', error instanceof Error ? error.message : String(error))
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Re-apply the persisted choice at activation (default: enabled).
|
|
409
|
+
const toggleState = readState(TOGGLE_STATE)
|
|
410
|
+
if (toggleState.enabled === false) {
|
|
411
|
+
void applyProvider(false)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
ctx.webServer.register({
|
|
415
|
+
kind: 'exact',
|
|
416
|
+
path: '/api/tavily-usage',
|
|
417
|
+
handler: async (_req, res) => {
|
|
418
|
+
if (credentials === undefined) return send(res, 500, { ok: false, error: 'credentials service unavailable' })
|
|
419
|
+
try {
|
|
420
|
+
const keys = await collectStoredKeys(credentials)
|
|
421
|
+
if (keys.length === 0) return send(res, 200, { ok: false, error: 'no Tavily API key configured' })
|
|
422
|
+
const perKey = []
|
|
423
|
+
for (const key of keys) {
|
|
424
|
+
try {
|
|
425
|
+
const usage = await fetchUsageFor(key)
|
|
426
|
+
perKey.push(usage !== null
|
|
427
|
+
? { ok: true, usage, planUsage: usage, planLimit: null, currentPlan: null }
|
|
428
|
+
: { ok: false, error: 'usage unavailable' })
|
|
429
|
+
} catch (error) {
|
|
430
|
+
perKey.push({ ok: false, error: String(error && error.message ? error.message : error) })
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
const okRows = perKey.filter((row) => row.ok)
|
|
434
|
+
return send(res, 200, {
|
|
435
|
+
ok: true,
|
|
436
|
+
perKey,
|
|
437
|
+
totals: {
|
|
438
|
+
keys: keys.length,
|
|
439
|
+
okKeys: okRows.length,
|
|
440
|
+
usage: okRows.reduce((sum, row) => sum + (row.usage || 0), 0),
|
|
441
|
+
planUsage: null,
|
|
442
|
+
planLimit: null,
|
|
443
|
+
},
|
|
444
|
+
})
|
|
445
|
+
} catch (error) {
|
|
446
|
+
send(res, 500, { ok: false, error: String(error && error.message ? error.message : error) })
|
|
447
|
+
}
|
|
448
|
+
},
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
ctx.webServer.register({
|
|
452
|
+
kind: 'exact',
|
|
453
|
+
path: '/api/tavily-manager',
|
|
454
|
+
handler: async (req, res) => {
|
|
455
|
+
if (credentials === undefined) return send(res, 500, { ok: false, error: 'credentials service unavailable' })
|
|
456
|
+
try {
|
|
457
|
+
const url = new URL(req.url || '/', 'http://x')
|
|
458
|
+
if (req.method === 'GET') {
|
|
459
|
+
const reveal = url.searchParams.get('reveal')
|
|
460
|
+
const keys = await collectStoredKeys(credentials)
|
|
461
|
+
if (reveal !== null) {
|
|
462
|
+
const match = keys.find((key) => maskValue(key) === reveal)
|
|
463
|
+
if (match === undefined) return send(res, 404, { ok: false, error: 'key not found' })
|
|
464
|
+
return send(res, 200, { ok: true, value: match })
|
|
465
|
+
}
|
|
466
|
+
return send(res, 200, { ok: true, ...(await buildManagerPayload(keys)) })
|
|
467
|
+
}
|
|
468
|
+
if (req.method === 'POST') {
|
|
469
|
+
const body = await readBody(req)
|
|
470
|
+
const strategy = isValidStrategy(body.strategy) ? body.strategy : 'rotate'
|
|
471
|
+
const addValues = Array.isArray(body.add)
|
|
472
|
+
? body.add.map((item) => typeof item === 'string' ? item.trim() : '').filter((value) => value.length > 0)
|
|
473
|
+
: []
|
|
474
|
+
const removeMasked = Array.isArray(body.remove)
|
|
475
|
+
? body.remove.filter((item) => typeof item === 'string' && item.length > 0)
|
|
476
|
+
: []
|
|
477
|
+
|
|
478
|
+
let stored = await collectStoredKeys(credentials)
|
|
479
|
+
if (removeMasked.length > 0) {
|
|
480
|
+
stored = stored.filter((key) => !removeMasked.includes(maskValue(key)))
|
|
481
|
+
}
|
|
482
|
+
let values = [...stored]
|
|
483
|
+
for (const value of addValues) {
|
|
484
|
+
if (!values.includes(value)) values.push(value)
|
|
485
|
+
}
|
|
486
|
+
if (values.length === 0) {
|
|
487
|
+
await credentials.unset('TAVILY_API_KEYS')
|
|
488
|
+
await credentials.unset('TAVILY_API_KEY')
|
|
489
|
+
} else {
|
|
490
|
+
if (strategy !== 'rotate') {
|
|
491
|
+
const usageRows = await Promise.all(values.map(async (value) => ({ value, usage: await fetchUsageFor(value) })))
|
|
492
|
+
const usageOf = (value) => {
|
|
493
|
+
const row = usageRows.find((r) => r.value === value)
|
|
494
|
+
return row !== undefined && row.usage !== null ? row.usage : null
|
|
495
|
+
}
|
|
496
|
+
values = orderKeys(values, strategy, usageOf)
|
|
497
|
+
}
|
|
498
|
+
await credentials.set('TAVILY_API_KEYS', values.join(','))
|
|
499
|
+
await credentials.set('TAVILY_API_KEY', values[0])
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const state = readState(MANAGER_STATE)
|
|
503
|
+
const keySavedAt = state.keySavedAt !== undefined && typeof state.keySavedAt === 'object' ? state.keySavedAt : {}
|
|
504
|
+
const now = new Date().toISOString()
|
|
505
|
+
for (const value of values) {
|
|
506
|
+
const masked = maskValue(value)
|
|
507
|
+
if (keySavedAt[masked] === undefined) keySavedAt[masked] = now
|
|
508
|
+
}
|
|
509
|
+
writeState(MANAGER_STATE, { keySavedAt, strategy })
|
|
510
|
+
|
|
511
|
+
return send(res, 200, { ok: true, ...(await buildManagerPayload(values)) })
|
|
512
|
+
}
|
|
513
|
+
return send(res, 405, { ok: false, error: 'method not allowed' })
|
|
514
|
+
} catch (error) {
|
|
515
|
+
send(res, 500, { ok: false, error: String(error && error.message ? error.message : error) })
|
|
516
|
+
}
|
|
517
|
+
},
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
ctx.webServer.register({
|
|
521
|
+
kind: 'exact',
|
|
522
|
+
path: '/api/tavily-toggle',
|
|
523
|
+
handler: async (req, res) => {
|
|
524
|
+
try {
|
|
525
|
+
if (req.method === 'GET') {
|
|
526
|
+
return send(res, 200, { ok: true, enabled: readState(TOGGLE_STATE).enabled === true })
|
|
527
|
+
}
|
|
528
|
+
if (req.method === 'POST') {
|
|
529
|
+
const body = await readBody(req)
|
|
530
|
+
const enabled = body !== null && typeof body === 'object' && body.enabled === true
|
|
531
|
+
writeState(TOGGLE_STATE, { enabled })
|
|
532
|
+
await applyProvider(enabled)
|
|
533
|
+
return send(res, 200, { ok: true, enabled })
|
|
534
|
+
}
|
|
535
|
+
return send(res, 405, { ok: false, error: 'method not allowed' })
|
|
536
|
+
} catch (error) {
|
|
537
|
+
send(res, 500, { ok: false, error: String(error && error.message ? error.message : error) })
|
|
538
|
+
}
|
|
539
|
+
},
|
|
540
|
+
})
|
|
541
|
+
}
|
package/src/invariant.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `@moguiyu/dsh-tavily`.
|
|
3
|
+
*/
|
|
4
|
+
const PACKAGE_NAME = '@moguiyu/dsh-tavily'
|
|
5
|
+
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
const name = 'dsh-tavily-invariant'
|
|
8
|
+
|
|
9
|
+
/** Service required before the companion can reserve package ownership. */
|
|
10
|
+
const inject = ['invariants']
|
|
11
|
+
|
|
12
|
+
/** No runtime invariant: this tool has no independent lifecycle stream. */
|
|
13
|
+
const install = () => {}
|
|
14
|
+
|
|
15
|
+
/** Register this package's invariant companion. */
|
|
16
|
+
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
|
17
|
+
|
|
18
|
+
export { apply, inject, name }
|
package/src/lib.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for the Tavily backend (exported for tests).
|
|
3
|
+
*/
|
|
4
|
+
import { readFileSync } from 'node:fs'
|
|
5
|
+
|
|
6
|
+
export const STRATEGIES = ['rotate', 'low-usage-first', 'high-usage-first']
|
|
7
|
+
|
|
8
|
+
export function isValidStrategy(value) {
|
|
9
|
+
return STRATEGIES.includes(value)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Mask a key for display: `first12…last4`; comma-separated lists are masked
|
|
14
|
+
* per part. Never reveals more than a fingerprint.
|
|
15
|
+
*/
|
|
16
|
+
export function maskValue(value) {
|
|
17
|
+
if (typeof value !== 'string' || value.length === 0) return ''
|
|
18
|
+
return value.split(',').map((part) => {
|
|
19
|
+
const p = part.trim()
|
|
20
|
+
if (p.length <= 12) return '••••'
|
|
21
|
+
return p.slice(0, 12) + '…' + p.slice(-4)
|
|
22
|
+
}).join(', ')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Split a comma-separated credential value into trimmed, deduped keys. */
|
|
26
|
+
export function parseKeyList(value) {
|
|
27
|
+
if (typeof value !== 'string') return []
|
|
28
|
+
const seen = new Set()
|
|
29
|
+
const out = []
|
|
30
|
+
for (const part of value.split(',')) {
|
|
31
|
+
const key = part.trim()
|
|
32
|
+
if (key.length > 0 && !seen.has(key)) {
|
|
33
|
+
seen.add(key)
|
|
34
|
+
out.push(key)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return out
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 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.
|
|
43
|
+
*/
|
|
44
|
+
export function orderKeys(values, strategy, usageOf) {
|
|
45
|
+
if (strategy === 'rotate') return [...values]
|
|
46
|
+
const usageOfKey = (value) => {
|
|
47
|
+
const usage = usageOf(value)
|
|
48
|
+
return usage !== null && typeof usage === 'number' ? usage : 0
|
|
49
|
+
}
|
|
50
|
+
const sorted = [...values]
|
|
51
|
+
sorted.sort((a, b) => strategy === 'low-usage-first'
|
|
52
|
+
? usageOfKey(a) - usageOfKey(b)
|
|
53
|
+
: usageOfKey(b) - usageOfKey(a))
|
|
54
|
+
return sorted
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Read a JSON object file; `fallback` on missing or unparsable content. */
|
|
58
|
+
export function readJsonFile(path, fallback) {
|
|
59
|
+
try {
|
|
60
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'))
|
|
61
|
+
return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : fallback
|
|
62
|
+
} catch {
|
|
63
|
+
return fallback
|
|
64
|
+
}
|
|
65
|
+
}
|