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 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** (`kimi-for-coding`) and
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": ["./path/to/opencode-usage-report"] // or an npm spec
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": [["./path/to/opencode-usage-report", { "thresholdPercent": 75 }]]
45
+ "plugin": [["opencode-usage-report", { "thresholdPercent": 75 }]]
45
46
  }
46
47
  ```
47
48
 
48
49
  ### TUI sidebar panel
49
50
 
50
- Add the TUI module to `tui.json` and restart opencode:
51
+ Add the plugin to `tui.json` and restart opencode:
51
52
 
52
53
  ```jsonc
53
54
  {
54
- "plugin": ["file:///abs/path/to/opencode-usage-report/src/tui.tsx"]
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
- - `/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.
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.com/coding/v1/usages` and
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-usage-report",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "opencode plugin: /usage command showing subscription quota windows for configured providers",
5
5
  "author": "Kaan Chinar",
6
6
  "type": "module",
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-for-coding" -> "OPENCODE_USAGE_KIMI_FOR_CODING_KEY" (non-alphanumerics become "_", uppercased). */
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 (kimi-for-coding, opencode-go)";
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.";
@@ -1,5 +1,5 @@
1
1
  import type { ProviderAdapter } from "../types.js";
2
- import { kimiAdapter } from "./kimi.js";
2
+ import { kimiCnAdapter, kimiGlobalAdapter } from "./kimi.js";
3
3
  import { opencodeGoAdapter } from "./opencode-go.js";
4
- export const adapters: ProviderAdapter[] = [kimiAdapter, opencodeGoAdapter];
4
+ export const adapters: ProviderAdapter[] = [kimiGlobalAdapter, kimiCnAdapter, opencodeGoAdapter];
5
5
  export function getAdapter(id: string): ProviderAdapter | undefined { return adapters.find(a => a.id === id); }
@@ -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.1.0";
13
- const ENDPOINT = "https://api.kimi.com/coding/v1/usages";
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(ENDPOINT, {
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(cred: Credential, opts: FetchOptions): Promise<Response> {
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 const kimiAdapter: ProviderAdapter = {
161
- id: "kimi-for-coding",
162
- displayName: "Kimi Code",
163
- async fetch(cred: Credential, opts: FetchOptions): Promise<AdapterResult> {
164
- const res = await fetchWithRetry(cred, opts);
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
- if (res.status === 401) {
167
- throw new AdapterError("auth", redact("invalid Kimi API key", cred.key));
168
- }
169
- if (res.status === 429) {
170
- throw new AdapterError("rate-limited", redact("Kimi API rate limit exceeded", cred.key));
171
- }
172
- if (res.status < 200 || res.status >= 300) {
173
- throw new AdapterError(
174
- "bad-response",
175
- redact(`Kimi API returned HTTP ${res.status}`, cred.key),
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
- let body: unknown;
180
- try {
181
- body = await res.json();
182
- } catch {
183
- throw new AdapterError(
184
- "bad-response",
185
- redact("Kimi API returned a non-JSON response", cred.key),
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
- if (body === null || typeof body !== "object" || Array.isArray(body)) {
190
- throw new AdapterError(
191
- "bad-response",
192
- redact("Kimi API returned an unexpected response shape", cred.key),
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
- return parse(body as Record<string, unknown>, cred);
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.1.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
- ): Promise<ProviderReport> {
72
+ explicit: boolean,
73
+ ): Promise<ProviderReport | null> {
73
74
  const cred = resolveCredential(id, { env });
74
- if (cred === null) return errorReport(id, adapter.displayName, "no credential found");
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
- * registered adapter). Never throws: each provider is isolated and failures
130
- * surface as `source: "error"` rows.
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 ids =
139
- firstNonEmpty(opts.providers) ??
140
- firstNonEmpty(options.providers) ??
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
- reports.push(await collectOne(id, adapter, options, refresh, env));
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
  }