@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 CHANGED
@@ -1,32 +1,32 @@
1
1
  # @moguiyu/dsh-tavily
2
2
 
3
- Combined plugin: the opt-in advanced Tavily model tools (`tavily_search`, `tavily_extract`,
4
- `tavily_map`, `tavily_crawl`), the **Tavily Search** settings card, and the local HTTP backend
5
- for key/usage management.
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`). Opt-in and
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
- - **Settings card** — key list, usage gauge, strategy selector, and the advanced-tool switch.
13
- - **Plugin-config settings card** — the Host installs the `tavily-search` settings section
14
- and the card is keyed by it (keyed `settings.plugin.item`), so the Plugins configuration tab
15
- serves the card exactly when this plugin is composed (needs DSH **0.1.0-rc.7 or newer**,
16
- including `0.1.2-alpha.x`, the `0.1.3` line, and the `0.1.5`/current `0.1.6` lines — both seam
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 DeepSeek `web_search` keeps its
22
- native provider and schema. Tavily is an extra, opt-in search option.
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`). Client half: prebuilt `window.__ModuleLoader__` bundle
25
- at `lib/client.js`, generated from `src/client.js` via `pnpm build`.
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
- The host half composes the two standalone packages instead of carrying a second copy of the
28
- implementation: the tool comes from [`@moguiyu/dsh-tool-tavily-search`](../dsh-tool-tavily-search)
29
- and the routes/switch pipeline from [`@moguiyu/dsh-tavily-backend`](../dsh-tavily-backend).
30
- Both are regular dependencies, installed automatically with this package.
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
- const isOff = enabled === false
287
-
288
- // Card box mirrors the native plugin-config card (PluginCard.module.css,
289
- // ui-settings-plugins): bg-layer-3 fill, .5px border-l4, 16px radius,
290
- // hover/open border lift, and the open state's bg-layer-2 upgrade — so
291
- // the card sits at the same elevation as native cards in dark mode too.
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 card the host renders. 0.1.6-alpha.2 asks a bundle-config entry for
442
- // `view: 'page'` only; the legacy keyed settings slot passes no view at all.
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') return react.createElement(TavilySummary, null)
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 off the Settings page and
459
- // replaced the keyed `settings.plugin.item` slot with the Plugins page's
460
- // `plugins.*` family (`dsh-client-ui-plugin-manager` declares it).
461
- // `slots.inject` fires only for a slot the host actually declares, so
462
- // injecting both IS the feature detection: exactly one mounts per harness
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.2.8",
4
- "description": "Opt-in advanced Tavily model tools for DeepSeek Harness with the Tavily Search settings card (rc.7/rc.8 and the 0.1.2-0.1.6 plugin management lines). The built-in web_search is never replaced.",
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
+ }