@moguiyu/dsh-tavily 0.2.8 → 0.3.0
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 +21 -21
- package/lib/client.js +19 -116
- package/package.json +3 -12
- package/src/backend.js +297 -0
- package/src/client.js +19 -116
- package/src/index.js +30 -157
- package/src/lib.js +65 -0
- package/src/tools.js +632 -0
package/README.md
CHANGED
|
@@ -1,32 +1,32 @@
|
|
|
1
1
|
# @moguiyu/dsh-tavily
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
`tavily_map`, `tavily_crawl`), the
|
|
5
|
-
|
|
3
|
+
Everything in one package: the advanced Tavily model tools (`tavily_search`, `tavily_extract`,
|
|
4
|
+
`tavily_map`, `tavily_crawl`), the key/usage HTTP routes, and the browser card on the **Plugins**
|
|
5
|
+
page.
|
|
6
6
|
|
|
7
7
|
- **`tavily_search`** — full Tavily surface (`max_results`, `search_depth`, `topic`, `days`,
|
|
8
|
-
`include_answer`, `include_raw_content`, `include_domains`, `exclude_domains`).
|
|
9
|
-
off by default.
|
|
8
|
+
`include_answer`, `include_raw_content`, `include_domains`, `exclude_domains`).
|
|
10
9
|
- **`tavily_extract` / `tavily_map` / `tavily_crawl`** — read a known URL's content, map a
|
|
11
10
|
site's links, or crawl a site and return its pages. Same key-rotation/failover as search.
|
|
12
|
-
- **
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
generations are detected at runtime).
|
|
18
|
-
The switch writes the namespace and restarts the row; the choice is mirrored to
|
|
19
|
-
`~/.dsh/tavily-tool.json`.
|
|
11
|
+
- **Card** — key list, usage gauge, and the strategy selector. It registers into the Plugins
|
|
12
|
+
page's `plugins.bundle.config` slot, keyed `@moguiyu/dsh-tavily`, and needs DSH
|
|
13
|
+
**0.1.6-alpha.2 or newer** — the release that retired `settings.plugin.item` and moved plugin
|
|
14
|
+
configuration off the Settings page. On older lines the tools and routes work; there is simply
|
|
15
|
+
no card.
|
|
20
16
|
- **`web_search` is never replaced** — no `ctx.web` provider is registered and
|
|
21
|
-
`web.searchProvider` is never rewritten; the built-in
|
|
22
|
-
|
|
17
|
+
`web.searchProvider` is never rewritten; the built-in `web_search` keeps its native provider
|
|
18
|
+
and schema.
|
|
23
19
|
|
|
24
|
-
Host half: plain ESM (`src/index.js`
|
|
25
|
-
at `lib/client.js`, generated from
|
|
20
|
+
Host half: plain ESM (`src/index.js`, `src/tools.js`, `src/backend.js`, `src/lib.js`). Client
|
|
21
|
+
half: prebuilt `window.__ModuleLoader__` bundle at `lib/client.js`, generated from
|
|
22
|
+
`src/client.js` via `pnpm build`.
|
|
26
23
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
24
|
+
Earlier releases split this across `@moguiyu/dsh-tool-tavily-search` and
|
|
25
|
+
`@moguiyu/dsh-tavily-backend`, which this package imported as regular dependencies. From 0.3.0
|
|
26
|
+
both are folded in here: one package means no dependency range can drift behind the contract,
|
|
27
|
+
which is exactly how the old tool-level switch broke. Both old packages are deprecated.
|
|
28
|
+
|
|
29
|
+
There is no tool-level on/off switch. Composing the plugin registers the tools, and the Plugins
|
|
30
|
+
page's own bundle toggle is the only on/off.
|
|
31
31
|
|
|
32
32
|
See the [workspace README](../../README.md) for install and configuration.
|
package/lib/client.js
CHANGED
|
@@ -2,7 +2,6 @@ window.__ModuleLoader__.load({
|
|
|
2
2
|
id: "@moguiyu/dsh-tavily",
|
|
3
3
|
factory: function factory(require) {
|
|
4
4
|
const react = require('react')
|
|
5
|
-
const _primitives = require('@deepseek-ai/dsh-client-ui-primitives')
|
|
6
5
|
|
|
7
6
|
const inject = ['slots']
|
|
8
7
|
|
|
@@ -52,18 +51,6 @@ window.__ModuleLoader__.load({
|
|
|
52
51
|
}, react.createElement(SvgIcon, { name: icon, size: 13 }))
|
|
53
52
|
}
|
|
54
53
|
|
|
55
|
-
function Switch({ checked, disabled, label, onChange }) {
|
|
56
|
-
return react.createElement('button', {
|
|
57
|
-
type: 'button',
|
|
58
|
-
role: 'switch',
|
|
59
|
-
'aria-checked': checked === true,
|
|
60
|
-
'aria-label': label,
|
|
61
|
-
disabled: disabled || checked === null,
|
|
62
|
-
onClick: onChange,
|
|
63
|
-
style: { width: 36, height: 20, borderRadius: 10, border: '1px solid var(--dsw-alias-border-l2)', background: checked === true ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-bg-layer-1)', cursor: 'pointer', padding: 0, position: 'relative', flex: 'none', opacity: disabled ? 0.5 : 1 }
|
|
64
|
-
}, react.createElement('span', { style: { position: 'absolute', top: 2, left: checked === true ? 18 : 2, width: 14, height: 14, borderRadius: 7, background: '#fff', transition: 'left .12s ease' } }))
|
|
65
|
-
}
|
|
66
|
-
|
|
67
54
|
function UsageCircle({ percent, size, label, onClick }) {
|
|
68
55
|
const radius = 15
|
|
69
56
|
const circumference = 2 * Math.PI * radius
|
|
@@ -79,8 +66,6 @@ window.__ModuleLoader__.load({
|
|
|
79
66
|
}
|
|
80
67
|
|
|
81
68
|
function TavilySettingsCard() {
|
|
82
|
-
const [enabled, setEnabled] = react.useState(null)
|
|
83
|
-
const [expanded, setExpanded] = react.useState(false)
|
|
84
69
|
const [server, setServer] = react.useState(null)
|
|
85
70
|
const [loadError, setLoadError] = react.useState(null)
|
|
86
71
|
const [strategy, setStrategy] = react.useState('rotate')
|
|
@@ -96,13 +81,6 @@ window.__ModuleLoader__.load({
|
|
|
96
81
|
const [usageError, setUsageError] = react.useState(null)
|
|
97
82
|
|
|
98
83
|
const refresh = react.useCallback(async () => {
|
|
99
|
-
try {
|
|
100
|
-
const response = await fetch('/api/tavily-tool', { cache: 'no-store' })
|
|
101
|
-
const data = await response.json()
|
|
102
|
-
if (data.ok) { setEnabled(data.enabled); setLoadError(null) } else { setLoadError(data.error || 'Failed to load settings') }
|
|
103
|
-
} catch (error) {
|
|
104
|
-
setLoadError(String(error && error.message ? error.message : error))
|
|
105
|
-
}
|
|
106
84
|
try {
|
|
107
85
|
const response = await fetch('/api/tavily-manager', { cache: 'no-store' })
|
|
108
86
|
const data = await response.json()
|
|
@@ -122,27 +100,6 @@ window.__ModuleLoader__.load({
|
|
|
122
100
|
|
|
123
101
|
react.useEffect(() => { refresh() }, [refresh])
|
|
124
102
|
|
|
125
|
-
const toggleEnabled = async () => {
|
|
126
|
-
const next = enabled !== true
|
|
127
|
-
setBusy(true)
|
|
128
|
-
setNotice(null)
|
|
129
|
-
try {
|
|
130
|
-
const response = await fetch('/api/tavily-tool', {
|
|
131
|
-
method: 'POST',
|
|
132
|
-
headers: { 'content-type': 'application/json' },
|
|
133
|
-
body: JSON.stringify({ enabled: next })
|
|
134
|
-
})
|
|
135
|
-
const data = await response.json()
|
|
136
|
-
if (!data.ok) { setNotice({ error: data.error || 'Switch failed' }); return }
|
|
137
|
-
setEnabled(data.enabled)
|
|
138
|
-
setNotice({ ok: data.enabled ? 'Advanced tavily_search tool enabled.' : 'Advanced tavily_search tool disabled — built-in web_search is unchanged.' })
|
|
139
|
-
} catch (error) {
|
|
140
|
-
setNotice({ error: String(error && error.message ? error.message : error) })
|
|
141
|
-
} finally {
|
|
142
|
-
setBusy(false)
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
103
|
const saveStrategy = async (next) => {
|
|
147
104
|
setBusy(true)
|
|
148
105
|
setNotice(null)
|
|
@@ -283,46 +240,12 @@ window.__ModuleLoader__.load({
|
|
|
283
240
|
const headStyle = { textAlign: 'left', padding: '6px 8px', borderBottom: '1px solid var(--dsw-alias-border-l2)', fontSize: 12, color: 'var(--dsw-alias-label-tertiary)' }
|
|
284
241
|
const cellStyle = { padding: '8px', verticalAlign: 'top' }
|
|
285
242
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
//
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
return react.createElement('div', { className: expanded ? 'dts-card dts-card-open' : 'dts-card', style: { minWidth: 0 } },
|
|
293
|
-
react.createElement('style', null, '.dts-card{border:.5px solid var(--dsw-alias-border-l4);border-radius:16px;background:var(--dsw-alias-bg-layer-3);transition:border-color .16s,background .16s}.dts-card:hover{border-color:var(--dsw-alias-label-dimmed)}.dts-card-open{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}.dts-icon-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}.dts-icon-btn:disabled{opacity:.4;cursor:default}.dts-icon-btn-danger:hover{background:var(--dsw-alias-interactive-bg-hover-danger)}'),
|
|
294
|
-
react.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, padding: '12px 14px', minHeight: 52, boxSizing: 'border-box' } },
|
|
295
|
-
react.createElement('button', {
|
|
296
|
-
type: 'button',
|
|
297
|
-
'aria-expanded': expanded,
|
|
298
|
-
onClick: () => setExpanded((current) => !current),
|
|
299
|
-
style: { flex: 1, minWidth: 0, textAlign: 'left', background: 'none', border: 'none', padding: 0, cursor: 'pointer', display: 'flex', flexDirection: 'column', gap: 2, font: 'inherit', color: 'inherit' }
|
|
300
|
-
},
|
|
301
|
-
react.createElement('strong', { style: { fontSize: 14, fontWeight: 600, lineHeight: '20px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, 'Tavily Search'),
|
|
302
|
-
react.createElement('span', { style: { fontSize: 12, lineHeight: '16px', color: 'var(--dsw-alias-label-tertiary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, 'Advanced tavily_search tool · key rotation · usage')
|
|
303
|
-
),
|
|
304
|
-
react.createElement('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 6, flex: 'none' } },
|
|
305
|
-
react.createElement(Switch, { checked: enabled, disabled: busy, label: 'Advanced tavily_search tool enabled', onChange: toggleEnabled }),
|
|
306
|
-
react.createElement('button', {
|
|
307
|
-
type: 'button',
|
|
308
|
-
className: 'dts-icon-btn',
|
|
309
|
-
title: expanded ? 'Collapse' : 'Expand',
|
|
310
|
-
'aria-label': expanded ? 'Collapse' : 'Expand',
|
|
311
|
-
onClick: () => setExpanded((current) => !current),
|
|
312
|
-
style: { width: 26, height: 26, borderRadius: 6, border: 'none', background: 'transparent', color: 'var(--dsw-alias-label-tertiary)', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', padding: 0 }
|
|
313
|
-
},
|
|
314
|
-
react.createElement('span', { style: { display: 'inline-flex', transform: expanded ? 'rotate(180deg)' : undefined, transition: 'transform .14s var(--ds-ease-in-out)' } },
|
|
315
|
-
react.createElement(_primitives.IconChevronDownOutline14, { size: 12, 'aria-hidden': 'true' })
|
|
316
|
-
)
|
|
317
|
-
)
|
|
318
|
-
)
|
|
319
|
-
),
|
|
320
|
-
expanded && react.createElement('div', { style: { borderTop: '1px solid var(--dsw-alias-border-l2)', padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 16 } },
|
|
321
|
-
isOff
|
|
322
|
-
? react.createElement('p', { style: { margin: 0, fontSize: 13, color: 'var(--dsw-alias-label-tertiary)' } },
|
|
323
|
-
'The advanced tavily_search tool is off. The built-in web_search keeps its native provider and is never replaced.'
|
|
324
|
-
)
|
|
325
|
-
: react.createElement(react.Fragment, null,
|
|
243
|
+
// The Plugins page draws the title, the icon, and the crumb — the
|
|
244
|
+
// `plugins.bundle.config` contract puts the form alone in the entry — so
|
|
245
|
+
// this renders no card chrome, no second heading, and no collapse.
|
|
246
|
+
return react.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 16, minWidth: 0 } },
|
|
247
|
+
react.createElement('style', null, '.dts-icon-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}.dts-icon-btn:disabled{opacity:.4;cursor:default}.dts-icon-btn-danger:hover{background:var(--dsw-alias-interactive-bg-hover-danger)}'),
|
|
248
|
+
react.createElement(react.Fragment, null,
|
|
326
249
|
react.createElement('p', { style: { margin: 0, fontSize: 13, color: 'var(--dsw-alias-label-tertiary)' } },
|
|
327
250
|
'All keys are listed below; the green dot marks the first (primary) key. The tavily_search tool uses all keys according to the strategy. Built-in web_search is unaffected.'
|
|
328
251
|
),
|
|
@@ -434,35 +357,27 @@ window.__ModuleLoader__.load({
|
|
|
434
357
|
usageError !== null && react.createElement('p', { style: { margin: 0, fontSize: 12, color: 'var(--dsw-alias-state-error-primary)' } }, String(usageError)),
|
|
435
358
|
usage !== null && usage.ok === false && react.createElement('p', { style: { margin: 0, fontSize: 12, color: 'var(--dsw-alias-state-error-primary)' } }, String(usage.error))
|
|
436
359
|
)
|
|
437
|
-
)
|
|
438
360
|
)
|
|
439
361
|
}
|
|
440
362
|
|
|
441
|
-
// The
|
|
442
|
-
// `
|
|
363
|
+
// The entry the Plugins page renders. The contract asks for two views:
|
|
364
|
+
// `summary` for the one-liner under the title, and `page` for the form with
|
|
365
|
+
// its own save control. The page supplies the title, icon, and crumb, so the
|
|
366
|
+
// summary is a plain string and the page view is the form alone — matching
|
|
367
|
+
// how the shipped cards (WebSearchCard and friends) are written.
|
|
443
368
|
function TavilyCard(props) {
|
|
444
|
-
if (props && props.view === 'summary')
|
|
369
|
+
if (props && props.view === 'summary') {
|
|
370
|
+
return 'Manage Tavily API keys and the key-usage strategy. The built-in web_search is never replaced.'
|
|
371
|
+
}
|
|
445
372
|
return react.createElement(TavilySettingsCard, null)
|
|
446
373
|
}
|
|
447
374
|
|
|
448
|
-
// One-liner for the summary view. Deliberately static and cheap: the page
|
|
449
|
-
// draws the title, the icon, and the crumb itself, and never asks this entry
|
|
450
|
-
// for anything but `page`.
|
|
451
|
-
function TavilySummary() {
|
|
452
|
-
return react.createElement('p', { style: { margin: 0, fontSize: 12, color: 'var(--dsw-alias-label-tertiary)' } },
|
|
453
|
-
'Rotating Tavily API keys for the advanced search tools. The built-in web_search keeps its native provider and is never replaced.'
|
|
454
|
-
)
|
|
455
|
-
}
|
|
456
|
-
|
|
457
375
|
function apply(ctx) {
|
|
458
|
-
// 0.1.6-alpha.2 moved plugin configuration
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
// line, and neither throws on the other. Keep both -- dropping the retired
|
|
464
|
-
// name silently removes the card from rc.7 through 0.1.6-alpha.1, and
|
|
465
|
-
// dropping the new one silently removes it from 0.1.6-alpha.2 onward.
|
|
376
|
+
// ONE slot. 0.1.6-alpha.2 moved plugin configuration to the sidebar
|
|
377
|
+
// Plugins page and retired `settings.plugin.item`. This package no longer
|
|
378
|
+
// registers a settings namespace, so the retired keyed slot has no key it
|
|
379
|
+
// could claim. On rc.7 through 0.1.6-alpha.1 the tools and routes work but
|
|
380
|
+
// this card has no host — see `docs/agents/plugin-design.md`.
|
|
466
381
|
ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({
|
|
467
382
|
name: 'plugins.bundle.config',
|
|
468
383
|
// Keyed by the bundle's package name. The Plugins page renders this on
|
|
@@ -470,18 +385,6 @@ window.__ModuleLoader__.load({
|
|
|
470
385
|
key: '@moguiyu/dsh-tavily',
|
|
471
386
|
id: '@moguiyu/dsh-tavily'
|
|
472
387
|
}, TavilyCard))
|
|
473
|
-
|
|
474
|
-
ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({
|
|
475
|
-
name: 'settings.plugin.item',
|
|
476
|
-
// rc.7 through 0.1.6-alpha.1: keyed by the settings namespace the Host
|
|
477
|
-
// serves (`tavily-search`); the Plugins configuration tab dispatches it
|
|
478
|
-
// when the namespace is present. The id/order/label props keep rc.6
|
|
479
|
-
// list-slot deployments rendering the card as well.
|
|
480
|
-
key: 'tavily-search',
|
|
481
|
-
id: 'tavily-search',
|
|
482
|
-
order: 30,
|
|
483
|
-
label: 'Tavily Search'
|
|
484
|
-
}, TavilyCard))
|
|
485
388
|
}
|
|
486
389
|
|
|
487
390
|
return { apply, inject }
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moguiyu/dsh-tavily",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.3.0",
|
|
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.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -43,8 +43,6 @@
|
|
|
43
43
|
"client": {
|
|
44
44
|
"platform": "web",
|
|
45
45
|
"inject": [
|
|
46
|
-
"@deepseek-ai/dsh-client-ui-settings",
|
|
47
|
-
"@deepseek-ai/dsh-client-ui-settings-plugins",
|
|
48
46
|
"@deepseek-ai/dsh-client-ui-plugin-manager"
|
|
49
47
|
]
|
|
50
48
|
}
|
|
@@ -68,16 +66,9 @@
|
|
|
68
66
|
"peerDependencies": {
|
|
69
67
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
70
68
|
"@deepseek-ai/dsh-home-paths": "^0.1.0-rc.7 || ^0.1.1-rc.1 || ^0.1.2-alpha.2 || ^0.1.3-alpha.1 || ^0.1.5-0 || ^0.1.6-0",
|
|
71
|
-
"@deepseek-ai/dsh-tools": "^0.1.0-rc.7 || ^0.1.1-rc.1 || ^0.1.2-alpha.2 || ^0.1.3-alpha.1 || ^0.1.5-0 || ^0.1.6-0"
|
|
72
|
-
"@deepseek-ai/dsh-settings": "^0.1.0-rc.7 || ^0.1.1-rc.1 || ^0.1.2-alpha.2 || ^0.1.3-alpha.1 || ^0.1.5-0 || ^0.1.6-0"
|
|
73
|
-
},
|
|
74
|
-
"dependencies": {
|
|
75
|
-
"@deepseek-ai/schemastery": "^3.18.2",
|
|
76
|
-
"@moguiyu/dsh-tavily-backend": "^0.2.1",
|
|
77
|
-
"@moguiyu/dsh-tool-tavily-search": "^0.2.1"
|
|
69
|
+
"@deepseek-ai/dsh-tools": "^0.1.0-rc.7 || ^0.1.1-rc.1 || ^0.1.2-alpha.2 || ^0.1.3-alpha.1 || ^0.1.5-0 || ^0.1.6-0"
|
|
78
70
|
},
|
|
79
71
|
"devDependencies": {
|
|
80
|
-
"@deepseek-ai/dsh-settings": "^0.1.2-alpha.2",
|
|
81
72
|
"@deepseek-ai/dsh-system-prompt": "^0.1.2-alpha.2"
|
|
82
73
|
}
|
|
83
74
|
}
|
package/src/backend.js
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Routes half of `@moguiyu/dsh-tavily`: key and usage management on the
|
|
3
|
+
* harness webServer (`/api/tavily-usage`, `/api/tavily-manager`). The
|
|
4
|
+
* built-in `web_search` tool is never replaced — no web search provider is
|
|
5
|
+
* registered and `web.searchProvider` is never touched.
|
|
6
|
+
*
|
|
7
|
+
* There is no `/api/tavily-tool` switch route: the tool-level on/off is gone
|
|
8
|
+
* (see `tools.js`). Key management is not a capability gate — it only edits
|
|
9
|
+
* the credentials the tools read.
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
|
|
14
|
+
import { STRATEGIES, isValidStrategy, maskValue, parseKeyList, orderKeys, readJsonFile } from './lib.js'
|
|
15
|
+
|
|
16
|
+
const USAGE_TTL_MS = 60000
|
|
17
|
+
const usageCache = new Map()
|
|
18
|
+
|
|
19
|
+
const MANAGER_STATE = 'tavily-manager.json'
|
|
20
|
+
const LEGACY_STATE = 'tavily-settings.json'
|
|
21
|
+
const REFS = ['TAVILY_API_KEYS', 'TAVILY_API_KEY']
|
|
22
|
+
|
|
23
|
+
function statePath(file) {
|
|
24
|
+
return join(resolveDshHome(), file)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readState(file) {
|
|
28
|
+
return readJsonFile(statePath(file), {})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function writeState(file, state) {
|
|
32
|
+
writeFileSync(statePath(file), JSON.stringify(state, null, 2) + '\n', { mode: 0o600 })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readBody(req) {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const chunks = []
|
|
38
|
+
req.on('data', (chunk) => chunks.push(chunk))
|
|
39
|
+
req.on('end', () => {
|
|
40
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
41
|
+
try {
|
|
42
|
+
resolve(text.length > 0 ? JSON.parse(text) : {})
|
|
43
|
+
} catch {
|
|
44
|
+
reject(new Error('invalid JSON body'))
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
req.on('error', reject)
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function fetchUsageDetailsFor(key) {
|
|
52
|
+
const cached = usageCache.get(key)
|
|
53
|
+
if (cached !== undefined && Date.now() - cached.at < USAGE_TTL_MS) return cached.details
|
|
54
|
+
try {
|
|
55
|
+
const controller = new AbortController()
|
|
56
|
+
const timer = setTimeout(() => controller.abort(), 15000)
|
|
57
|
+
let response
|
|
58
|
+
try {
|
|
59
|
+
response = await fetch('https://api.tavily.com/usage', {
|
|
60
|
+
headers: { authorization: 'Bearer ' + key, accept: 'application/json' },
|
|
61
|
+
signal: controller.signal,
|
|
62
|
+
})
|
|
63
|
+
} finally {
|
|
64
|
+
clearTimeout(timer)
|
|
65
|
+
}
|
|
66
|
+
if (!response.ok) return null
|
|
67
|
+
const json = await response.json()
|
|
68
|
+
const account = json !== null && typeof json === 'object' ? json.account : undefined
|
|
69
|
+
const keyUsage = json !== null && typeof json === 'object' ? json.key : undefined
|
|
70
|
+
const usage = keyUsage !== undefined && typeof keyUsage.usage === 'number'
|
|
71
|
+
? keyUsage.usage
|
|
72
|
+
: (account !== undefined && typeof account.plan_usage === 'number' ? account.plan_usage : null)
|
|
73
|
+
const planUsage = account !== undefined && typeof account.plan_usage === 'number'
|
|
74
|
+
? account.plan_usage
|
|
75
|
+
: (keyUsage !== undefined && typeof keyUsage.usage === 'number' ? keyUsage.usage : null)
|
|
76
|
+
const planLimit = account !== undefined && typeof account.plan_limit === 'number'
|
|
77
|
+
? account.plan_limit
|
|
78
|
+
: (keyUsage !== undefined && typeof keyUsage.limit === 'number' ? keyUsage.limit
|
|
79
|
+
: (keyUsage !== undefined && typeof keyUsage.plan_limit === 'number' ? keyUsage.plan_limit : null))
|
|
80
|
+
const currentPlan = account !== undefined && typeof account.plan === 'string'
|
|
81
|
+
? account.plan
|
|
82
|
+
: (keyUsage !== undefined && typeof keyUsage.plan === 'string' ? keyUsage.plan : null)
|
|
83
|
+
if (usage === null && planUsage === null) return null
|
|
84
|
+
const details = { usage, planUsage, planLimit, currentPlan }
|
|
85
|
+
usageCache.set(key, { at: Date.now(), details })
|
|
86
|
+
return details
|
|
87
|
+
} catch {
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function fetchUsageFor(key) {
|
|
93
|
+
const details = await fetchUsageDetailsFor(key)
|
|
94
|
+
if (details === null) return null
|
|
95
|
+
if (details.planLimit !== null && details.planLimit > 0 && details.planUsage !== null) {
|
|
96
|
+
return (details.planUsage / details.planLimit) * 100
|
|
97
|
+
}
|
|
98
|
+
return details.usage !== null ? details.usage : details.planUsage
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function collectStoredKeys(credentials) {
|
|
102
|
+
const out = []
|
|
103
|
+
const seen = new Set()
|
|
104
|
+
for (const ref of REFS) {
|
|
105
|
+
let hit
|
|
106
|
+
try {
|
|
107
|
+
hit = await credentials.resolve(ref)
|
|
108
|
+
} catch {
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
if (hit === undefined || typeof hit.value !== 'string') continue
|
|
112
|
+
for (const key of parseKeyList(hit.value)) {
|
|
113
|
+
if (!seen.has(key)) {
|
|
114
|
+
seen.add(key)
|
|
115
|
+
out.push(key)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return out
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Register the Tavily key/usage routes on `ctx.webServer`:
|
|
124
|
+
* `/api/tavily-usage` and `/api/tavily-manager`.
|
|
125
|
+
*
|
|
126
|
+
* Every route is registered through `ctx.effect`, so the calling fiber owns
|
|
127
|
+
* it and unloading the row leaves nothing registered.
|
|
128
|
+
*/
|
|
129
|
+
export function installBackend(ctx) {
|
|
130
|
+
const credentials = ctx.get('credentials')
|
|
131
|
+
|
|
132
|
+
const send = (res, status, payload) => {
|
|
133
|
+
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' })
|
|
134
|
+
res.end(JSON.stringify(payload))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── key manager payload ────────────────────────────────────────────────────
|
|
138
|
+
async function buildManagerPayload(keys) {
|
|
139
|
+
const state = readState(MANAGER_STATE)
|
|
140
|
+
const keySavedAt = state.keySavedAt !== undefined && typeof state.keySavedAt === 'object' ? state.keySavedAt : {}
|
|
141
|
+
if (Object.keys(keySavedAt).length === 0 && credentials !== undefined) {
|
|
142
|
+
// Migrate dates recorded by the pre-0.2 layout (ref-keyed legacy file).
|
|
143
|
+
const legacy = readJsonFile(statePath(LEGACY_STATE), null)
|
|
144
|
+
if (legacy !== null && typeof legacy === 'object') {
|
|
145
|
+
for (const ref of REFS) {
|
|
146
|
+
const entry = legacy[ref]
|
|
147
|
+
if (entry !== null && typeof entry === 'object' && typeof entry.savedAt === 'string') {
|
|
148
|
+
try {
|
|
149
|
+
const hit = await credentials.resolve(ref)
|
|
150
|
+
if (hit !== undefined) keySavedAt[maskValue(hit.value)] = entry.savedAt
|
|
151
|
+
} catch {
|
|
152
|
+
/* keep going */
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const primaryMasked = keys.length > 0 ? maskValue(keys[0]) : null
|
|
159
|
+
const display = keys.map((value) => {
|
|
160
|
+
const masked = maskValue(value)
|
|
161
|
+
return {
|
|
162
|
+
masked,
|
|
163
|
+
savedAt: keySavedAt[masked] !== undefined ? keySavedAt[masked] : null,
|
|
164
|
+
}
|
|
165
|
+
}).sort((a, b) => {
|
|
166
|
+
if (a.savedAt === b.savedAt) return 0
|
|
167
|
+
if (a.savedAt === null) return 1
|
|
168
|
+
if (b.savedAt === null) return -1
|
|
169
|
+
return a.savedAt < b.savedAt ? -1 : 1
|
|
170
|
+
})
|
|
171
|
+
return {
|
|
172
|
+
keys: display.map((entry) => ({
|
|
173
|
+
masked: entry.masked,
|
|
174
|
+
savedAt: entry.savedAt,
|
|
175
|
+
primary: entry.masked === primaryMasked,
|
|
176
|
+
})),
|
|
177
|
+
strategy: isValidStrategy(state.strategy) ? state.strategy : 'rotate',
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
ctx.effect(() => ctx.webServer.register({
|
|
182
|
+
kind: 'exact',
|
|
183
|
+
path: '/api/tavily-usage',
|
|
184
|
+
handler: async (_req, res) => {
|
|
185
|
+
if (credentials === undefined) return send(res, 500, { ok: false, error: 'credentials service unavailable' })
|
|
186
|
+
try {
|
|
187
|
+
const keys = await collectStoredKeys(credentials)
|
|
188
|
+
if (keys.length === 0) return send(res, 200, { ok: false, error: 'no Tavily API key configured' })
|
|
189
|
+
const state = readState(MANAGER_STATE)
|
|
190
|
+
const keySavedAt = state.keySavedAt !== undefined && typeof state.keySavedAt === 'object' ? state.keySavedAt : {}
|
|
191
|
+
const orderedKeys = keys.slice().sort((a, b) => {
|
|
192
|
+
const am = keySavedAt[maskValue(a)] !== undefined ? keySavedAt[maskValue(a)] : null
|
|
193
|
+
const bm = keySavedAt[maskValue(b)] !== undefined ? keySavedAt[maskValue(b)] : null
|
|
194
|
+
if (am === bm) return 0
|
|
195
|
+
if (am === null) return 1
|
|
196
|
+
if (bm === null) return -1
|
|
197
|
+
return am < bm ? -1 : 1
|
|
198
|
+
})
|
|
199
|
+
const perKey = []
|
|
200
|
+
for (const key of orderedKeys) {
|
|
201
|
+
try {
|
|
202
|
+
const usage = await fetchUsageDetailsFor(key)
|
|
203
|
+
perKey.push(usage !== null
|
|
204
|
+
? { ok: true, masked: maskValue(key), ...usage }
|
|
205
|
+
: { ok: false, masked: maskValue(key), error: 'usage unavailable' })
|
|
206
|
+
} catch (error) {
|
|
207
|
+
perKey.push({ ok: false, masked: maskValue(key), error: String(error && error.message ? error.message : error) })
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const okRows = perKey.filter((row) => row.ok)
|
|
211
|
+
return send(res, 200, {
|
|
212
|
+
ok: true,
|
|
213
|
+
perKey,
|
|
214
|
+
totals: {
|
|
215
|
+
keys: keys.length,
|
|
216
|
+
okKeys: okRows.length,
|
|
217
|
+
usage: okRows.reduce((sum, row) => sum + (row.usage || 0), 0),
|
|
218
|
+
planUsage: okRows.reduce((sum, row) => sum + (row.planUsage || 0), 0),
|
|
219
|
+
planLimit: okRows.every((row) => row.planLimit !== null) ? okRows.reduce((sum, row) => sum + row.planLimit, 0) : null,
|
|
220
|
+
},
|
|
221
|
+
})
|
|
222
|
+
} catch (error) {
|
|
223
|
+
send(res, 500, { ok: false, error: String(error && error.message ? error.message : error) })
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
}), 'tavily-backend: route /api/tavily-usage')
|
|
227
|
+
|
|
228
|
+
ctx.effect(() => ctx.webServer.register({
|
|
229
|
+
kind: 'exact',
|
|
230
|
+
path: '/api/tavily-manager',
|
|
231
|
+
handler: async (req, res) => {
|
|
232
|
+
if (credentials === undefined) return send(res, 500, { ok: false, error: 'credentials service unavailable' })
|
|
233
|
+
try {
|
|
234
|
+
const url = new URL(req.url || '/', 'http://x')
|
|
235
|
+
if (req.method === 'GET') {
|
|
236
|
+
const reveal = url.searchParams.get('reveal')
|
|
237
|
+
const keys = await collectStoredKeys(credentials)
|
|
238
|
+
if (reveal !== null) {
|
|
239
|
+
const match = keys.find((key) => maskValue(key) === reveal)
|
|
240
|
+
if (match === undefined) return send(res, 404, { ok: false, error: 'key not found' })
|
|
241
|
+
return send(res, 200, { ok: true, value: match })
|
|
242
|
+
}
|
|
243
|
+
return send(res, 200, { ok: true, ...(await buildManagerPayload(keys)) })
|
|
244
|
+
}
|
|
245
|
+
if (req.method === 'POST') {
|
|
246
|
+
const body = await readBody(req)
|
|
247
|
+
const strategy = isValidStrategy(body.strategy) ? body.strategy : 'rotate'
|
|
248
|
+
const addValues = Array.isArray(body.add)
|
|
249
|
+
? body.add.map((item) => typeof item === 'string' ? item.trim() : '').filter((value) => value.length > 0)
|
|
250
|
+
: []
|
|
251
|
+
const removeMasked = Array.isArray(body.remove)
|
|
252
|
+
? body.remove.filter((item) => typeof item === 'string' && item.length > 0)
|
|
253
|
+
: []
|
|
254
|
+
|
|
255
|
+
let stored = await collectStoredKeys(credentials)
|
|
256
|
+
if (removeMasked.length > 0) {
|
|
257
|
+
stored = stored.filter((key) => !removeMasked.includes(maskValue(key)))
|
|
258
|
+
}
|
|
259
|
+
let values = [...stored]
|
|
260
|
+
for (const value of addValues) {
|
|
261
|
+
if (!values.includes(value)) values.push(value)
|
|
262
|
+
}
|
|
263
|
+
if (values.length === 0) {
|
|
264
|
+
await credentials.unset('TAVILY_API_KEYS')
|
|
265
|
+
await credentials.unset('TAVILY_API_KEY')
|
|
266
|
+
} else {
|
|
267
|
+
if (strategy !== 'rotate') {
|
|
268
|
+
const usageRows = await Promise.all(values.map(async (value) => ({ value, usage: await fetchUsageFor(value) })))
|
|
269
|
+
const usageOf = (value) => {
|
|
270
|
+
const row = usageRows.find((r) => r.value === value)
|
|
271
|
+
return row !== undefined && row.usage !== null ? row.usage : null
|
|
272
|
+
}
|
|
273
|
+
values = orderKeys(values, strategy, usageOf)
|
|
274
|
+
}
|
|
275
|
+
await credentials.set('TAVILY_API_KEYS', values.join(','))
|
|
276
|
+
await credentials.set('TAVILY_API_KEY', values[0])
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const state = readState(MANAGER_STATE)
|
|
280
|
+
const keySavedAt = state.keySavedAt !== undefined && typeof state.keySavedAt === 'object' ? state.keySavedAt : {}
|
|
281
|
+
const now = new Date().toISOString()
|
|
282
|
+
for (const value of values) {
|
|
283
|
+
const masked = maskValue(value)
|
|
284
|
+
if (keySavedAt[masked] === undefined) keySavedAt[masked] = now
|
|
285
|
+
}
|
|
286
|
+
writeState(MANAGER_STATE, { keySavedAt, strategy })
|
|
287
|
+
|
|
288
|
+
return send(res, 200, { ok: true, ...(await buildManagerPayload(values)) })
|
|
289
|
+
}
|
|
290
|
+
return send(res, 405, { ok: false, error: 'method not allowed' })
|
|
291
|
+
} catch (error) {
|
|
292
|
+
send(res, 500, { ok: false, error: String(error && error.message ? error.message : error) })
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
}), 'tavily-backend: route /api/tavily-manager')
|
|
296
|
+
|
|
297
|
+
}
|