opencode-usage-report 0.1.0 → 0.2.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 +28 -10
- package/package.json +1 -1
- package/src/auth.ts +1 -1
- package/src/index.ts +3 -1
- package/src/providers/index.ts +2 -2
- package/src/providers/kimi.ts +73 -41
- package/src/providers/opencode-go.ts +1 -1
- package/src/report.ts +15 -9
- package/src/tui-format.ts +2 -2
package/README.md
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
An [opencode](https://opencode.ai) plugin that adds a `/usage` command (and a
|
|
8
8
|
`usage_report` tool) showing the quota windows (5-hour, weekly, monthly) of your
|
|
9
|
-
inference subscriptions — currently **Kimi Code** (
|
|
9
|
+
inference subscriptions — currently **Kimi Code** (both regional plans:
|
|
10
|
+
`kimi-code-plan-global` on kimi.ai and `kimi-code-plan-cn` on kimi.com) and
|
|
10
11
|
**OpenCode Go** (`opencode-go`). It fetches from each provider's API, caches
|
|
11
12
|
results on disk, and can fall back to a local estimate when the API is
|
|
12
13
|
unreachable. It also emits background low-quota warnings in the TUI.
|
|
@@ -31,7 +32,7 @@ Add the plugin to `opencode.json` / `opencode.jsonc` and restart opencode:
|
|
|
31
32
|
|
|
32
33
|
```jsonc
|
|
33
34
|
{
|
|
34
|
-
"plugin": ["
|
|
35
|
+
"plugin": ["opencode-usage-report"]
|
|
35
36
|
}
|
|
36
37
|
```
|
|
37
38
|
|
|
@@ -41,20 +42,33 @@ options in the tuple form:
|
|
|
41
42
|
|
|
42
43
|
```jsonc
|
|
43
44
|
{
|
|
44
|
-
"plugin": [["
|
|
45
|
+
"plugin": [["opencode-usage-report", { "thresholdPercent": 75 }]]
|
|
45
46
|
}
|
|
46
47
|
```
|
|
47
48
|
|
|
48
49
|
### TUI sidebar panel
|
|
49
50
|
|
|
50
|
-
Add the
|
|
51
|
+
Add the plugin to `tui.json` and restart opencode:
|
|
51
52
|
|
|
52
53
|
```jsonc
|
|
53
54
|
{
|
|
54
|
-
"plugin": ["
|
|
55
|
+
"plugin": ["opencode-usage-report"]
|
|
55
56
|
}
|
|
56
57
|
```
|
|
57
58
|
|
|
59
|
+
Use the bare package name — opencode resolves the `./tui` entrypoint from the
|
|
60
|
+
package `exports` on its own. (`opencode-usage-report/tui` is **not** a valid
|
|
61
|
+
spec and will silently not load.)
|
|
62
|
+
|
|
63
|
+
For local development, point the configs at the source instead:
|
|
64
|
+
|
|
65
|
+
```jsonc
|
|
66
|
+
// opencode.json
|
|
67
|
+
{ "plugin": ["file:///abs/path/to/opencode-usage-report/src/index.ts"] }
|
|
68
|
+
// tui.json
|
|
69
|
+
{ "plugin": ["file:///abs/path/to/opencode-usage-report/src/tui.tsx"] }
|
|
70
|
+
```
|
|
71
|
+
|
|
58
72
|
The panel renders under opencode's native Context block in the session sidebar
|
|
59
73
|
(order 150) and shows a colored progress bar per quota window, `NN%`, and a
|
|
60
74
|
live reset countdown — refreshed every 60s, on `session.idle`, and on demand via
|
|
@@ -68,10 +82,13 @@ TUI options (tuple form): `providers`, `cacheTtlSeconds`, `thresholdPercent`,
|
|
|
68
82
|
|
|
69
83
|
## Commands
|
|
70
84
|
|
|
71
|
-
- `/usage` — show every configured provider's quota windows.
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
85
|
+
- `/usage` — show every configured provider's quota windows. Providers without a
|
|
86
|
+
resolved credential are skipped in this view.
|
|
87
|
+
- `/usage kimi-code-plan-global` — filter to a single provider. The argument must
|
|
88
|
+
match a registered provider id **exactly** (`kimi-code-plan-global`,
|
|
89
|
+
`kimi-code-plan-cn` or `opencode-go`); an unknown id (e.g. `/usage kimi`)
|
|
90
|
+
returns a helpful error listing the known ids. An explicitly requested
|
|
91
|
+
provider with no credential is reported as an error row.
|
|
75
92
|
- `/usage --json` — emit the raw `ProviderReport[]` JSON.
|
|
76
93
|
- `/usage --refresh` — bypass the on-disk cache and fetch live.
|
|
77
94
|
|
|
@@ -103,7 +120,8 @@ per window until that window resets; it re-arms after usage drops below
|
|
|
103
120
|
- Credential resolution order: `OPENCODE_USAGE_<ID>_KEY` env override, then
|
|
104
121
|
`~/.local/share/opencode/auth.json` (`type: "api"` `.key`, or `type: "oauth"`
|
|
105
122
|
`.access`). `$OPENCODE_DATA_HOME` overrides the data directory.
|
|
106
|
-
- APIs: `https://api.kimi.
|
|
123
|
+
- APIs: `https://api.kimi.ai/coding/v1/usages` (global plan),
|
|
124
|
+
`https://api.kimi.com/coding/v1/usages` (China plan) and
|
|
107
125
|
`https://opencode.ai/zen/go/v1/usage` (custom `User-Agent` is required by the
|
|
108
126
|
latter).
|
|
109
127
|
- Cache and state live in `<data-home>/usage-report/` (TTL cache, session id,
|
package/package.json
CHANGED
package/src/auth.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import type { Credential } from "./types.js";
|
|
4
4
|
import { dataHome } from "./paths.js";
|
|
5
5
|
|
|
6
|
-
/** "kimi-
|
|
6
|
+
/** "kimi-code-plan-global" -> "OPENCODE_USAGE_KIMI_CODE_PLAN_GLOBAL_KEY" (non-alphanumerics become "_", uppercased). */
|
|
7
7
|
export function envVarName(providerId: string): string {
|
|
8
8
|
const normalized = providerId.replace(/[^a-zA-Z0-9]/g, "_").toUpperCase();
|
|
9
9
|
return `OPENCODE_USAGE_${normalized}_KEY`;
|
package/src/index.ts
CHANGED
|
@@ -4,12 +4,14 @@ import { DEFAULT_OPTIONS, collectReports } from "./report.js";
|
|
|
4
4
|
import { renderJson, renderText } from "./render.js";
|
|
5
5
|
import { checkWarnings } from "./warn.js";
|
|
6
6
|
import { toNumber } from "./normalize.js";
|
|
7
|
+
import { adapters } from "./providers/index.js";
|
|
7
8
|
import type { PluginOptions } from "./types.js";
|
|
8
9
|
|
|
9
10
|
const WARN_THROTTLE_MS = 10 * 60 * 1000;
|
|
10
11
|
|
|
11
12
|
const TOOL_DESCRIPTION =
|
|
12
|
-
"Show subscription usage/quota windows (5h, weekly, monthly) for configured inference providers
|
|
13
|
+
"Show subscription usage/quota windows (5h, weekly, monthly) for configured inference providers " +
|
|
14
|
+
`(${adapters.map((adapter) => adapter.id).join(", ")})`;
|
|
13
15
|
|
|
14
16
|
const COMMAND_TEMPLATE =
|
|
15
17
|
"Call the usage_report tool with these arguments: $ARGUMENTS and present the result verbatim.";
|
package/src/providers/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ProviderAdapter } from "../types.js";
|
|
2
|
-
import {
|
|
2
|
+
import { kimiCnAdapter, kimiGlobalAdapter } from "./kimi.js";
|
|
3
3
|
import { opencodeGoAdapter } from "./opencode-go.js";
|
|
4
|
-
export const adapters: ProviderAdapter[] = [
|
|
4
|
+
export const adapters: ProviderAdapter[] = [kimiGlobalAdapter, kimiCnAdapter, opencodeGoAdapter];
|
|
5
5
|
export function getAdapter(id: string): ProviderAdapter | undefined { return adapters.find(a => a.id === id); }
|
package/src/providers/kimi.ts
CHANGED
|
@@ -9,11 +9,22 @@ import { AdapterError } from "../types.js";
|
|
|
9
9
|
import { pick, ratioToPercent, toISODate, toNumber, windowFromCounts } from "../normalize.js";
|
|
10
10
|
import { redact } from "../auth.js";
|
|
11
11
|
|
|
12
|
-
const VERSION = "0.
|
|
13
|
-
|
|
12
|
+
const VERSION = "0.2.0";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Kimi Code is split by region in opencode/models.dev: the global plan lives on
|
|
16
|
+
* api.kimi.ai and the mainland-China plan on api.kimi.com. Both expose the same
|
|
17
|
+
* `GET <base>/usages` contract, so one factory builds both adapters.
|
|
18
|
+
*/
|
|
19
|
+
export interface KimiAdapterConfig {
|
|
20
|
+
id: string;
|
|
21
|
+
displayName: string;
|
|
22
|
+
/** API base including the version segment, e.g. "https://api.kimi.ai/coding/v1". */
|
|
23
|
+
baseUrl: string;
|
|
24
|
+
}
|
|
14
25
|
|
|
15
|
-
function request(cred: Credential, opts: FetchOptions): Promise<Response> {
|
|
16
|
-
return fetch(
|
|
26
|
+
function request(cred: Credential, opts: FetchOptions, endpoint: string): Promise<Response> {
|
|
27
|
+
return fetch(endpoint, {
|
|
17
28
|
method: "GET",
|
|
18
29
|
headers: {
|
|
19
30
|
Authorization: "Bearer " + cred.key,
|
|
@@ -25,11 +36,15 @@ function request(cred: Credential, opts: FetchOptions): Promise<Response> {
|
|
|
25
36
|
}
|
|
26
37
|
|
|
27
38
|
/** One retry on network throw or 5xx; 4xx and 2xx return immediately. */
|
|
28
|
-
async function fetchWithRetry(
|
|
39
|
+
async function fetchWithRetry(
|
|
40
|
+
cred: Credential,
|
|
41
|
+
opts: FetchOptions,
|
|
42
|
+
endpoint: string,
|
|
43
|
+
): Promise<Response> {
|
|
29
44
|
let lastError: unknown = null;
|
|
30
45
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
31
46
|
try {
|
|
32
|
-
const res = await request(cred, opts);
|
|
47
|
+
const res = await request(cred, opts, endpoint);
|
|
33
48
|
if (res.status >= 500) {
|
|
34
49
|
lastError = new Error(`HTTP ${res.status}`);
|
|
35
50
|
continue;
|
|
@@ -157,42 +172,59 @@ function parse(body: Record<string, unknown>, cred: Credential): AdapterResult {
|
|
|
157
172
|
return { windows, extras };
|
|
158
173
|
}
|
|
159
174
|
|
|
160
|
-
export
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
175
|
+
export function createKimiAdapter(config: KimiAdapterConfig): ProviderAdapter {
|
|
176
|
+
const endpoint = `${config.baseUrl}/usages`;
|
|
177
|
+
return {
|
|
178
|
+
id: config.id,
|
|
179
|
+
displayName: config.displayName,
|
|
180
|
+
async fetch(cred: Credential, opts: FetchOptions): Promise<AdapterResult> {
|
|
181
|
+
const res = await fetchWithRetry(cred, opts, endpoint);
|
|
165
182
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
183
|
+
if (res.status === 401) {
|
|
184
|
+
throw new AdapterError("auth", redact("invalid Kimi API key", cred.key));
|
|
185
|
+
}
|
|
186
|
+
if (res.status === 429) {
|
|
187
|
+
throw new AdapterError("rate-limited", redact("Kimi API rate limit exceeded", cred.key));
|
|
188
|
+
}
|
|
189
|
+
if (res.status < 200 || res.status >= 300) {
|
|
190
|
+
throw new AdapterError(
|
|
191
|
+
"bad-response",
|
|
192
|
+
redact(`Kimi API returned HTTP ${res.status}`, cred.key),
|
|
193
|
+
);
|
|
194
|
+
}
|
|
178
195
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
196
|
+
let body: unknown;
|
|
197
|
+
try {
|
|
198
|
+
body = await res.json();
|
|
199
|
+
} catch {
|
|
200
|
+
throw new AdapterError(
|
|
201
|
+
"bad-response",
|
|
202
|
+
redact("Kimi API returned a non-JSON response", cred.key),
|
|
203
|
+
);
|
|
204
|
+
}
|
|
188
205
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
206
|
+
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
|
207
|
+
throw new AdapterError(
|
|
208
|
+
"bad-response",
|
|
209
|
+
redact("Kimi API returned an unexpected response shape", cred.key),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return parse(body as Record<string, unknown>, cred);
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
}
|
|
195
217
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
218
|
+
/** Kimi For Coding (kimi.ai) — the global `kimi-code-plan-global` provider. */
|
|
219
|
+
export const kimiGlobalAdapter: ProviderAdapter = createKimiAdapter({
|
|
220
|
+
id: "kimi-code-plan-global",
|
|
221
|
+
displayName: "Kimi Code (kimi.ai)",
|
|
222
|
+
baseUrl: "https://api.kimi.ai/coding/v1",
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
/** Kimi For Coding (kimi.com) — the mainland-China `kimi-code-plan-cn` provider. */
|
|
226
|
+
export const kimiCnAdapter: ProviderAdapter = createKimiAdapter({
|
|
227
|
+
id: "kimi-code-plan-cn",
|
|
228
|
+
displayName: "Kimi Code (kimi.com)",
|
|
229
|
+
baseUrl: "https://api.kimi.com/coding/v1",
|
|
230
|
+
});
|
|
@@ -11,7 +11,7 @@ import { AdapterError } from "../types.js";
|
|
|
11
11
|
import { toISODate, toNumber } from "../normalize.js";
|
|
12
12
|
import { redact } from "../auth.js";
|
|
13
13
|
|
|
14
|
-
const VERSION = "0.
|
|
14
|
+
const VERSION = "0.2.0";
|
|
15
15
|
const USER_AGENT = "opencode-usage-report/" + VERSION;
|
|
16
16
|
const ENDPOINT = "https://opencode.ai/zen/go/v1/usage";
|
|
17
17
|
|
package/src/report.ts
CHANGED
|
@@ -69,9 +69,14 @@ async function collectOne(
|
|
|
69
69
|
options: PluginOptions,
|
|
70
70
|
refresh: boolean,
|
|
71
71
|
env: NodeJS.ProcessEnv,
|
|
72
|
-
|
|
72
|
+
explicit: boolean,
|
|
73
|
+
): Promise<ProviderReport | null> {
|
|
73
74
|
const cred = resolveCredential(id, { env });
|
|
74
|
-
if (cred === null)
|
|
75
|
+
if (cred === null) {
|
|
76
|
+
// The default view lists only providers that are actually configured; a
|
|
77
|
+
// provider the user asked for by name still reports the missing credential.
|
|
78
|
+
return explicit ? errorReport(id, adapter.displayName, "no credential found") : null;
|
|
79
|
+
}
|
|
75
80
|
|
|
76
81
|
if (!refresh) {
|
|
77
82
|
const cached = await readCache(id, options.cacheTtlSeconds, env);
|
|
@@ -126,8 +131,9 @@ async function collectOne(
|
|
|
126
131
|
|
|
127
132
|
/**
|
|
128
133
|
* Collects normalized usage reports for the requested providers (default: every
|
|
129
|
-
*
|
|
130
|
-
*
|
|
134
|
+
* configured adapter; unconfigured ones are skipped unless requested by name).
|
|
135
|
+
* Never throws: each provider is isolated and failures surface as
|
|
136
|
+
* `source: "error"` rows.
|
|
131
137
|
*/
|
|
132
138
|
export async function collectReports(opts: CollectReportsOptions = {}): Promise<ProviderReport[]> {
|
|
133
139
|
const options: PluginOptions = { ...DEFAULT_OPTIONS, ...(opts.options ?? {}) };
|
|
@@ -135,10 +141,9 @@ export async function collectReports(opts: CollectReportsOptions = {}): Promise<
|
|
|
135
141
|
const refresh = opts.refresh === true;
|
|
136
142
|
|
|
137
143
|
// An empty filter is treated like "no filter": report every registered adapter.
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
adapters.map((adapter) => adapter.id);
|
|
144
|
+
const requested = firstNonEmpty(opts.providers) ?? firstNonEmpty(options.providers);
|
|
145
|
+
const ids = requested ?? adapters.map((adapter) => adapter.id);
|
|
146
|
+
const explicit = requested !== null;
|
|
142
147
|
const reports: ProviderReport[] = [];
|
|
143
148
|
|
|
144
149
|
for (const id of ids) {
|
|
@@ -150,7 +155,8 @@ export async function collectReports(opts: CollectReportsOptions = {}): Promise<
|
|
|
150
155
|
}
|
|
151
156
|
|
|
152
157
|
try {
|
|
153
|
-
|
|
158
|
+
const report = await collectOne(id, adapter, options, refresh, env, explicit);
|
|
159
|
+
if (report !== null) reports.push(report);
|
|
154
160
|
} catch {
|
|
155
161
|
// Last-resort isolation: never let one provider break the whole report.
|
|
156
162
|
reports.push(errorReport(id, adapter.displayName, "internal error while collecting usage"));
|
package/src/tui-format.ts
CHANGED
|
@@ -109,10 +109,10 @@ export function percentText(w: {
|
|
|
109
109
|
const usedIsNumber = typeof used === "number" && Number.isFinite(used);
|
|
110
110
|
const limitIsNumber = typeof limit === "number" && Number.isFinite(limit);
|
|
111
111
|
if (usedIsNumber && limitIsNumber) {
|
|
112
|
-
return `${used.toLocaleString()}/${limit.toLocaleString()}`;
|
|
112
|
+
return `${used.toLocaleString("en-US")}/${limit.toLocaleString("en-US")}`;
|
|
113
113
|
}
|
|
114
114
|
if (usedIsNumber) {
|
|
115
|
-
return `~${used.toLocaleString()}`;
|
|
115
|
+
return `~${used.toLocaleString("en-US")}`;
|
|
116
116
|
}
|
|
117
117
|
return "—";
|
|
118
118
|
}
|