@cardinal4/opencode-tavily-quota 1.0.1 → 1.1.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 +34 -21
- package/dist/index.js +22 -12
- package/dist/rpc.js +54 -0
- package/dist/tui.js +15 -9
- package/dist/usage.js +61 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -5,6 +5,7 @@ session sidebar.
|
|
|
5
5
|
|
|
6
6
|
```
|
|
7
7
|
Tavily
|
|
8
|
+
resets in 6d 12h
|
|
8
9
|
█████████████████████████ 84% left
|
|
9
10
|
```
|
|
10
11
|
|
|
@@ -20,19 +21,25 @@ Or declare it in `cli.json` to pass the API key option (see
|
|
|
20
21
|
the package so the plugin is not loaded twice.
|
|
21
22
|
|
|
22
23
|
The sidebar follows the built-in quota styling — the heading uses the default
|
|
23
|
-
text color and the body (bar included) uses the muted/subdued text color.
|
|
24
|
-
|
|
24
|
+
text color and the body (bar included) uses the muted/subdued text color. A
|
|
25
|
+
countdown between the heading and the bar shows how long until the monthly
|
|
26
|
+
credits reset. It polls every 10 minutes and can be refreshed on demand with the
|
|
27
|
+
command below.
|
|
25
28
|
|
|
26
29
|
## Requirements
|
|
27
30
|
|
|
28
31
|
- OpenCode 2 (`opencode --version` reports `2.x`).
|
|
29
|
-
- A Tavily API key, supplied
|
|
30
|
-
- `TAVILY_API_KEY` in the environment
|
|
31
|
-
- the `apiKey` plugin option configured
|
|
32
|
-
|
|
33
|
-
`
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
- A Tavily API key, supplied by:
|
|
33
|
+
- `TAVILY_API_KEY` in the CLI environment,
|
|
34
|
+
- the `apiKey` plugin option configured in `cli.json`, or
|
|
35
|
+
- OpenCode's active Tavily connection (use `opencode auth login tavily` or
|
|
36
|
+
`/connect` to connect Tavily).
|
|
37
|
+
|
|
38
|
+
The CLI environment takes precedence over `apiKey`; OpenCode's active
|
|
39
|
+
connection is used when neither supplies a key. The server resolves its saved
|
|
40
|
+
credential and fetches `/usage` itself. Only quota data returns to the TUI;
|
|
41
|
+
the saved key is never sent over plugin RPC or displayed. With a remote server,
|
|
42
|
+
install this plugin there too to use its active connection.
|
|
36
43
|
|
|
37
44
|
The `apiKey` option also accepts `{file:...}` and `{env:...}` references, which
|
|
38
45
|
the plugin expands itself because `cli.json` does not (unlike `opencode.jsonc`).
|
|
@@ -40,12 +47,13 @@ the plugin expands itself because `cli.json` does not (unlike `opencode.jsonc`).
|
|
|
40
47
|
## Layout
|
|
41
48
|
|
|
42
49
|
```
|
|
43
|
-
index.ts # server entrypoint
|
|
50
|
+
index.ts # server entrypoint: integration credential + quota RPC
|
|
51
|
+
rpc.ts # shared RPC definition
|
|
44
52
|
tui.tsx # TUI entrypoint: sidebar slot + command
|
|
45
53
|
usage.ts # key resolution, /usage fetch, formatting
|
|
46
54
|
usage.test.ts # unit tests for usage.ts
|
|
47
55
|
scripts/build.mjs
|
|
48
|
-
dist/ # built entrypoints shipped to npm
|
|
56
|
+
dist/ # built entrypoints shipped to npm
|
|
49
57
|
```
|
|
50
58
|
|
|
51
59
|
`npm run build` compiles the TypeScript/JSX sources into `dist/`, which is what
|
|
@@ -55,12 +63,13 @@ Solid JSX runtime (`@opentui/solid`) is only provided at runtime, not from the
|
|
|
55
63
|
installed plugin.
|
|
56
64
|
|
|
57
65
|
OpenCode installs package plugins under `~/.cache/opencode/npm/`. The server
|
|
58
|
-
entrypoint (`dist/index.js`)
|
|
59
|
-
(`dist/tui.js`)
|
|
66
|
+
entrypoint (`dist/index.js`) registers the quota RPC; the `tui` entrypoint
|
|
67
|
+
(`dist/tui.js`) renders the sidebar in the CLI.
|
|
60
68
|
|
|
61
69
|
## Configuration
|
|
62
70
|
|
|
63
|
-
|
|
71
|
+
If you already connected Tavily in OpenCode, no key option is needed. To pass
|
|
72
|
+
the API key as a plugin option instead, register the plugin in `cli.json`:
|
|
64
73
|
|
|
65
74
|
```json title="cli.json"
|
|
66
75
|
{
|
|
@@ -108,7 +117,7 @@ reported in the sidebar instead of being used as the key.
|
|
|
108
117
|
## Commands
|
|
109
118
|
|
|
110
119
|
- `/tavily-quota` — slash command that re-fetches and reports the current quota
|
|
111
|
-
in a toast.
|
|
120
|
+
and reset countdown in a toast.
|
|
112
121
|
- "Refresh Tavily quota" — the same action from the command palette (`Ctrl+P`).
|
|
113
122
|
|
|
114
123
|
## Data source
|
|
@@ -119,22 +128,26 @@ e.g. `Researcher` with 1000 monthly credits) and falls back to the per-key limit
|
|
|
119
128
|
when the plan limit is absent. A `null` per-key limit (unlimited key) is
|
|
120
129
|
therefore handled.
|
|
121
130
|
|
|
131
|
+
The response has no reset timestamp, so the sidebar countdown is derived from
|
|
132
|
+
the calendar: Tavily resets credits on the first day of each month (not the
|
|
133
|
+
billing date), and the plugin assumes `00:00 UTC` for that reset. If Tavily
|
|
134
|
+
later exposes an explicit reset time, prefer it over this derivation.
|
|
135
|
+
|
|
122
136
|
## Development
|
|
123
137
|
|
|
124
138
|
`usage.ts` is deliberately free of OpenCode and OpenTUI imports so it can be
|
|
125
|
-
tested anywhere.
|
|
126
|
-
|
|
127
|
-
error branch using an injected `fetch`.
|
|
139
|
+
tested anywhere. The tests cover quota math, local-key precedence and fallback,
|
|
140
|
+
the connected-key RPC, and HTTP error handling using a mock `fetch`.
|
|
128
141
|
|
|
129
142
|
Run the tests and type check from the repository root:
|
|
130
143
|
|
|
131
144
|
```sh
|
|
132
145
|
npm install
|
|
133
|
-
npm test
|
|
146
|
+
npm test
|
|
134
147
|
npm run typecheck
|
|
135
148
|
```
|
|
136
149
|
|
|
137
150
|
## Notes
|
|
138
151
|
|
|
139
|
-
- The
|
|
140
|
-
|
|
152
|
+
- The server handles OpenCode-connected credentials and their quota requests;
|
|
153
|
+
the CLI handles local keys and renders TUI slots.
|
package/dist/index.js
CHANGED
|
@@ -1,21 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Server entrypoint
|
|
3
|
-
*
|
|
4
|
-
* OpenCode discovers a local plugin directory by its `index` (server) entry and
|
|
5
|
-
* loads the `tui` entry beside it for the terminal UI. All rendering happens in
|
|
6
|
-
* the CLI runtime (`tui.tsx`); this file only exists so the directory is a
|
|
7
|
-
* complete plugin and shows up by id instead of as an anonymous entry.
|
|
8
|
-
*
|
|
9
|
-
* It deliberately imports nothing beyond `@opencode/plugin` so the background
|
|
10
|
-
* service can load it in any runtime.
|
|
2
|
+
* Server entrypoint. Resolve the active Tavily integration credential and
|
|
3
|
+
* fetch quota here; only the quota result crosses the RPC boundary to the TUI.
|
|
11
4
|
*/
|
|
12
5
|
|
|
13
6
|
import { Plugin } from "@opencode/plugin";
|
|
7
|
+
import { TavilyQuotaRpc } from "./rpc.js";
|
|
8
|
+
import { fetchQuota } from "./usage.js";
|
|
14
9
|
export const PLUGIN_ID = "opencode-tavily-quota";
|
|
15
10
|
export default Plugin.define({
|
|
16
11
|
id: PLUGIN_ID,
|
|
17
|
-
setup() {
|
|
18
|
-
|
|
19
|
-
|
|
12
|
+
async setup(ctx) {
|
|
13
|
+
await ctx.rpc.register(TavilyQuotaRpc, {
|
|
14
|
+
usage: async () => {
|
|
15
|
+
const connection = await ctx.integration.connection.active("tavily");
|
|
16
|
+
const credential = connection && (await ctx.integration.connection.resolve(connection));
|
|
17
|
+
const key = credential?.type === "key" ? credential.key : connection?.type === "env" ? process.env[connection.name] : undefined;
|
|
18
|
+
if (!key) {
|
|
19
|
+
return {
|
|
20
|
+
ok: false,
|
|
21
|
+
message: "no active Tavily connection (run opencode auth login tavily)"
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
return fetchQuota({
|
|
25
|
+
apiKey: key,
|
|
26
|
+
env: {}
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
});
|
|
20
30
|
}
|
|
21
31
|
});
|
package/dist/rpc.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Rpc } from "@opencode/plugin/rpc";
|
|
2
|
+
|
|
3
|
+
/** The server fetches quota so the saved OpenCode credential never enters the TUI. */
|
|
4
|
+
export const TavilyQuotaRpc = Rpc.define({
|
|
5
|
+
id: "opencode-tavily-quota",
|
|
6
|
+
methods: {
|
|
7
|
+
usage: {
|
|
8
|
+
input: {
|
|
9
|
+
type: "object",
|
|
10
|
+
properties: {},
|
|
11
|
+
additionalProperties: false
|
|
12
|
+
},
|
|
13
|
+
output: {
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: {
|
|
16
|
+
ok: {
|
|
17
|
+
type: "boolean"
|
|
18
|
+
},
|
|
19
|
+
message: {
|
|
20
|
+
type: "string"
|
|
21
|
+
},
|
|
22
|
+
snapshot: {
|
|
23
|
+
type: "object",
|
|
24
|
+
properties: {
|
|
25
|
+
planName: {
|
|
26
|
+
type: "string"
|
|
27
|
+
},
|
|
28
|
+
used: {
|
|
29
|
+
type: "number"
|
|
30
|
+
},
|
|
31
|
+
limit: {
|
|
32
|
+
type: "number"
|
|
33
|
+
},
|
|
34
|
+
remaining: {
|
|
35
|
+
type: "number"
|
|
36
|
+
},
|
|
37
|
+
percentRemaining: {
|
|
38
|
+
type: "number"
|
|
39
|
+
},
|
|
40
|
+
updatedAt: {
|
|
41
|
+
type: "number"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
required: ["planName", "used", "limit", "remaining", "percentRemaining", "updatedAt"],
|
|
45
|
+
additionalProperties: false
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
required: ["ok"],
|
|
49
|
+
additionalProperties: false
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
events: {}
|
|
54
|
+
});
|
package/dist/tui.js
CHANGED
|
@@ -19,14 +19,14 @@ import { createElement as _$createElement } from "@opentui/solid";
|
|
|
19
19
|
* Colors follow the built-in quota sections: the heading uses the default text
|
|
20
20
|
* color and the body (bar included) uses the muted/subdued text color.
|
|
21
21
|
*
|
|
22
|
-
* Authenticate with `TAVILY_API_KEY`,
|
|
23
|
-
*
|
|
24
|
-
* live in `./usage.ts`.
|
|
22
|
+
* Authenticate with `TAVILY_API_KEY`, the `apiKey` plugin option in `cli.json`,
|
|
23
|
+
* or OpenCode's active Tavily integration. A saved key is used server-side.
|
|
25
24
|
*/
|
|
26
25
|
|
|
27
26
|
import { Plugin } from "@opencode/plugin/tui";
|
|
28
27
|
import { createSignal } from "solid-js";
|
|
29
|
-
import {
|
|
28
|
+
import { fetchQuotaWithFallback, formatPercent, formatQuotaLine, formatRemaining, formatResetLine } from "./usage.js";
|
|
29
|
+
import { TavilyQuotaRpc } from "./rpc.js";
|
|
30
30
|
export const PLUGIN_ID = "opencode-tavily-quota";
|
|
31
31
|
// Tavily's `/usage` endpoint allows at most 10 requests per 10 minutes, so poll
|
|
32
32
|
// once per window and rely on the `/tavily-quota` command for on-demand reads.
|
|
@@ -43,7 +43,7 @@ function readThemeColors(context) {
|
|
|
43
43
|
textMuted: text.subdued ?? text.muted
|
|
44
44
|
};
|
|
45
45
|
}
|
|
46
|
-
export function buildLines(state) {
|
|
46
|
+
export function buildLines(state, now = Date.now()) {
|
|
47
47
|
if (state.status === "loading") {
|
|
48
48
|
return ["loading…"];
|
|
49
49
|
}
|
|
@@ -51,7 +51,9 @@ export function buildLines(state) {
|
|
|
51
51
|
return [`⚠ ${state.message}`];
|
|
52
52
|
}
|
|
53
53
|
const snapshot = state.snapshot;
|
|
54
|
-
|
|
54
|
+
// Reset countdown sits between the heading and the bar, matching the sidebar
|
|
55
|
+
// order used by the built-in quota section.
|
|
56
|
+
return [formatResetLine(now), formatQuotaLine(snapshot)];
|
|
55
57
|
}
|
|
56
58
|
function TavilyQuotaSidebar(props) {
|
|
57
59
|
const colors = () => readThemeColors(props.context);
|
|
@@ -100,7 +102,7 @@ function TavilyQuotaCommands(props) {
|
|
|
100
102
|
},
|
|
101
103
|
run: async () => {
|
|
102
104
|
const next = await props.refresh();
|
|
103
|
-
const message = next.status === "ready" ? `${formatRemaining(next.snapshot)} · ${formatPercent(next.snapshot.percentRemaining)}` : next.status === "error" ? next.message : "still loading…";
|
|
105
|
+
const message = next.status === "ready" ? `${formatRemaining(next.snapshot)} · ${formatPercent(next.snapshot.percentRemaining)} · ${formatResetLine()}` : next.status === "error" ? next.message : "still loading…";
|
|
104
106
|
props.context.ui.toast.show({
|
|
105
107
|
title: "Tavily quota",
|
|
106
108
|
message,
|
|
@@ -118,6 +120,7 @@ export const TavilyQuotaTuiPlugin = Plugin.define({
|
|
|
118
120
|
status: "loading"
|
|
119
121
|
});
|
|
120
122
|
const apiKey = typeof context.options.apiKey === "string" ? context.options.apiKey : undefined;
|
|
123
|
+
const remote = context.client.rpc(TavilyQuotaRpc);
|
|
121
124
|
const refreshMs = typeof context.options.refreshMs === "number" && context.options.refreshMs > 0 ? context.options.refreshMs : REFRESH_INTERVAL_MS;
|
|
122
125
|
let disposed = false;
|
|
123
126
|
let inFlight = false;
|
|
@@ -125,8 +128,11 @@ export const TavilyQuotaTuiPlugin = Plugin.define({
|
|
|
125
128
|
if (inFlight) return state();
|
|
126
129
|
inFlight = true;
|
|
127
130
|
try {
|
|
128
|
-
const result = await
|
|
129
|
-
apiKey
|
|
131
|
+
const result = await fetchQuotaWithFallback({
|
|
132
|
+
apiKey,
|
|
133
|
+
remote: () => remote.usage({}, {
|
|
134
|
+
location: context.location ?? context.data.location.default()
|
|
135
|
+
})
|
|
130
136
|
});
|
|
131
137
|
if (!disposed) {
|
|
132
138
|
setState(result.ok ? {
|
package/dist/usage.js
CHANGED
|
@@ -36,6 +36,29 @@ export const DEFAULT_BAR_WIDTH = SIDEBAR_WIDTH - BAR_SEPARATOR.length - PERCENT_
|
|
|
36
36
|
|
|
37
37
|
/** Shape of `GET https://api.tavily.com/usage` (all fields optional). */
|
|
38
38
|
|
|
39
|
+
/** Prefer CLI credentials; ask the server only when the CLI has no key. */
|
|
40
|
+
export async function fetchQuotaWithFallback(options) {
|
|
41
|
+
try {
|
|
42
|
+
const key = resolveApiKey(options.env, options.apiKey, options);
|
|
43
|
+
if (key) return fetchQuota({
|
|
44
|
+
...options,
|
|
45
|
+
apiKey: key,
|
|
46
|
+
env: {}
|
|
47
|
+
});
|
|
48
|
+
} catch {
|
|
49
|
+
// Preserve the existing inline error for an unreadable {file:...} option.
|
|
50
|
+
return fetchQuota(options);
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
return await options.remote();
|
|
54
|
+
} catch (error) {
|
|
55
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
message: `OpenCode Tavily credential unavailable: ${reason}`
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
39
62
|
function finite(value) {
|
|
40
63
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
41
64
|
}
|
|
@@ -162,6 +185,44 @@ export function formatQuotaLine(snapshot, width = DEFAULT_BAR_WIDTH) {
|
|
|
162
185
|
export function formatRemaining(snapshot) {
|
|
163
186
|
return `${snapshot.remaining} credits left`;
|
|
164
187
|
}
|
|
188
|
+
const MS_PER_MINUTE = 60_000;
|
|
189
|
+
const MS_PER_HOUR = 3_600_000;
|
|
190
|
+
const MS_PER_DAY = 86_400_000;
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* When the credit quota next resets, as epoch milliseconds.
|
|
194
|
+
*
|
|
195
|
+
* Tavily resets credits on the first day of each month (calendar-based, not the
|
|
196
|
+
* billing date) and `/usage` does not report when. Derive it as `00:00 UTC` on
|
|
197
|
+
* the 1st of the following month. Tavily does not document a reset timezone;
|
|
198
|
+
* UTC is assumed (see the README).
|
|
199
|
+
*/
|
|
200
|
+
export function nextMonthlyResetAt(now = Date.now()) {
|
|
201
|
+
const date = new Date(now);
|
|
202
|
+
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1, 0, 0, 0, 0);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Countdown to `resetAt`, e.g. `12d 4h`, `5h 12m`, or `45m`.
|
|
207
|
+
*
|
|
208
|
+
* Days and hours are floored so the value never overstates the time left; a
|
|
209
|
+
* sub-minute remainder rounds up to `1m`, and a past reset reads `0m`.
|
|
210
|
+
*/
|
|
211
|
+
export function formatResetCountdown(resetAt, now = Date.now()) {
|
|
212
|
+
const diff = resetAt - now;
|
|
213
|
+
if (!Number.isFinite(diff) || diff <= 0) return "0m";
|
|
214
|
+
const days = Math.floor(diff / MS_PER_DAY);
|
|
215
|
+
const hours = Math.floor(diff % MS_PER_DAY / MS_PER_HOUR);
|
|
216
|
+
const minutes = Math.floor(diff % MS_PER_HOUR / MS_PER_MINUTE);
|
|
217
|
+
if (days > 0) return `${days}d ${hours}h`;
|
|
218
|
+
if (hours > 0) return `${hours}h ${minutes}m`;
|
|
219
|
+
return `${Math.max(1, minutes)}m`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Sidebar line for the next reset, e.g. `resets in 12d 4h`. */
|
|
223
|
+
export function formatResetLine(now = Date.now()) {
|
|
224
|
+
return `resets in ${formatResetCountdown(nextMonthlyResetAt(now), now)}`;
|
|
225
|
+
}
|
|
165
226
|
|
|
166
227
|
/**
|
|
167
228
|
* Fetch and derive the current Tavily quota.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@cardinal4/opencode-tavily-quota",
|
|
4
|
-
"version": "1.0
|
|
4
|
+
"version": "1.1.0",
|
|
5
5
|
"description": "OpenCode 2 TUI plugin that shows remaining Tavily API credits in the sidebar.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "MIT",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"scripts": {
|
|
20
20
|
"build": "node scripts/build.mjs",
|
|
21
21
|
"prepublishOnly": "npm run build",
|
|
22
|
-
"test": "tsx --test usage.test.ts",
|
|
22
|
+
"test": "tsx --test usage.test.ts index.test.ts",
|
|
23
23
|
"typecheck": "tsc --noEmit"
|
|
24
24
|
},
|
|
25
25
|
"keywords": [
|