@moguiyu/dsh-tavily 0.2.7 → 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
@@ -78,9 +65,7 @@ window.__ModuleLoader__.load({
78
65
  )
79
66
  }
80
67
 
81
- function TavilyCard() {
82
- const [enabled, setEnabled] = react.useState(null)
83
- const [expanded, setExpanded] = react.useState(false)
68
+ function TavilySettingsCard() {
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,21 +357,33 @@ 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
 
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.
368
+ function TavilyCard(props) {
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
+ }
372
+ return react.createElement(TavilySettingsCard, null)
373
+ }
374
+
441
375
  function apply(ctx) {
442
- ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({
443
- name: 'settings.plugin.item',
444
- // rc.7 plugin management: this card is keyed by the settings namespace
445
- // the Host serves (`tavily-search`); the Plugins configuration tab
446
- // dispatches it when the namespace is present. The id/order/label props
447
- // keep rc.6 list-slot deployments rendering the card as well.
448
- key: 'tavily-search',
449
- id: 'tavily-search',
450
- order: 30,
451
- label: 'Tavily Search'
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`.
381
+ ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({
382
+ name: 'plugins.bundle.config',
383
+ // Keyed by the bundle's package name. The Plugins page renders this on
384
+ // the @moguiyu/dsh-tavily bundle page, between its description and rows.
385
+ key: '@moguiyu/dsh-tavily',
386
+ id: '@moguiyu/dsh-tavily'
452
387
  }, TavilyCard))
453
388
  }
454
389
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@moguiyu/dsh-tavily",
3
- "version": "0.2.7",
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": {
@@ -36,14 +36,14 @@
36
36
  "0.1.5-alpha.2": "compatible",
37
37
  "0.1.5-rc.1": "compatible",
38
38
  "0.1.5-rc.2": "compatible",
39
- "0.1.6-alpha.1": "compatible"
39
+ "0.1.6-alpha.1": "compatible",
40
+ "0.1.6-alpha.2": "compatible"
40
41
  }
41
42
  },
42
43
  "client": {
43
44
  "platform": "web",
44
45
  "inject": [
45
- "@deepseek-ai/dsh-client-ui-settings",
46
- "@deepseek-ai/dsh-client-ui-settings-plugins"
46
+ "@deepseek-ai/dsh-client-ui-plugin-manager"
47
47
  ]
48
48
  }
49
49
  },
@@ -66,16 +66,9 @@
66
66
  "peerDependencies": {
67
67
  "@deepseek-ai/cordis": "^4.0.1",
68
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",
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",
70
- "@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"
71
- },
72
- "dependencies": {
73
- "@deepseek-ai/schemastery": "^3.18.2",
74
- "@moguiyu/dsh-tavily-backend": "^0.2.1",
75
- "@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"
76
70
  },
77
71
  "devDependencies": {
78
- "@deepseek-ai/dsh-settings": "^0.1.2-alpha.2",
79
72
  "@deepseek-ai/dsh-system-prompt": "^0.1.2-alpha.2"
80
73
  }
81
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
+ }