opencode-usage-report 0.1.0 → 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
@@ -6,8 +6,10 @@
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
10
- **OpenCode Go** (`opencode-go`). It fetches from each provider's API, caches
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),
11
+ **OpenCode Go** (`opencode-go`), **GitHub Copilot** (`github-copilot`) and
12
+ **ChatGPT** (`openai`). It fetches from each provider's API, caches
11
13
  results on disk, and can fall back to a local estimate when the API is
12
14
  unreachable. It also emits background low-quota warnings in the TUI.
13
15
 
@@ -31,7 +33,7 @@ Add the plugin to `opencode.json` / `opencode.jsonc` and restart opencode:
31
33
 
32
34
  ```jsonc
33
35
  {
34
- "plugin": ["./path/to/opencode-usage-report"] // or an npm spec
36
+ "plugin": ["opencode-usage-report"],
35
37
  }
36
38
  ```
37
39
 
@@ -41,20 +43,33 @@ options in the tuple form:
41
43
 
42
44
  ```jsonc
43
45
  {
44
- "plugin": [["./path/to/opencode-usage-report", { "thresholdPercent": 75 }]]
46
+ "plugin": [["opencode-usage-report", { "thresholdPercent": 75 }]],
45
47
  }
46
48
  ```
47
49
 
48
50
  ### TUI sidebar panel
49
51
 
50
- Add the TUI module to `tui.json` and restart opencode:
52
+ Add the plugin to `tui.json` and restart opencode:
51
53
 
52
54
  ```jsonc
53
55
  {
54
- "plugin": ["file:///abs/path/to/opencode-usage-report/src/tui.tsx"]
56
+ "plugin": ["opencode-usage-report"],
55
57
  }
56
58
  ```
57
59
 
60
+ Use the bare package name — opencode resolves the `./tui` entrypoint from the
61
+ package `exports` on its own. (`opencode-usage-report/tui` is **not** a valid
62
+ spec and will silently not load.)
63
+
64
+ For local development, point the configs at the source instead:
65
+
66
+ ```jsonc
67
+ // opencode.json
68
+ { "plugin": ["file:///abs/path/to/opencode-usage-report/src/index.ts"] }
69
+ // tui.json
70
+ { "plugin": ["file:///abs/path/to/opencode-usage-report/src/tui.tsx"] }
71
+ ```
72
+
58
73
  The panel renders under opencode's native Context block in the session sidebar
59
74
  (order 150) and shows a colored progress bar per quota window, `NN%`, and a
60
75
  live reset countdown — refreshed every 60s, on `session.idle`, and on demand via
@@ -65,13 +80,16 @@ and local estimates `(est)`.
65
80
  TUI options (tuple form): `providers`, `cacheTtlSeconds`, `thresholdPercent`,
66
81
  `refreshIntervalSeconds` (default `60`), `barWidth` (default `14`).
67
82
 
68
-
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`, `opencode-go`, `github-copilot` or `openai`); an unknown
90
+ id (e.g. `/usage kimi`)
91
+ returns a helpful error listing the known ids. An explicitly requested
92
+ provider with no credential is reported as an error row.
75
93
  - `/usage --json` — emit the raw `ProviderReport[]` JSON.
76
94
  - `/usage --refresh` — bypass the on-disk cache and fetch live.
77
95
 
@@ -80,12 +98,12 @@ tool can also be invoked directly by an agent.
80
98
 
81
99
  ## Options
82
100
 
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. |
101
+ | Option | Type | Default | Meaning |
102
+ | ------------------ | ------------------ | ------- | ------------------------------------------------------------ |
103
+ | `thresholdPercent` | `number` | `80` | Warn when a window is at/above this percent used. |
104
+ | `cacheTtlSeconds` | `number` | `120` | How long a cached API result is considered fresh. |
105
+ | `providers` | `string[] \| null` | `null` | Providers to report; `null` = every registered adapter. |
106
+ | `fallback` | `boolean` | `true` | Use a local estimate when the API fails and no cache exists. |
89
107
 
90
108
  Malformed option values are ignored and the defaults are kept.
91
109
 
@@ -100,12 +118,21 @@ per window until that window resets; it re-arms after usage drops below
100
118
 
101
119
  ## Data sources & privacy
102
120
 
103
- - Credential resolution order: `OPENCODE_USAGE_<ID>_KEY` env override, then
121
+ - Credential resolution order: `OPENCODE_USAGE_<ID>_KEY` env override (optionally
122
+ paired with `OPENCODE_USAGE_<ID>_ACCOUNT_ID`, e.g.
123
+ `OPENCODE_USAGE_OPENAI_ACCOUNT_ID`, to supply the ChatGPT account id), then
104
124
  `~/.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
125
+ `.access`, falling back to `.refresh` for GitHub Copilot). For ChatGPT the
126
+ oauth entry's `.accountId` is read automatically. `$OPENCODE_DATA_HOME`
127
+ overrides the data directory.
128
+ - APIs: `https://api.kimi.ai/coding/v1/usages` (global plan),
129
+ `https://api.kimi.com/coding/v1/usages` (China plan),
107
130
  `https://opencode.ai/zen/go/v1/usage` (custom `User-Agent` is required by the
108
- latter).
131
+ latter), `https://api.github.com/copilot_internal/user` (GitHub Copilot) and
132
+ `https://chatgpt.com/backend-api/wham/usage` (ChatGPT).
133
+ - Auth: run `opencode auth login` and pick **GitHub Copilot** or **OpenAI
134
+ ChatGPT**. ChatGPT additionally needs the account id that login writes to
135
+ `auth.json`; the plugin reads it automatically.
109
136
  - Cache and state live in `<data-home>/usage-report/` (TTL cache, session id,
110
137
  warn state).
111
138
  - Local fallback reads `opencode.db` read-only.
@@ -129,13 +156,17 @@ Reference endpoints for future adapters (not built in v1):
129
156
  - **Claude**: `api.anthropic.com/api/oauth/usage` (requires
130
157
  `anthropic-beta: oauth-2025-04-20`, the claude-code User-Agent, and
131
158
  `~/.claude/.credentials.json`).
132
- - **Codex**: the ChatGPT backend usage endpoint.
133
159
 
134
160
  ## Development
135
161
 
136
162
  ```sh
163
+ npm run lint # oxlint
164
+ npm run lint:fix # oxlint --fix
165
+ npm run format # oxfmt (write in place)
166
+ npm run format:check # oxfmt --check
137
167
  npm test # vitest, no network
138
168
  npm run typecheck # tsc --noEmit
169
+ npm run check # lint + format:check + typecheck + test
139
170
  npm run smoke -- --yes-live # manual live check (real keys; opt-in)
140
171
  ```
141
172
 
package/package.json CHANGED
@@ -1,60 +1,68 @@
1
1
  {
2
2
  "name": "opencode-usage-report",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "opencode plugin: /usage command showing subscription quota windows for configured providers",
5
+ "keywords": [
6
+ "kimi",
7
+ "opencode",
8
+ "opencode-plugin",
9
+ "quota",
10
+ "rate-limit",
11
+ "tui",
12
+ "usage"
13
+ ],
14
+ "homepage": "https://github.com/kaanchinar/opencode-usage-report#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/kaanchinar/opencode-usage-report/issues"
17
+ },
18
+ "license": "MIT",
5
19
  "author": "Kaan Chinar",
6
- "type": "module",
7
- "main": "src/index.ts",
8
- "exports": {
9
- ".": "./src/index.ts",
10
- "./tui": "./src/tui.tsx"
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/kaanchinar/opencode-usage-report.git"
11
23
  },
12
24
  "files": [
13
25
  "src",
14
26
  "README.md"
15
27
  ],
16
- "repository": {
17
- "type": "git",
18
- "url": "git+https://github.com/kaanchinar/opencode-usage-report.git"
28
+ "type": "module",
29
+ "main": "src/index.ts",
30
+ "exports": {
31
+ ".": "./src/index.ts",
32
+ "./tui": "./src/tui.tsx"
19
33
  },
20
- "bugs": {
21
- "url": "https://github.com/kaanchinar/opencode-usage-report/issues"
34
+ "publishConfig": {
35
+ "access": "public"
22
36
  },
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
37
  "scripts": {
34
- "test": "vitest run",
38
+ "lint": "oxlint",
39
+ "lint:fix": "oxlint --fix",
40
+ "format": "oxfmt",
41
+ "format:check": "oxfmt --check",
35
42
  "typecheck": "tsc --noEmit",
43
+ "test": "vitest run",
44
+ "test:watch": "vitest",
36
45
  "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"
46
+ "check": "npm run lint && npm run format:check && npm run typecheck && npm test",
47
+ "prepublishOnly": "npm run lint && npm run format:check && npm run typecheck && npm test"
44
48
  },
45
49
  "devDependencies": {
46
50
  "@opencode-ai/plugin": "^1.0.0",
47
51
  "@opentui/core": "^0.5.11",
48
52
  "@opentui/keymap": "^0.5.11",
49
53
  "@opentui/solid": "^0.5.11",
50
- "@types/node": "^22.0.0",
54
+ "@types/node": "^26.6.2",
55
+ "oxfmt": "^0.70.0",
56
+ "oxlint": "^1.85.0",
51
57
  "solid-js": "1.9.12",
52
58
  "tsx": "^4.19.0",
53
- "typescript": "^5.6.0",
54
- "vitest": "^3.0.0"
59
+ "typescript": "^7.0.2",
60
+ "vitest": "^5.0.1"
55
61
  },
56
- "license": "MIT",
57
- "publishConfig": {
58
- "access": "public"
62
+ "peerDependencies": {
63
+ "@opencode-ai/plugin": "*",
64
+ "@opentui/core": ">=0.5.11",
65
+ "@opentui/solid": ">=0.5.11",
66
+ "solid-js": ">=1.9.0"
59
67
  }
60
68
  }
package/src/auth.ts CHANGED
@@ -1,12 +1,21 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import type { Credential } from "./types.js";
4
- import { dataHome } from "./paths.js";
3
+ import type { Credential } from "./types";
4
+ import { dataHome } from "./paths";
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
+ return providerEnvVarName(providerId, "KEY");
9
+ }
10
+
11
+ /** "openai" -> "OPENCODE_USAGE_OPENAI_ACCOUNT_ID" (same normalization as envVarName). */
12
+ export function accountIdEnvVarName(providerId: string): string {
13
+ return providerEnvVarName(providerId, "ACCOUNT_ID");
14
+ }
15
+
16
+ function providerEnvVarName(providerId: string, suffix: string): string {
8
17
  const normalized = providerId.replace(/[^a-zA-Z0-9]/g, "_").toUpperCase();
9
- return `OPENCODE_USAGE_${normalized}_KEY`;
18
+ return `OPENCODE_USAGE_${normalized}_${suffix}`;
10
19
  }
11
20
 
12
21
  /**
@@ -23,7 +32,10 @@ export function resolveCredential(
23
32
 
24
33
  const override = env[envVarName(providerId)];
25
34
  if (typeof override === "string" && override.trim() !== "") {
26
- return { type: "api", key: override };
35
+ const accountId = env[accountIdEnvVarName(providerId)];
36
+ return typeof accountId === "string" && accountId.trim() !== ""
37
+ ? { type: "api", key: override, accountId }
38
+ : { type: "api", key: override };
27
39
  }
28
40
 
29
41
  const dir = opts.dataHomeDir ?? dataHome(env);
@@ -40,10 +52,21 @@ export function resolveCredential(
40
52
  return typeof key === "string" && key !== "" ? { type: "api", key } : null;
41
53
  }
42
54
  if (record.type === "oauth") {
55
+ // Prefer the access token. opencode stores the GitHub Copilot token in
56
+ // `refresh` (there is no access token), so fall back to it there only;
57
+ // other oauth providers keep the access-only behavior.
43
58
  const access = record.access;
44
- return typeof access === "string" && access !== ""
45
- ? { type: "oauth", key: access }
46
- : null;
59
+ let key = typeof access === "string" && access !== "" ? access : null;
60
+ if (key === null && providerId === "github-copilot") {
61
+ const refresh = record.refresh;
62
+ if (typeof refresh === "string" && refresh !== "") key = refresh;
63
+ }
64
+ if (key === null) return null;
65
+
66
+ const accountId = record.accountId;
67
+ return typeof accountId === "string" && accountId !== ""
68
+ ? { type: "oauth", key, accountId }
69
+ : { type: "oauth", key };
47
70
  }
48
71
  return null;
49
72
  } catch {
package/src/cache.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { join } from "node:path";
4
- import type { AdapterResult } from "./types.js";
5
- import { pluginStateDir } from "./paths.js";
4
+ import type { AdapterResult } from "./types";
5
+ import { pluginStateDir } from "./paths";
6
6
 
7
7
  export interface CacheEntry {
8
8
  fetchedAt: string;
package/src/fallback.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
- import type { AdapterResult, UsageWindow } from "./types.js";
3
- import { dbPath } from "./paths.js";
2
+ import type { AdapterResult, UsageWindow } from "./types";
3
+ import { dbPath } from "./paths";
4
4
 
5
5
  const HOUR_MS = 60 * 60 * 1000;
6
6
  const DAY_MS = 24 * HOUR_MS;
package/src/index.ts CHANGED
@@ -1,15 +1,17 @@
1
1
  import type { Plugin } from "@opencode-ai/plugin";
2
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";
3
+ import { DEFAULT_OPTIONS, collectReports } from "./report";
4
+ import { renderJson, renderText } from "./render";
5
+ import { checkWarnings } from "./warn";
6
+ import { toNumber } from "./normalize";
7
+ import { adapters } from "./providers/index";
8
+ import type { PluginOptions } from "./types";
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.";
package/src/normalize.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { UsageWindow, WindowKind } from "./types.js";
1
+ import type { UsageWindow, WindowKind } from "./types";
2
2
 
3
3
  /** Accepts a number or a numeric string; anything else (including NaN/Infinity) yields null. */
4
4
  export function toNumber(v: unknown): number | null {
@@ -0,0 +1,194 @@
1
+ import type {
2
+ AdapterResult,
3
+ Credential,
4
+ FetchOptions,
5
+ ProviderAdapter,
6
+ UsageWindow,
7
+ WindowKind,
8
+ } from "../types";
9
+ import { AdapterError } from "../types";
10
+ import { toISODate, toNumber } from "../normalize";
11
+ import { redact } from "../auth";
12
+
13
+ const VERSION = "0.3.0";
14
+ const USER_AGENT = "opencode-usage-report/" + VERSION;
15
+ const ENDPOINT = "https://chatgpt.com/backend-api/wham/usage";
16
+
17
+ function asRecord(v: unknown): Record<string, unknown> | null {
18
+ return v !== null && typeof v === "object" && !Array.isArray(v)
19
+ ? (v as Record<string, unknown>)
20
+ : null;
21
+ }
22
+
23
+ /** "foo_bar-baz" -> "Foo Bar Baz". */
24
+ function titleCase(v: string): string {
25
+ return v
26
+ .split(/[\s_-]+/)
27
+ .filter((word) => word !== "")
28
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
29
+ .join(" ");
30
+ }
31
+
32
+ /** Maps a ChatGPT plan string to a friendly label. Pure so it can be unit-tested directly. */
33
+ export function chatgptPlan(plan: unknown): string {
34
+ if (typeof plan === "string" && plan.trim() !== "") {
35
+ const p = plan.toLowerCase();
36
+ if (p === "free") return "ChatGPT Free";
37
+ if (p === "go") return "ChatGPT Go";
38
+ if (p === "plus") return "ChatGPT Plus";
39
+ if (p === "pro") return "ChatGPT Pro";
40
+ if (p === "team") return "ChatGPT Team";
41
+ if (p === "business") return "ChatGPT Business";
42
+ if (p === "enterprise") return "ChatGPT Enterprise";
43
+ return titleCase(plan);
44
+ }
45
+ return "ChatGPT";
46
+ }
47
+
48
+ /** Maps a window length (seconds) to its kind and label. */
49
+ function windowKind(seconds: number | null): { kind: WindowKind; label: string } {
50
+ if (seconds === 18000) return { kind: "5h", label: "5-hour" };
51
+ if (seconds === 86400) return { kind: "daily", label: "Daily" };
52
+ if (seconds === 604800) return { kind: "weekly", label: "Weekly" };
53
+ if (seconds === 2592000) return { kind: "monthly", label: "Monthly" };
54
+ return { kind: "other", label: "Other" };
55
+ }
56
+
57
+ /** Reads the body once as text and best-effort parses JSON, so error and success paths share it. */
58
+ async function readBody(res: Response): Promise<{ text: string; json: unknown }> {
59
+ const text = await res.text().catch(() => "");
60
+ let json: unknown = null;
61
+ try {
62
+ json = JSON.parse(text);
63
+ } catch {
64
+ json = null;
65
+ }
66
+ return { text, json };
67
+ }
68
+
69
+ async function attempt(cred: Credential, accountId: string, opts: FetchOptions): Promise<Response> {
70
+ return fetch(ENDPOINT, {
71
+ method: "GET",
72
+ headers: {
73
+ Authorization: "Bearer " + cred.key,
74
+ "ChatGPT-Account-Id": accountId,
75
+ Accept: "application/json",
76
+ "User-Agent": USER_AGENT,
77
+ },
78
+ signal: AbortSignal.timeout(opts.timeoutMs),
79
+ });
80
+ }
81
+
82
+ /** Exactly one retry on a network throw or 5xx; 4xx is returned immediately (never retried). */
83
+ async function request(cred: Credential, accountId: string, opts: FetchOptions): Promise<Response> {
84
+ try {
85
+ const first = await attempt(cred, accountId, opts);
86
+ if (first.status < 500) return first;
87
+ } catch {
88
+ // fall through to the single retry
89
+ }
90
+
91
+ try {
92
+ return await attempt(cred, accountId, opts);
93
+ } catch (err) {
94
+ throw new AdapterError(
95
+ "network",
96
+ redact(
97
+ `ChatGPT request failed: ${err instanceof Error ? err.message : String(err)}`,
98
+ cred.key,
99
+ ),
100
+ );
101
+ }
102
+ }
103
+
104
+ async function handleError(res: Response, cred: Credential): Promise<never> {
105
+ const { text } = await readBody(res);
106
+
107
+ if (res.status === 401) {
108
+ throw new AdapterError("auth", redact("invalid ChatGPT login", cred.key));
109
+ }
110
+ if (res.status === 403) {
111
+ throw new AdapterError("no-plan", redact("ChatGPT is not enabled for this account", cred.key));
112
+ }
113
+ if (res.status === 429) {
114
+ throw new AdapterError("rate-limited", redact("ChatGPT rate limit reached", cred.key));
115
+ }
116
+ if (res.status >= 500) {
117
+ throw new AdapterError("network", redact(`ChatGPT server error ${res.status}`, cred.key));
118
+ }
119
+ throw new AdapterError(
120
+ "bad-response",
121
+ redact(`ChatGPT unexpected status ${res.status}: ${text.slice(0, 200)}`, cred.key),
122
+ );
123
+ }
124
+
125
+ export const chatgptAdapter: ProviderAdapter = {
126
+ id: "openai",
127
+ displayName: "ChatGPT",
128
+ async fetch(cred: Credential, opts: FetchOptions): Promise<AdapterResult> {
129
+ if (typeof cred.accountId !== "string" || cred.accountId === "") {
130
+ throw new AdapterError("auth", "ChatGPT account id missing (re-run: opencode auth login)");
131
+ }
132
+
133
+ const res = await request(cred, cred.accountId, opts);
134
+ if (!res.ok) return handleError(res, cred);
135
+
136
+ const { json } = await readBody(res);
137
+ const body = asRecord(json);
138
+ const rateLimit = body ? asRecord(body.rate_limit) : null;
139
+ if (!rateLimit) {
140
+ throw new AdapterError(
141
+ "bad-response",
142
+ redact("ChatGPT response missing rate_limit", cred.key),
143
+ );
144
+ }
145
+
146
+ const limitReached = rateLimit.limit_reached === true || rateLimit.allowed === false;
147
+ const windows: UsageWindow[] = [];
148
+ for (const raw of [rateLimit.primary_window, rateLimit.secondary_window]) {
149
+ const window = asRecord(raw);
150
+ if (!window) continue;
151
+ const { kind, label } = windowKind(toNumber(window.limit_window_seconds));
152
+ const used = toNumber(window.used_percent);
153
+ windows.push({
154
+ kind,
155
+ label,
156
+ usedPercent: used !== null ? Math.min(100, Math.max(0, Math.round(used * 10) / 10)) : null,
157
+ used: null,
158
+ limit: null,
159
+ remaining: null,
160
+ resetsAt: toISODate(window.reset_at),
161
+ status: limitReached ? "rate-limited" : "ok",
162
+ });
163
+ }
164
+
165
+ if (windows.length === 0) {
166
+ throw new AdapterError(
167
+ "bad-response",
168
+ redact("ChatGPT response missing usage windows", cred.key),
169
+ );
170
+ }
171
+
172
+ const extras: Record<string, string> = {};
173
+ extras["Plan"] = chatgptPlan(body?.plan_type);
174
+
175
+ const credits = body ? asRecord(body.credits) : null;
176
+ if (credits) {
177
+ if (credits.unlimited === true) {
178
+ extras["Credits"] = "unlimited";
179
+ } else {
180
+ const balance = toNumber(credits.balance);
181
+ if (balance !== null) {
182
+ extras["Credits"] = `$${balance.toFixed(2)}`;
183
+ } else if (
184
+ credits.has_credits === true &&
185
+ (credits.balance === null || credits.balance === undefined)
186
+ ) {
187
+ extras["Credits"] = "available";
188
+ }
189
+ }
190
+ }
191
+
192
+ return { windows, extras };
193
+ },
194
+ };
@@ -0,0 +1,234 @@
1
+ import type {
2
+ AdapterResult,
3
+ Credential,
4
+ FetchOptions,
5
+ ProviderAdapter,
6
+ UsageWindow,
7
+ } from "../types";
8
+ import { AdapterError } from "../types";
9
+ import { toISODate, toNumber } from "../normalize";
10
+ import { redact } from "../auth";
11
+
12
+ const VERSION = "0.3.0";
13
+ const USER_AGENT = "opencode-usage-report/" + VERSION;
14
+ const ENDPOINT = "https://api.github.com/copilot_internal/user";
15
+
16
+ /** Snapshot keys in display order, paired with their human-readable labels. */
17
+ const SNAPSHOTS: Array<{ key: string; label: string }> = [
18
+ { key: "chat", label: "Chat" },
19
+ { key: "completions", label: "Completions" },
20
+ { key: "premium_interactions", label: "Premium interactions" },
21
+ ];
22
+
23
+ function asRecord(v: unknown): Record<string, unknown> | null {
24
+ return v !== null && typeof v === "object" && !Array.isArray(v)
25
+ ? (v as Record<string, unknown>)
26
+ : null;
27
+ }
28
+
29
+ /** "foo_bar-baz" -> "Foo Bar Baz". */
30
+ function titleCase(v: string): string {
31
+ return v
32
+ .split(/[\s_-]+/)
33
+ .filter((word) => word !== "")
34
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
35
+ .join(" ");
36
+ }
37
+
38
+ /**
39
+ * Maps a Copilot SKU (preferred) or plan string to a friendly tier label.
40
+ * Pure so it can be unit-tested directly.
41
+ */
42
+ export function copilotTier(sku: unknown, plan: unknown): string {
43
+ if (typeof sku === "string") {
44
+ const s = sku.toLowerCase();
45
+ if (s === "free_educational_quota") return "Copilot Student";
46
+ if (s === "free" || s.startsWith("free")) return "Copilot Free";
47
+ if (s.includes("pro_plus") || s.includes("pro+")) return "Copilot Pro+";
48
+ if (s.includes("max")) return "Copilot Max";
49
+ if (s.includes("business")) return "Copilot Business";
50
+ if (s.includes("enterprise")) return "Copilot Enterprise";
51
+ if (s.includes("pro")) return "Copilot Pro";
52
+ }
53
+ if (typeof plan === "string" && plan.trim() !== "") {
54
+ const p = plan.toLowerCase();
55
+ if (p === "individual") return "Copilot Individual";
56
+ if (p === "business") return "Copilot Business";
57
+ if (p === "enterprise") return "Copilot Enterprise";
58
+ return titleCase(plan);
59
+ }
60
+ return "Copilot";
61
+ }
62
+
63
+ /** Reads the body once as text and best-effort parses JSON, so error and success paths share it. */
64
+ async function readBody(res: Response): Promise<{ text: string; json: unknown }> {
65
+ const text = await res.text().catch(() => "");
66
+ let json: unknown = null;
67
+ try {
68
+ json = JSON.parse(text);
69
+ } catch {
70
+ json = null;
71
+ }
72
+ return { text, json };
73
+ }
74
+
75
+ async function attempt(cred: Credential, opts: FetchOptions): Promise<Response> {
76
+ return fetch(ENDPOINT, {
77
+ method: "GET",
78
+ headers: {
79
+ Authorization: "Bearer " + cred.key,
80
+ Accept: "application/json",
81
+ "User-Agent": USER_AGENT,
82
+ },
83
+ signal: AbortSignal.timeout(opts.timeoutMs),
84
+ });
85
+ }
86
+
87
+ /** Exactly one retry on a network throw or 5xx; 4xx is returned immediately (never retried). */
88
+ async function request(cred: Credential, opts: FetchOptions): Promise<Response> {
89
+ try {
90
+ const first = await attempt(cred, opts);
91
+ if (first.status < 500) return first;
92
+ } catch {
93
+ // fall through to the single retry
94
+ }
95
+
96
+ try {
97
+ return await attempt(cred, opts);
98
+ } catch (err) {
99
+ throw new AdapterError(
100
+ "network",
101
+ redact(
102
+ `GitHub Copilot request failed: ${err instanceof Error ? err.message : String(err)}`,
103
+ cred.key,
104
+ ),
105
+ );
106
+ }
107
+ }
108
+
109
+ async function handleError(res: Response, cred: Credential): Promise<never> {
110
+ const { text } = await readBody(res);
111
+
112
+ if (res.status === 401 || res.status === 404) {
113
+ throw new AdapterError(
114
+ "auth",
115
+ redact("GitHub Copilot login invalid or not available for this account", cred.key),
116
+ );
117
+ }
118
+ if (res.status === 403) {
119
+ throw new AdapterError(
120
+ "no-plan",
121
+ redact("GitHub Copilot is not enabled for this account", cred.key),
122
+ );
123
+ }
124
+ if (res.status === 429) {
125
+ throw new AdapterError("rate-limited", redact("GitHub Copilot rate limit reached", cred.key));
126
+ }
127
+ if (res.status >= 500) {
128
+ throw new AdapterError(
129
+ "network",
130
+ redact(`GitHub Copilot server error ${res.status}`, cred.key),
131
+ );
132
+ }
133
+ throw new AdapterError(
134
+ "bad-response",
135
+ redact(`GitHub Copilot unexpected status ${res.status}: ${text.slice(0, 200)}`, cred.key),
136
+ );
137
+ }
138
+
139
+ export const copilotAdapter: ProviderAdapter = {
140
+ id: "github-copilot",
141
+ displayName: "GitHub Copilot",
142
+ async fetch(cred: Credential, opts: FetchOptions): Promise<AdapterResult> {
143
+ const res = await request(cred, opts);
144
+ if (!res.ok) return handleError(res, cred);
145
+
146
+ const { json } = await readBody(res);
147
+ const body = asRecord(json);
148
+ if (!body) {
149
+ throw new AdapterError(
150
+ "bad-response",
151
+ redact("GitHub Copilot response was not an object", cred.key),
152
+ );
153
+ }
154
+
155
+ const snapshots = asRecord(body.quota_snapshots) ?? {};
156
+ const reset = toISODate(body.quota_reset_date);
157
+ const tokenBasedBilling = body.token_based_billing === true;
158
+
159
+ const windows: UsageWindow[] = [];
160
+ const extras: Record<string, string> = {};
161
+ let unlimitedCount = 0;
162
+
163
+ for (const { key, label } of SNAPSHOTS) {
164
+ const snapshot = asRecord(snapshots[key]);
165
+ if (!snapshot) continue;
166
+
167
+ if (snapshot.unlimited === true) {
168
+ unlimitedCount += 1;
169
+ extras[label] = "unlimited";
170
+ continue;
171
+ }
172
+
173
+ const entitlement = toNumber(snapshot.entitlement);
174
+ const quotaRemaining = toNumber(snapshot.quota_remaining);
175
+ if (snapshot.has_quota === false || (entitlement === null && quotaRemaining === null)) {
176
+ continue;
177
+ }
178
+
179
+ const limit = toNumber(snapshot.entitlement ?? snapshot.quota_remaining);
180
+ const remaining = toNumber(snapshot.remaining ?? snapshot.quota_remaining);
181
+ const used = limit !== null && remaining !== null ? Math.max(0, limit - remaining) : null;
182
+
183
+ const percentRemaining = toNumber(snapshot.percent_remaining);
184
+ let usedPercent: number | null = null;
185
+ if (percentRemaining !== null) {
186
+ usedPercent = Math.min(100, Math.max(0, Math.round((100 - percentRemaining) * 10) / 10));
187
+ } else if (used !== null && limit !== null && limit > 0) {
188
+ usedPercent = Math.min(100, Math.max(0, Math.round((used / limit) * 1000) / 10));
189
+ }
190
+
191
+ const windowLabel =
192
+ key === "premium_interactions"
193
+ ? tokenBasedBilling
194
+ ? "Monthly credits"
195
+ : "Monthly premium requests"
196
+ : key === "chat"
197
+ ? "Chat (monthly)"
198
+ : "Completions (monthly)";
199
+
200
+ windows.push({
201
+ kind: "monthly",
202
+ label: windowLabel,
203
+ usedPercent,
204
+ used,
205
+ limit,
206
+ remaining,
207
+ resetsAt: reset,
208
+ status: remaining !== null && remaining <= 0 ? "rate-limited" : "ok",
209
+ });
210
+ }
211
+
212
+ extras["Plan"] = copilotTier(body.access_type_sku, body.copilot_plan);
213
+
214
+ // Billing extras come from the premium-interactions snapshot (the billed one).
215
+ const premium = asRecord(snapshots["premium_interactions"]);
216
+ const overage = premium ? toNumber(premium.overage_entitlement) : null;
217
+ if (tokenBasedBilling && overage !== null && overage > 0) {
218
+ extras["Additional usage budget"] = `$${(overage / 100).toFixed(2)}`;
219
+ }
220
+ const creditsUsed = premium ? toNumber(premium.credits_used) : null;
221
+ if (creditsUsed !== null && creditsUsed > 0) {
222
+ extras["Credits used"] = `${creditsUsed} credits ($${(creditsUsed / 100).toFixed(2)})`;
223
+ }
224
+
225
+ if (windows.length === 0 && unlimitedCount === 0) {
226
+ throw new AdapterError(
227
+ "bad-response",
228
+ redact("GitHub Copilot response missing quota snapshots", cred.key),
229
+ );
230
+ }
231
+
232
+ return { windows, extras };
233
+ },
234
+ };
@@ -1,5 +1,15 @@
1
- import type { ProviderAdapter } from "../types.js";
2
- import { kimiAdapter } from "./kimi.js";
3
- import { opencodeGoAdapter } from "./opencode-go.js";
4
- export const adapters: ProviderAdapter[] = [kimiAdapter, opencodeGoAdapter];
5
- export function getAdapter(id: string): ProviderAdapter | undefined { return adapters.find(a => a.id === id); }
1
+ import type { ProviderAdapter } from "../types";
2
+ import { kimiCnAdapter, kimiGlobalAdapter } from "./kimi";
3
+ import { opencodeGoAdapter } from "./opencode-go";
4
+ import { copilotAdapter } from "./copilot";
5
+ import { chatgptAdapter } from "./chatgpt";
6
+ export const adapters: ProviderAdapter[] = [
7
+ kimiGlobalAdapter,
8
+ kimiCnAdapter,
9
+ opencodeGoAdapter,
10
+ copilotAdapter,
11
+ chatgptAdapter,
12
+ ];
13
+ export function getAdapter(id: string): ProviderAdapter | undefined {
14
+ return adapters.find((a) => a.id === id);
15
+ }
@@ -4,16 +4,27 @@ import type {
4
4
  FetchOptions,
5
5
  ProviderAdapter,
6
6
  UsageWindow,
7
- } from "../types.js";
8
- import { AdapterError } from "../types.js";
9
- import { pick, ratioToPercent, toISODate, toNumber, windowFromCounts } from "../normalize.js";
10
- import { redact } from "../auth.js";
11
-
12
- const VERSION = "0.1.0";
13
- const ENDPOINT = "https://api.kimi.com/coding/v1/usages";
7
+ } from "../types";
8
+ import { AdapterError } from "../types";
9
+ import { pick, ratioToPercent, toISODate, toNumber, windowFromCounts } from "../normalize";
10
+ import { redact } from "../auth";
11
+
12
+ const VERSION = "0.3.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;
@@ -56,11 +71,7 @@ function findLimitDetail(limits: unknown, durationMinutes: number): unknown {
56
71
  return undefined;
57
72
  }
58
73
 
59
- function ratioWindow(
60
- kind: UsageWindow["kind"],
61
- label: string,
62
- value: unknown,
63
- ): UsageWindow {
74
+ function ratioWindow(kind: UsageWindow["kind"], label: string, value: unknown): UsageWindow {
64
75
  const ratio = toNumber(pick(value, "used_ratio", "usedRatio"));
65
76
  return {
66
77
  kind,
@@ -157,42 +168,59 @@ function parse(body: Record<string, unknown>, cred: Credential): AdapterResult {
157
168
  return { windows, extras };
158
169
  }
159
170
 
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);
171
+ export function createKimiAdapter(config: KimiAdapterConfig): ProviderAdapter {
172
+ const endpoint = `${config.baseUrl}/usages`;
173
+ return {
174
+ id: config.id,
175
+ displayName: config.displayName,
176
+ async fetch(cred: Credential, opts: FetchOptions): Promise<AdapterResult> {
177
+ const res = await fetchWithRetry(cred, opts, endpoint);
165
178
 
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
- }
179
+ if (res.status === 401) {
180
+ throw new AdapterError("auth", redact("invalid Kimi API key", cred.key));
181
+ }
182
+ if (res.status === 429) {
183
+ throw new AdapterError("rate-limited", redact("Kimi API rate limit exceeded", cred.key));
184
+ }
185
+ if (res.status < 200 || res.status >= 300) {
186
+ throw new AdapterError(
187
+ "bad-response",
188
+ redact(`Kimi API returned HTTP ${res.status}`, cred.key),
189
+ );
190
+ }
178
191
 
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
- }
192
+ let body: unknown;
193
+ try {
194
+ body = await res.json();
195
+ } catch {
196
+ throw new AdapterError(
197
+ "bad-response",
198
+ redact("Kimi API returned a non-JSON response", cred.key),
199
+ );
200
+ }
188
201
 
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
- }
202
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
203
+ throw new AdapterError(
204
+ "bad-response",
205
+ redact("Kimi API returned an unexpected response shape", cred.key),
206
+ );
207
+ }
208
+
209
+ return parse(body as Record<string, unknown>, cred);
210
+ },
211
+ };
212
+ }
195
213
 
196
- return parse(body as Record<string, unknown>, cred);
197
- },
198
- };
214
+ /** Kimi For Coding (kimi.ai) — the global `kimi-code-plan-global` provider. */
215
+ export const kimiGlobalAdapter: ProviderAdapter = createKimiAdapter({
216
+ id: "kimi-code-plan-global",
217
+ displayName: "Kimi Code (kimi.ai)",
218
+ baseUrl: "https://api.kimi.ai/coding/v1",
219
+ });
220
+
221
+ /** Kimi For Coding (kimi.com) — the mainland-China `kimi-code-plan-cn` provider. */
222
+ export const kimiCnAdapter: ProviderAdapter = createKimiAdapter({
223
+ id: "kimi-code-plan-cn",
224
+ displayName: "Kimi Code (kimi.com)",
225
+ baseUrl: "https://api.kimi.com/coding/v1",
226
+ });
@@ -6,12 +6,12 @@ import type {
6
6
  UsageWindow,
7
7
  WindowKind,
8
8
  WindowStatus,
9
- } from "../types.js";
10
- import { AdapterError } from "../types.js";
11
- import { toISODate, toNumber } from "../normalize.js";
12
- import { redact } from "../auth.js";
9
+ } from "../types";
10
+ import { AdapterError } from "../types";
11
+ import { toISODate, toNumber } from "../normalize";
12
+ import { redact } from "../auth";
13
13
 
14
- const VERSION = "0.1.0";
14
+ const VERSION = "0.3.0";
15
15
  const USER_AGENT = "opencode-usage-report/" + VERSION;
16
16
  const ENDPOINT = "https://opencode.ai/zen/go/v1/usage";
17
17
 
@@ -139,7 +139,10 @@ export const opencodeGoAdapter: ProviderAdapter = {
139
139
  const body = asRecord(json);
140
140
  const usage = body ? asRecord(body.usage) : null;
141
141
  if (!usage) {
142
- throw new AdapterError("bad-response", redact("OpenCode Go response missing usage", cred.key));
142
+ throw new AdapterError(
143
+ "bad-response",
144
+ redact("OpenCode Go response missing usage", cred.key),
145
+ );
143
146
  }
144
147
 
145
148
  const windows: UsageWindow[] = [];
package/src/render.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ProviderReport, UsageWindow } from "./types.js";
1
+ import type { ProviderReport, UsageWindow } from "./types";
2
2
 
3
3
  const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
4
4
  const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
@@ -38,7 +38,7 @@ function header(report: ProviderReport, now: Date): string {
38
38
  return `${report.displayName} (${parts.join(", ")})`;
39
39
  }
40
40
 
41
- function windowLine(report: ProviderReport, w: UsageWindow, now: Date): string {
41
+ function windowLine(report: ProviderReport, w: UsageWindow, now: Date, labelWidth: number): string {
42
42
  // Local estimates always carry the `~` marker, even when only absolutes are
43
43
  // known (percent is null): a bare em dash would hide that the row is estimated.
44
44
  const isEstimate = report.source === "local-estimate";
@@ -48,10 +48,11 @@ function windowLine(report: ProviderReport, w: UsageWindow, now: Date): string {
48
48
  : isEstimate
49
49
  ? "~estimate"
50
50
  : "—";
51
- let line = ` ${w.label.padEnd(LABEL_WIDTH)}${percent.padEnd(PERCENT_WIDTH)}`;
51
+ let line = ` ${w.label.padEnd(labelWidth)}${percent.padEnd(PERCENT_WIDTH)}`;
52
52
 
53
53
  if (w.used !== null && w.limit !== null) {
54
- line += `${formatNumber(w.used)} / ${formatNumber(w.limit)} reqs`.padEnd(ABSOLUTES_WIDTH);
54
+ const unit = /credit/i.test(w.label) ? "credits" : "reqs";
55
+ line += `${formatNumber(w.used)} / ${formatNumber(w.limit)} ${unit}`.padEnd(ABSOLUTES_WIDTH);
55
56
  }
56
57
  if (w.resetsAt !== null) {
57
58
  const reset = formatReset(w.resetsAt, now);
@@ -63,13 +64,17 @@ function windowLine(report: ProviderReport, w: UsageWindow, now: Date): string {
63
64
  /** Renders provider reports as a human-readable text table (spec §3.8). */
64
65
  export function renderText(reports: ProviderReport[], opts?: { now?: Date }): string {
65
66
  const now = opts?.now ?? new Date();
67
+ const labelWidth = Math.max(
68
+ LABEL_WIDTH,
69
+ ...reports.flatMap((report) => report.windows.map((w) => w.label.length + 1)),
70
+ );
66
71
  const blocks = reports.map((report) => {
67
72
  if (report.source === "error") {
68
73
  return `⚠ ${report.displayName}: ${report.error ?? "unknown error"}`;
69
74
  }
70
75
  const lines = [header(report, now)];
71
76
  for (const w of report.windows) {
72
- lines.push(windowLine(report, w, now));
77
+ lines.push(windowLine(report, w, now, labelWidth));
73
78
  }
74
79
  for (const [key, value] of Object.entries(report.extras)) {
75
80
  lines.push(` ${key}: ${value}`);
package/src/report.ts CHANGED
@@ -1,9 +1,9 @@
1
- import type { PluginOptions, ProviderAdapter, ProviderReport } from "./types.js";
2
- import { adapters, getAdapter } from "./providers/index.js";
3
- import { redact, resolveCredential } from "./auth.js";
4
- import { ensureSessionId, readCache, writeCache } from "./cache.js";
5
- import type { CacheEntry } from "./cache.js";
6
- import { localEstimate } from "./fallback.js";
1
+ import type { PluginOptions, ProviderAdapter, ProviderReport } from "./types";
2
+ import { adapters, getAdapter } from "./providers/index";
3
+ import { redact, resolveCredential } from "./auth";
4
+ import { ensureSessionId, readCache, writeCache } from "./cache";
5
+ import type { CacheEntry } from "./cache";
6
+ import { localEstimate } from "./fallback";
7
7
 
8
8
  export const DEFAULT_OPTIONS: PluginOptions = {
9
9
  thresholdPercent: 80,
@@ -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);
@@ -97,7 +102,10 @@ async function collectOne(
97
102
  error: null,
98
103
  };
99
104
  } catch (err) {
100
- failure = redact(err instanceof Error ? err.message : String(err), cred.key);
105
+ failure = redact(
106
+ redact(err instanceof Error ? err.message : String(err), cred.key),
107
+ cred.accountId ?? null,
108
+ );
101
109
  }
102
110
 
103
111
  const cached = await readCache(id, options.cacheTtlSeconds, env);
@@ -126,19 +134,19 @@ async function collectOne(
126
134
 
127
135
  /**
128
136
  * 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.
137
+ * configured adapter; unconfigured ones are skipped unless requested by name).
138
+ * Never throws: each provider is isolated and failures surface as
139
+ * `source: "error"` rows.
131
140
  */
132
141
  export async function collectReports(opts: CollectReportsOptions = {}): Promise<ProviderReport[]> {
133
- const options: PluginOptions = { ...DEFAULT_OPTIONS, ...(opts.options ?? {}) };
142
+ const options: PluginOptions = { ...DEFAULT_OPTIONS, ...opts.options };
134
143
  const env = opts.env ?? process.env;
135
144
  const refresh = opts.refresh === true;
136
145
 
137
146
  // 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);
147
+ const requested = firstNonEmpty(opts.providers) ?? firstNonEmpty(options.providers);
148
+ const ids = requested ?? adapters.map((adapter) => adapter.id);
149
+ const explicit = requested !== null;
142
150
  const reports: ProviderReport[] = [];
143
151
 
144
152
  for (const id of ids) {
@@ -150,7 +158,8 @@ export async function collectReports(opts: CollectReportsOptions = {}): Promise<
150
158
  }
151
159
 
152
160
  try {
153
- reports.push(await collectOne(id, adapter, options, refresh, env));
161
+ const report = await collectOne(id, adapter, options, refresh, env, explicit);
162
+ if (report !== null) reports.push(report);
154
163
  } catch {
155
164
  // Last-resort isolation: never let one provider break the whole report.
156
165
  reports.push(errorReport(id, adapter.displayName, "internal error while collecting usage"));
package/src/tui-format.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Pure formatting helpers for the TUI sidebar usage panel. No JSX, no Solid
3
3
  * imports (the only module dependency is the shared `DEFAULT_OPTIONS`).
4
4
  */
5
- import { DEFAULT_OPTIONS } from "./report.js";
5
+ import { DEFAULT_OPTIONS } from "./report";
6
6
 
7
7
  export type ToneName = "success" | "warning" | "error" | "textMuted";
8
8
 
@@ -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
  }
@@ -160,9 +160,7 @@ function toFiniteNumber(value: unknown): number | null {
160
160
  * `barWidth` is floored and clamped to 4..40; `refreshIntervalSeconds` is
161
161
  * rounded and clamped to 5..3600 so the timer can never hot-loop or overflow.
162
162
  */
163
- export function coerceTuiOptions(
164
- raw: Record<string, unknown> | undefined,
165
- ): ResolvedTuiOptions {
163
+ export function coerceTuiOptions(raw: Record<string, unknown> | undefined): ResolvedTuiOptions {
166
164
  const opts: ResolvedTuiOptions = {
167
165
  cacheTtlSeconds: DEFAULT_OPTIONS.cacheTtlSeconds,
168
166
  thresholdPercent: DEFAULT_OPTIONS.thresholdPercent,
package/src/tui.tsx CHANGED
@@ -6,16 +6,9 @@ import type {
6
6
  TuiSlotPlugin,
7
7
  } from "@opencode-ai/plugin/tui";
8
8
  import { For, createSignal } from "solid-js";
9
- import { DEFAULT_OPTIONS, collectReports } from "./report.js";
10
- import {
11
- bar,
12
- coerceTuiOptions,
13
- countdown,
14
- percentText,
15
- toneFor,
16
- windowLabel,
17
- } from "./tui-format.js";
18
- import type { ProviderReport, WindowKind } from "./types.js";
9
+ import { DEFAULT_OPTIONS, collectReports } from "./report";
10
+ import { bar, coerceTuiOptions, countdown, percentText, toneFor, windowLabel } from "./tui-format";
11
+ import type { ProviderReport, WindowKind } from "./types";
19
12
 
20
13
  type Theme = TuiPluginApi["theme"]["current"];
21
14
  type ThemeColor = Theme["text"];
@@ -55,8 +48,7 @@ function clip(value: string, max = 60): string {
55
48
 
56
49
  /** Pads a label to a fixed width, truncating (with a trailing space) when long. */
57
50
  function alignLabel(label: string): string {
58
- const text =
59
- label.length > NAMED_WIDTH ? label.slice(0, NAMED_WIDTH - 1) + " " : label;
51
+ const text = label.length > NAMED_WIDTH ? label.slice(0, NAMED_WIDTH - 1) + " " : label;
60
52
  return text.padEnd(NAMED_WIDTH);
61
53
  }
62
54
 
@@ -94,16 +86,12 @@ function buildLines(
94
86
 
95
87
  if (report.source === "error") {
96
88
  lines.push({
97
- segments: [
98
- { text: clip(report.error ?? "unknown error"), fg: theme.error },
99
- ],
89
+ segments: [{ text: clip(report.error ?? "unknown error"), fg: theme.error }],
100
90
  });
101
91
  continue;
102
92
  }
103
93
 
104
- const windows = [...report.windows].sort(
105
- (a, b) => windowRank(a.kind) - windowRank(b.kind),
106
- );
94
+ const windows = report.windows.toSorted((a, b) => windowRank(a.kind) - windowRank(b.kind));
107
95
  for (const w of windows) {
108
96
  const tone = toneFor(w.usedPercent, w.status);
109
97
  const row: Segment[] = [
@@ -121,9 +109,7 @@ function buildLines(
121
109
 
122
110
  for (const [key, value] of Object.entries(report.extras)) {
123
111
  lines.push({
124
- segments: [
125
- { text: clip(`${key}: ${value}`, EXTRAS_MAX_LENGTH), fg: theme.textMuted },
126
- ],
112
+ segments: [{ text: clip(`${key}: ${value}`, EXTRAS_MAX_LENGTH), fg: theme.textMuted }],
127
113
  });
128
114
  }
129
115
  }
@@ -198,9 +184,7 @@ export const tui: TuiPlugin = async (api, rawOptions) => {
198
184
  },
199
185
  },
200
186
  ],
201
- bindings: [
202
- { key: "ctrl+shift+u", cmd: "usage.refresh", desc: "Refresh usage panel" },
203
- ],
187
+ bindings: [{ key: "ctrl+shift+u", cmd: "usage.refresh", desc: "Refresh usage panel" }],
204
188
  }),
205
189
  );
206
190
 
@@ -215,7 +199,14 @@ export const tui: TuiPlugin = async (api, rawOptions) => {
215
199
  <b>Usage</b>
216
200
  </text>
217
201
  <For
218
- each={buildLines(theme, reports(), busy(), error(), new Date(tick()), options.barWidth)}
202
+ each={buildLines(
203
+ theme,
204
+ reports(),
205
+ busy(),
206
+ error(),
207
+ new Date(tick()),
208
+ options.barWidth,
209
+ )}
219
210
  >
220
211
  {(line) => (
221
212
  <text>
package/src/types.ts CHANGED
@@ -28,6 +28,8 @@ export interface ProviderReport {
28
28
  export interface Credential {
29
29
  type: "api" | "oauth";
30
30
  key: string;
31
+ /** Provider account id, when the auth entry carries one (e.g. ChatGPT). */
32
+ accountId?: string;
31
33
  }
32
34
  export interface AdapterResult {
33
35
  windows: UsageWindow[];
@@ -50,7 +52,10 @@ export interface PluginOptions {
50
52
 
51
53
  export type AdapterErrorKind = "auth" | "no-plan" | "rate-limited" | "network" | "bad-response";
52
54
  export class AdapterError extends Error {
53
- constructor(public kind: AdapterErrorKind, message: string) {
55
+ constructor(
56
+ public kind: AdapterErrorKind,
57
+ message: string,
58
+ ) {
54
59
  super(message);
55
60
  this.name = "AdapterError";
56
61
  }
package/src/warn.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import type { ProviderReport, UsageWindow } from "./types.js";
4
- import { pluginStateDir } from "./paths.js";
5
- import { formatReset } from "./render.js";
3
+ import type { ProviderReport, UsageWindow } from "./types";
4
+ import { pluginStateDir } from "./paths";
5
+ import { formatReset } from "./render";
6
6
 
7
7
  export interface WarnHit {
8
8
  provider: string;
@@ -72,7 +72,8 @@ export async function checkWarnings(
72
72
  for (const w of report.windows) {
73
73
  const key = `${report.provider}/${w.kind}`;
74
74
  const crossed =
75
- (w.usedPercent !== null && w.usedPercent >= thresholdPercent) || HITS_WINDOW_STATUSES.has(w.status);
75
+ (w.usedPercent !== null && w.usedPercent >= thresholdPercent) ||
76
+ HITS_WINDOW_STATUSES.has(w.status);
76
77
 
77
78
  if (crossed) {
78
79
  const prev = state[key];