opencode-usage-report 0.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/LICENSE +21 -0
- package/README.md +144 -0
- package/package.json +60 -0
- package/src/auth.ts +58 -0
- package/src/cache.ts +97 -0
- package/src/fallback.ts +175 -0
- package/src/index.ts +111 -0
- package/src/normalize.ts +75 -0
- package/src/paths.ts +19 -0
- package/src/providers/index.ts +5 -0
- package/src/providers/kimi.ts +198 -0
- package/src/providers/opencode-go.ts +153 -0
- package/src/render.ts +85 -0
- package/src/report.ts +161 -0
- package/src/tui-format.ts +201 -0
- package/src/tui.tsx +244 -0
- package/src/types.ts +57 -0
- package/src/warn.ts +97 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kaan Chinar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# opencode-usage-report
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/opencode-usage-report)
|
|
4
|
+
[](https://github.com/kaanchinar/opencode-usage-report/actions/workflows/ci.yml)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
|
|
7
|
+
An [opencode](https://opencode.ai) plugin that adds a `/usage` command (and a
|
|
8
|
+
`usage_report` tool) showing the quota windows (5-hour, weekly, monthly) of your
|
|
9
|
+
inference subscriptions — currently **Kimi Code** (`kimi-for-coding`) and
|
|
10
|
+
**OpenCode Go** (`opencode-go`). It fetches from each provider's API, caches
|
|
11
|
+
results on disk, and can fall back to a local estimate when the API is
|
|
12
|
+
unreachable. It also emits background low-quota warnings in the TUI.
|
|
13
|
+
|
|
14
|
+
## Features
|
|
15
|
+
|
|
16
|
+
- `/usage` command and `usage_report` tool for on-demand quota reports, with
|
|
17
|
+
JSON and single-provider filtering.
|
|
18
|
+
- TUI sidebar panel with live progress bars, `NN%` usage, and reset countdowns
|
|
19
|
+
for each quota window.
|
|
20
|
+
- On-disk caching with a configurable TTL, plus `--refresh` to bypass it.
|
|
21
|
+
- Local fallback estimate when the provider API is unreachable and no cache
|
|
22
|
+
exists.
|
|
23
|
+
- Background low-quota TUI warnings on startup and `session.idle`.
|
|
24
|
+
- Privacy-first: API keys are never logged, cached, or rendered.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
### Server plugin (`/usage` command + warnings)
|
|
29
|
+
|
|
30
|
+
Add the plugin to `opencode.json` / `opencode.jsonc` and restart opencode:
|
|
31
|
+
|
|
32
|
+
```jsonc
|
|
33
|
+
{
|
|
34
|
+
"plugin": ["./path/to/opencode-usage-report"] // or an npm spec
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Credentials are read from the same places your other opencode providers already
|
|
39
|
+
use (see [Data sources & privacy](#data-sources--privacy)). Optionally pass
|
|
40
|
+
options in the tuple form:
|
|
41
|
+
|
|
42
|
+
```jsonc
|
|
43
|
+
{
|
|
44
|
+
"plugin": [["./path/to/opencode-usage-report", { "thresholdPercent": 75 }]]
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### TUI sidebar panel
|
|
49
|
+
|
|
50
|
+
Add the TUI module to `tui.json` and restart opencode:
|
|
51
|
+
|
|
52
|
+
```jsonc
|
|
53
|
+
{
|
|
54
|
+
"plugin": ["file:///abs/path/to/opencode-usage-report/src/tui.tsx"]
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The panel renders under opencode's native Context block in the session sidebar
|
|
59
|
+
(order 150) and shows a colored progress bar per quota window, `NN%`, and a
|
|
60
|
+
live reset countdown — refreshed every 60s, on `session.idle`, and on demand via
|
|
61
|
+
the `Usage: refresh now` command (default binding `ctrl+shift+u`). It reuses the
|
|
62
|
+
same cache/fallback pipeline as the command; stale results are marked `(stale)`
|
|
63
|
+
and local estimates `(est)`.
|
|
64
|
+
|
|
65
|
+
TUI options (tuple form): `providers`, `cacheTtlSeconds`, `thresholdPercent`,
|
|
66
|
+
`refreshIntervalSeconds` (default `60`), `barWidth` (default `14`).
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
## Commands
|
|
70
|
+
|
|
71
|
+
- `/usage` — show every configured provider's quota windows.
|
|
72
|
+
- `/usage kimi-for-coding` — filter to a single provider. The argument must match a
|
|
73
|
+
registered provider id **exactly** (`kimi-for-coding` or `opencode-go`); an
|
|
74
|
+
unknown id (e.g. `/usage kimi`) returns a helpful error listing the known ids.
|
|
75
|
+
- `/usage --json` — emit the raw `ProviderReport[]` JSON.
|
|
76
|
+
- `/usage --refresh` — bypass the on-disk cache and fetch live.
|
|
77
|
+
|
|
78
|
+
The command simply calls the `usage_report` tool with those arguments, so the
|
|
79
|
+
tool can also be invoked directly by an agent.
|
|
80
|
+
|
|
81
|
+
## Options
|
|
82
|
+
|
|
83
|
+
| Option | Type | Default | Meaning |
|
|
84
|
+
| ------------------ | ---------- | ------- | -------------------------------------------------------------- |
|
|
85
|
+
| `thresholdPercent` | `number` | `80` | Warn when a window is at/above this percent used. |
|
|
86
|
+
| `cacheTtlSeconds` | `number` | `120` | How long a cached API result is considered fresh. |
|
|
87
|
+
| `providers` | `string[] \| null` | `null` | Providers to report; `null` = every registered adapter. |
|
|
88
|
+
| `fallback` | `boolean` | `true` | Use a local estimate when the API fails and no cache exists. |
|
|
89
|
+
|
|
90
|
+
Malformed option values are ignored and the defaults are kept.
|
|
91
|
+
|
|
92
|
+
## Warnings
|
|
93
|
+
|
|
94
|
+
On startup and again on `session.idle` (both throttled to at most once every 10
|
|
95
|
+
minutes, sharing the same timer), the plugin checks the cached reports. Any
|
|
96
|
+
window at or above `thresholdPercent`, or whose
|
|
97
|
+
status is `rate-limited` / `frozen`, produces a TUI toast. A warning fires once
|
|
98
|
+
per window until that window resets; it re-arms after usage drops below
|
|
99
|
+
`thresholdPercent - 10`. Toast failures (headless/server mode) are swallowed.
|
|
100
|
+
|
|
101
|
+
## Data sources & privacy
|
|
102
|
+
|
|
103
|
+
- Credential resolution order: `OPENCODE_USAGE_<ID>_KEY` env override, then
|
|
104
|
+
`~/.local/share/opencode/auth.json` (`type: "api"` `.key`, or `type: "oauth"`
|
|
105
|
+
`.access`). `$OPENCODE_DATA_HOME` overrides the data directory.
|
|
106
|
+
- APIs: `https://api.kimi.com/coding/v1/usages` and
|
|
107
|
+
`https://opencode.ai/zen/go/v1/usage` (custom `User-Agent` is required by the
|
|
108
|
+
latter).
|
|
109
|
+
- Cache and state live in `<data-home>/usage-report/` (TTL cache, session id,
|
|
110
|
+
warn state).
|
|
111
|
+
- Local fallback reads `opencode.db` read-only.
|
|
112
|
+
- **API keys are never logged, cached, or rendered.** Adapter errors are
|
|
113
|
+
sanitized (key substrings replaced with `<redacted>`) before they reach any
|
|
114
|
+
output, and the live smoke script never prints credentials.
|
|
115
|
+
|
|
116
|
+
## Extending
|
|
117
|
+
|
|
118
|
+
New providers are trivial:
|
|
119
|
+
|
|
120
|
+
1. Implement `ProviderAdapter` (`src/types.ts`) — an `id`, `displayName`, and
|
|
121
|
+
`fetch(cred, opts)` returning `{ windows, extras? }`.
|
|
122
|
+
2. Register it in `src/providers/index.ts` (`adapters` array).
|
|
123
|
+
3. Add fixture-driven tests under `test/`.
|
|
124
|
+
|
|
125
|
+
Reference endpoints for future adapters (not built in v1):
|
|
126
|
+
|
|
127
|
+
- **Gemini**: `cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota`
|
|
128
|
+
(requires Google OAuth).
|
|
129
|
+
- **Claude**: `api.anthropic.com/api/oauth/usage` (requires
|
|
130
|
+
`anthropic-beta: oauth-2025-04-20`, the claude-code User-Agent, and
|
|
131
|
+
`~/.claude/.credentials.json`).
|
|
132
|
+
- **Codex**: the ChatGPT backend usage endpoint.
|
|
133
|
+
|
|
134
|
+
## Development
|
|
135
|
+
|
|
136
|
+
```sh
|
|
137
|
+
npm test # vitest, no network
|
|
138
|
+
npm run typecheck # tsc --noEmit
|
|
139
|
+
npm run smoke -- --yes-live # manual live check (real keys; opt-in)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
MIT © [Kaan Chinar](https://github.com/kaanchinar)
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-usage-report",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "opencode plugin: /usage command showing subscription quota windows for configured providers",
|
|
5
|
+
"author": "Kaan Chinar",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts",
|
|
10
|
+
"./tui": "./src/tui.tsx"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/kaanchinar/opencode-usage-report.git"
|
|
19
|
+
},
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/kaanchinar/opencode-usage-report/issues"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/kaanchinar/opencode-usage-report#readme",
|
|
24
|
+
"keywords": [
|
|
25
|
+
"opencode",
|
|
26
|
+
"opencode-plugin",
|
|
27
|
+
"usage",
|
|
28
|
+
"quota",
|
|
29
|
+
"rate-limit",
|
|
30
|
+
"kimi",
|
|
31
|
+
"tui"
|
|
32
|
+
],
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test": "vitest run",
|
|
35
|
+
"typecheck": "tsc --noEmit",
|
|
36
|
+
"smoke": "tsx scripts/live-smoke.ts",
|
|
37
|
+
"prepublishOnly": "npm run typecheck && npm test"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"@opencode-ai/plugin": "*",
|
|
41
|
+
"@opentui/core": ">=0.5.11",
|
|
42
|
+
"@opentui/solid": ">=0.5.11",
|
|
43
|
+
"solid-js": ">=1.9.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@opencode-ai/plugin": "^1.0.0",
|
|
47
|
+
"@opentui/core": "^0.5.11",
|
|
48
|
+
"@opentui/keymap": "^0.5.11",
|
|
49
|
+
"@opentui/solid": "^0.5.11",
|
|
50
|
+
"@types/node": "^22.0.0",
|
|
51
|
+
"solid-js": "1.9.12",
|
|
52
|
+
"tsx": "^4.19.0",
|
|
53
|
+
"typescript": "^5.6.0",
|
|
54
|
+
"vitest": "^3.0.0"
|
|
55
|
+
},
|
|
56
|
+
"license": "MIT",
|
|
57
|
+
"publishConfig": {
|
|
58
|
+
"access": "public"
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { Credential } from "./types.js";
|
|
4
|
+
import { dataHome } from "./paths.js";
|
|
5
|
+
|
|
6
|
+
/** "kimi-for-coding" -> "OPENCODE_USAGE_KIMI_FOR_CODING_KEY" (non-alphanumerics become "_", uppercased). */
|
|
7
|
+
export function envVarName(providerId: string): string {
|
|
8
|
+
const normalized = providerId.replace(/[^a-zA-Z0-9]/g, "_").toUpperCase();
|
|
9
|
+
return `OPENCODE_USAGE_${normalized}_KEY`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolves a credential for a provider id.
|
|
14
|
+
* Order: (1) env override (empty string = missing), (2) auth.json entry.
|
|
15
|
+
* Never throws; returns null when no usable credential is found.
|
|
16
|
+
*/
|
|
17
|
+
export function resolveCredential(
|
|
18
|
+
providerId: string,
|
|
19
|
+
opts: { env?: NodeJS.ProcessEnv; dataHomeDir?: string } = {},
|
|
20
|
+
): Credential | null {
|
|
21
|
+
try {
|
|
22
|
+
const env = opts.env ?? process.env;
|
|
23
|
+
|
|
24
|
+
const override = env[envVarName(providerId)];
|
|
25
|
+
if (typeof override === "string" && override.trim() !== "") {
|
|
26
|
+
return { type: "api", key: override };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const dir = opts.dataHomeDir ?? dataHome(env);
|
|
30
|
+
const raw = readFileSync(join(dir, "auth.json"), "utf8");
|
|
31
|
+
const parsed: unknown = JSON.parse(raw);
|
|
32
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
33
|
+
|
|
34
|
+
const entry = (parsed as Record<string, unknown>)[providerId];
|
|
35
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) return null;
|
|
36
|
+
|
|
37
|
+
const record = entry as Record<string, unknown>;
|
|
38
|
+
if (record.type === "api") {
|
|
39
|
+
const key = record.key;
|
|
40
|
+
return typeof key === "string" && key !== "" ? { type: "api", key } : null;
|
|
41
|
+
}
|
|
42
|
+
if (record.type === "oauth") {
|
|
43
|
+
const access = record.access;
|
|
44
|
+
return typeof access === "string" && access !== ""
|
|
45
|
+
? { type: "oauth", key: access }
|
|
46
|
+
: null;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Replaces every occurrence of key material (len >= 8) with "<redacted>". Short/null keys are ignored. */
|
|
55
|
+
export function redact(text: string, key: string | null): string {
|
|
56
|
+
if (!key || key.length < 8) return text;
|
|
57
|
+
return text.split(key).join("<redacted>");
|
|
58
|
+
}
|
package/src/cache.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { AdapterResult } from "./types.js";
|
|
5
|
+
import { pluginStateDir } from "./paths.js";
|
|
6
|
+
|
|
7
|
+
export interface CacheEntry {
|
|
8
|
+
fetchedAt: string;
|
|
9
|
+
result: AdapterResult;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** <pluginStateDir>/cache-<providerId>.json */
|
|
13
|
+
function cacheFilePath(providerId: string, env?: NodeJS.ProcessEnv): string {
|
|
14
|
+
return join(pluginStateDir(env), `cache-${providerId}.json`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Reads a cached adapter result. Returns null when the file is missing, unreadable,
|
|
19
|
+
* corrupt, or structurally invalid. `fresh` is true while age <= ttlSeconds
|
|
20
|
+
* (a negative TTL always reports stale).
|
|
21
|
+
*/
|
|
22
|
+
export async function readCache(
|
|
23
|
+
providerId: string,
|
|
24
|
+
ttlSeconds: number,
|
|
25
|
+
env?: NodeJS.ProcessEnv,
|
|
26
|
+
): Promise<{ entry: CacheEntry; fresh: boolean } | null> {
|
|
27
|
+
try {
|
|
28
|
+
const raw = await readFile(cacheFilePath(providerId, env), "utf8");
|
|
29
|
+
const parsed: unknown = JSON.parse(raw);
|
|
30
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
31
|
+
|
|
32
|
+
const candidate = parsed as { fetchedAt?: unknown; result?: unknown };
|
|
33
|
+
if (typeof candidate.fetchedAt !== "string") return null;
|
|
34
|
+
if (candidate.result === null || typeof candidate.result !== "object") return null;
|
|
35
|
+
|
|
36
|
+
const result = candidate.result as { windows?: unknown };
|
|
37
|
+
if (!Array.isArray(result.windows)) return null;
|
|
38
|
+
|
|
39
|
+
const fetchedAtMs = new Date(candidate.fetchedAt).getTime();
|
|
40
|
+
if (Number.isNaN(fetchedAtMs)) return null;
|
|
41
|
+
const ageSeconds = (Date.now() - fetchedAtMs) / 1000;
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
entry: { fetchedAt: candidate.fetchedAt, result: result as AdapterResult },
|
|
45
|
+
fresh: ageSeconds <= ttlSeconds,
|
|
46
|
+
};
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Persists an adapter result atomically: writes a temp file next to the target
|
|
54
|
+
* then renames it into place. Best-effort: never throws.
|
|
55
|
+
*/
|
|
56
|
+
export async function writeCache(
|
|
57
|
+
providerId: string,
|
|
58
|
+
result: AdapterResult,
|
|
59
|
+
env?: NodeJS.ProcessEnv,
|
|
60
|
+
): Promise<void> {
|
|
61
|
+
try {
|
|
62
|
+
const dir = pluginStateDir(env);
|
|
63
|
+
await mkdir(dir, { recursive: true });
|
|
64
|
+
const file = cacheFilePath(providerId, env);
|
|
65
|
+
const entry: CacheEntry = { fetchedAt: new Date().toISOString(), result };
|
|
66
|
+
const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
|
|
67
|
+
await writeFile(tmp, JSON.stringify(entry), "utf8");
|
|
68
|
+
await rename(tmp, file);
|
|
69
|
+
} catch {
|
|
70
|
+
// Cache writes are best-effort; callers fall back to a live fetch.
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Returns a stable session id, creating and persisting a UUID on first use.
|
|
76
|
+
* Best-effort: if the id cannot be persisted, a fresh id is still returned.
|
|
77
|
+
*/
|
|
78
|
+
export async function ensureSessionId(env?: NodeJS.ProcessEnv): Promise<string> {
|
|
79
|
+
const dir = pluginStateDir(env);
|
|
80
|
+
const file = join(dir, "session-id");
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const existing = (await readFile(file, "utf8")).trim();
|
|
84
|
+
if (existing !== "") return existing;
|
|
85
|
+
} catch {
|
|
86
|
+
// Missing/unreadable -> create below.
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const id = randomUUID();
|
|
90
|
+
try {
|
|
91
|
+
await mkdir(dir, { recursive: true });
|
|
92
|
+
await writeFile(file, id, "utf8");
|
|
93
|
+
} catch {
|
|
94
|
+
// Best-effort persistence; return the generated id regardless.
|
|
95
|
+
}
|
|
96
|
+
return id;
|
|
97
|
+
}
|
package/src/fallback.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import type { AdapterResult, UsageWindow } from "./types.js";
|
|
3
|
+
import { dbPath } from "./paths.js";
|
|
4
|
+
|
|
5
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
6
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
7
|
+
|
|
8
|
+
/** Minimal structural view of the node:sqlite API we rely on (kept dependency-optional). */
|
|
9
|
+
interface SqliteStatement {
|
|
10
|
+
all(...params: unknown[]): unknown[];
|
|
11
|
+
}
|
|
12
|
+
interface SqliteDatabase {
|
|
13
|
+
prepare(sql: string): SqliteStatement;
|
|
14
|
+
close(): void;
|
|
15
|
+
}
|
|
16
|
+
type DatabaseSyncCtor = new (path: string, options?: { readOnly?: boolean }) => SqliteDatabase;
|
|
17
|
+
|
|
18
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
19
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
20
|
+
? (value as Record<string, unknown>)
|
|
21
|
+
: null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function toNum(value: unknown): number | null {
|
|
25
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Sums an assistant message's token counts; returns null when no token field is present. */
|
|
29
|
+
function sumTokens(data: unknown): number | null {
|
|
30
|
+
const record = asRecord(data);
|
|
31
|
+
if (record === null) return null;
|
|
32
|
+
const tokens = asRecord(record.tokens);
|
|
33
|
+
if (tokens === null) return null;
|
|
34
|
+
|
|
35
|
+
const total = toNum(tokens.total);
|
|
36
|
+
if (total !== null) return total;
|
|
37
|
+
|
|
38
|
+
let sum = 0;
|
|
39
|
+
let found = false;
|
|
40
|
+
for (const key of ["input", "output", "reasoning"]) {
|
|
41
|
+
const n = toNum(tokens[key]);
|
|
42
|
+
if (n !== null) {
|
|
43
|
+
sum += n;
|
|
44
|
+
found = true;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const cache = asRecord(tokens.cache);
|
|
48
|
+
if (cache !== null) {
|
|
49
|
+
for (const key of ["read", "write"]) {
|
|
50
|
+
const n = toNum(cache[key]);
|
|
51
|
+
if (n !== null) {
|
|
52
|
+
sum += n;
|
|
53
|
+
found = true;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return found ? sum : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isAssistantForProvider(data: unknown, providerId: string): boolean {
|
|
61
|
+
const record = asRecord(data);
|
|
62
|
+
if (record === null) return false;
|
|
63
|
+
return record.role === "assistant" && record.providerID === providerId;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function makeWindow(kind: "5h" | "weekly", label: string, used: number): UsageWindow {
|
|
67
|
+
return {
|
|
68
|
+
kind,
|
|
69
|
+
label,
|
|
70
|
+
usedPercent: null,
|
|
71
|
+
used,
|
|
72
|
+
limit: null,
|
|
73
|
+
remaining: null,
|
|
74
|
+
resetsAt: null,
|
|
75
|
+
status: "ok",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Best-effort local usage estimate read from opencode's SQLite database.
|
|
81
|
+
*
|
|
82
|
+
* The DB is opened READ-ONLY and every step is defensive: a missing file, an
|
|
83
|
+
* absent sqlite runtime, schema drift, malformed rows, or no matching rows all
|
|
84
|
+
* yield null. This function never throws.
|
|
85
|
+
*/
|
|
86
|
+
export async function localEstimate(
|
|
87
|
+
providerId: string,
|
|
88
|
+
env?: NodeJS.ProcessEnv,
|
|
89
|
+
): Promise<AdapterResult | null> {
|
|
90
|
+
let db: SqliteDatabase | null = null;
|
|
91
|
+
try {
|
|
92
|
+
const file = dbPath(env);
|
|
93
|
+
if (!existsSync(file)) return null;
|
|
94
|
+
|
|
95
|
+
let Ctor: DatabaseSyncCtor | null = null;
|
|
96
|
+
try {
|
|
97
|
+
const mod = await import("node:sqlite");
|
|
98
|
+
if (typeof mod.DatabaseSync === "function") {
|
|
99
|
+
Ctor = mod.DatabaseSync as unknown as DatabaseSyncCtor;
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
if (Ctor === null) return null;
|
|
105
|
+
|
|
106
|
+
db = new Ctor(file, { readOnly: true });
|
|
107
|
+
|
|
108
|
+
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all();
|
|
109
|
+
if (!tables.some((row) => asRecord(row)?.name === "message")) return null;
|
|
110
|
+
|
|
111
|
+
const columns = db.prepare("PRAGMA table_info(message)").all();
|
|
112
|
+
const columnNames = new Set(
|
|
113
|
+
columns
|
|
114
|
+
.map((column) => asRecord(column)?.name)
|
|
115
|
+
.filter((name): name is string => typeof name === "string"),
|
|
116
|
+
);
|
|
117
|
+
if (!columnNames.has("data") || !columnNames.has("time_created")) return null;
|
|
118
|
+
|
|
119
|
+
const now = Date.now();
|
|
120
|
+
const weeklyCutoff = now - 7 * DAY_MS;
|
|
121
|
+
const rows = db
|
|
122
|
+
.prepare("SELECT time_created AS time, data AS data FROM message WHERE time_created >= ?")
|
|
123
|
+
.all(weeklyCutoff);
|
|
124
|
+
|
|
125
|
+
const candidates: Array<{ time: number; data: unknown }> = [];
|
|
126
|
+
for (const row of rows) {
|
|
127
|
+
const record = asRecord(row);
|
|
128
|
+
if (record === null) continue;
|
|
129
|
+
const time = toNum(record.time);
|
|
130
|
+
if (time === null) continue;
|
|
131
|
+
const raw = record.data;
|
|
132
|
+
if (typeof raw !== "string") continue;
|
|
133
|
+
|
|
134
|
+
let parsed: unknown;
|
|
135
|
+
try {
|
|
136
|
+
parsed = JSON.parse(raw);
|
|
137
|
+
} catch {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (!isAssistantForProvider(parsed, providerId)) continue;
|
|
141
|
+
candidates.push({ time, data: parsed });
|
|
142
|
+
}
|
|
143
|
+
if (candidates.length === 0) return null;
|
|
144
|
+
|
|
145
|
+
const anyTokens = candidates.some((candidate) => sumTokens(candidate.data) !== null);
|
|
146
|
+
const fiveHourCutoff = now - 5 * HOUR_MS;
|
|
147
|
+
|
|
148
|
+
let used5h = 0;
|
|
149
|
+
let usedWeekly = 0;
|
|
150
|
+
for (const candidate of candidates) {
|
|
151
|
+
const value = anyTokens ? (sumTokens(candidate.data) ?? 0) : 1;
|
|
152
|
+
usedWeekly += value;
|
|
153
|
+
if (candidate.time >= fiveHourCutoff) used5h += value;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
windows: [
|
|
158
|
+
makeWindow("5h", "5-hour (local estimate)", used5h),
|
|
159
|
+
makeWindow("weekly", "Weekly (local estimate)", usedWeekly),
|
|
160
|
+
],
|
|
161
|
+
extras: {
|
|
162
|
+
source: "opencode.db",
|
|
163
|
+
metric: anyTokens ? "tokens" : "messages",
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
} catch {
|
|
167
|
+
return null;
|
|
168
|
+
} finally {
|
|
169
|
+
try {
|
|
170
|
+
db?.close();
|
|
171
|
+
} catch {
|
|
172
|
+
// ignore close failures
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
2
|
+
import { tool } from "@opencode-ai/plugin";
|
|
3
|
+
import { DEFAULT_OPTIONS, collectReports } from "./report.js";
|
|
4
|
+
import { renderJson, renderText } from "./render.js";
|
|
5
|
+
import { checkWarnings } from "./warn.js";
|
|
6
|
+
import { toNumber } from "./normalize.js";
|
|
7
|
+
import type { PluginOptions } from "./types.js";
|
|
8
|
+
|
|
9
|
+
const WARN_THROTTLE_MS = 10 * 60 * 1000;
|
|
10
|
+
|
|
11
|
+
const TOOL_DESCRIPTION =
|
|
12
|
+
"Show subscription usage/quota windows (5h, weekly, monthly) for configured inference providers (kimi-for-coding, opencode-go)";
|
|
13
|
+
|
|
14
|
+
const COMMAND_TEMPLATE =
|
|
15
|
+
"Call the usage_report tool with these arguments: $ARGUMENTS and present the result verbatim.";
|
|
16
|
+
|
|
17
|
+
/** Merges user plugin options over defaults, defensively ignoring malformed values. */
|
|
18
|
+
export function coerceOptions(raw: Record<string, unknown> | undefined): PluginOptions {
|
|
19
|
+
const opts: PluginOptions = { ...DEFAULT_OPTIONS };
|
|
20
|
+
if (raw === undefined) return opts;
|
|
21
|
+
|
|
22
|
+
const threshold = toNumber(raw.thresholdPercent);
|
|
23
|
+
if (threshold !== null) opts.thresholdPercent = threshold;
|
|
24
|
+
|
|
25
|
+
const ttl = toNumber(raw.cacheTtlSeconds);
|
|
26
|
+
if (ttl !== null) opts.cacheTtlSeconds = ttl;
|
|
27
|
+
|
|
28
|
+
if (typeof raw.fallback === "boolean") opts.fallback = raw.fallback;
|
|
29
|
+
|
|
30
|
+
if (Array.isArray(raw.providers)) {
|
|
31
|
+
const ids = raw.providers.filter((id): id is string => typeof id === "string");
|
|
32
|
+
// An empty filter means "all providers", matching `undefined`/`null`.
|
|
33
|
+
opts.providers = ids.length > 0 ? ids : null;
|
|
34
|
+
} else if (raw.providers === null) {
|
|
35
|
+
opts.providers = null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return opts;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const plugin: Plugin = async ({ client }, options) => {
|
|
42
|
+
const opts = coerceOptions(options);
|
|
43
|
+
let lastWarnCheck = 0;
|
|
44
|
+
|
|
45
|
+
/** Throttled warning pass (spec §3.7). Every failure is swallowed (spec §4). */
|
|
46
|
+
const runWarnCheck = async (): Promise<void> => {
|
|
47
|
+
try {
|
|
48
|
+
if (Date.now() - lastWarnCheck < WARN_THROTTLE_MS) return;
|
|
49
|
+
lastWarnCheck = Date.now();
|
|
50
|
+
|
|
51
|
+
const reports = await collectReports({ options: opts });
|
|
52
|
+
const hits = await checkWarnings(reports, opts.thresholdPercent);
|
|
53
|
+
for (const hit of hits) {
|
|
54
|
+
try {
|
|
55
|
+
await client.tui.showToast({
|
|
56
|
+
body: { title: "Usage warning", message: hit.message, variant: "warning" },
|
|
57
|
+
});
|
|
58
|
+
} catch {
|
|
59
|
+
// Toast delivery is best-effort; it is unavailable in headless/server mode.
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
// Never throw into the event bus.
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// Spec §3.6: one throttled warning check at startup, fire-and-forget.
|
|
68
|
+
setTimeout(() => {
|
|
69
|
+
void runWarnCheck();
|
|
70
|
+
}, 0);
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
tool: {
|
|
74
|
+
usage_report: tool({
|
|
75
|
+
description: TOOL_DESCRIPTION,
|
|
76
|
+
args: {
|
|
77
|
+
provider: tool.schema.string().optional(),
|
|
78
|
+
json: tool.schema.boolean().optional(),
|
|
79
|
+
refresh: tool.schema.boolean().optional(),
|
|
80
|
+
},
|
|
81
|
+
async execute(args) {
|
|
82
|
+
const reports = await collectReports({
|
|
83
|
+
providers: args.provider ? [args.provider] : undefined,
|
|
84
|
+
refresh: args.refresh === true,
|
|
85
|
+
options: opts,
|
|
86
|
+
});
|
|
87
|
+
return args.json ? renderJson(reports) : renderText(reports);
|
|
88
|
+
},
|
|
89
|
+
}),
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
async config(cfg) {
|
|
93
|
+
cfg.command ??= {};
|
|
94
|
+
cfg.command.usage ??= {
|
|
95
|
+
description: "Show subscription usage/quota windows for configured providers",
|
|
96
|
+
template: COMMAND_TEMPLATE,
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
async event({ event }) {
|
|
101
|
+
try {
|
|
102
|
+
if (event.type !== "session.idle") return;
|
|
103
|
+
await runWarnCheck();
|
|
104
|
+
} catch {
|
|
105
|
+
// Never throw into the event bus.
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export default plugin;
|