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 +53 -22
- package/package.json +43 -35
- package/src/auth.ts +31 -8
- package/src/cache.ts +2 -2
- package/src/fallback.ts +2 -2
- package/src/index.ts +8 -6
- package/src/normalize.ts +1 -1
- package/src/providers/chatgpt.ts +194 -0
- package/src/providers/copilot.ts +234 -0
- package/src/providers/index.ts +15 -5
- package/src/providers/kimi.ts +79 -51
- package/src/providers/opencode-go.ts +9 -6
- package/src/render.ts +10 -5
- package/src/report.ts +26 -17
- package/src/tui-format.ts +4 -6
- package/src/tui.tsx +16 -25
- package/src/types.ts +6 -1
- package/src/warn.ts +5 -4
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** (
|
|
10
|
-
|
|
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": ["
|
|
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": [["
|
|
46
|
+
"plugin": [["opencode-usage-report", { "thresholdPercent": 75 }]],
|
|
45
47
|
}
|
|
46
48
|
```
|
|
47
49
|
|
|
48
50
|
### TUI sidebar panel
|
|
49
51
|
|
|
50
|
-
Add the
|
|
52
|
+
Add the plugin to `tui.json` and restart opencode:
|
|
51
53
|
|
|
52
54
|
```jsonc
|
|
53
55
|
{
|
|
54
|
-
"plugin": ["
|
|
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
|
-
|
|
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`, `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
|
|
84
|
-
| ------------------ |
|
|
85
|
-
| `thresholdPercent` | `number`
|
|
86
|
-
| `cacheTtlSeconds` | `number`
|
|
87
|
-
| `providers` | `string[] \| null` | `null`
|
|
88
|
-
| `fallback` | `boolean`
|
|
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
|
|
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`).
|
|
106
|
-
|
|
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.
|
|
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
|
-
"
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
"
|
|
17
|
-
|
|
18
|
-
|
|
28
|
+
"type": "module",
|
|
29
|
+
"main": "src/index.ts",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": "./src/index.ts",
|
|
32
|
+
"./tui": "./src/tui.tsx"
|
|
19
33
|
},
|
|
20
|
-
"
|
|
21
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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": "^
|
|
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": "^
|
|
54
|
-
"vitest": "^
|
|
59
|
+
"typescript": "^7.0.2",
|
|
60
|
+
"vitest": "^5.0.1"
|
|
55
61
|
},
|
|
56
|
-
"
|
|
57
|
-
|
|
58
|
-
"
|
|
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
|
|
4
|
-
import { dataHome } from "./paths
|
|
3
|
+
import type { Credential } from "./types";
|
|
4
|
+
import { dataHome } from "./paths";
|
|
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
|
+
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}
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
5
|
-
import { pluginStateDir } from "./paths
|
|
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
|
|
3
|
-
import { dbPath } from "./paths
|
|
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
|
|
4
|
-
import { renderJson, renderText } from "./render
|
|
5
|
-
import { checkWarnings } from "./warn
|
|
6
|
-
import { toNumber } from "./normalize
|
|
7
|
-
import
|
|
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
|
|
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
|
|
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
|
+
};
|
package/src/providers/index.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
|
-
import type { ProviderAdapter } from "../types
|
|
2
|
-
import {
|
|
3
|
-
import { opencodeGoAdapter } from "./opencode-go
|
|
4
|
-
|
|
5
|
-
|
|
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
|
+
}
|
package/src/providers/kimi.ts
CHANGED
|
@@ -4,16 +4,27 @@ import type {
|
|
|
4
4
|
FetchOptions,
|
|
5
5
|
ProviderAdapter,
|
|
6
6
|
UsageWindow,
|
|
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.
|
|
13
|
-
|
|
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(
|
|
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;
|
|
@@ -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
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
|
10
|
-
import { AdapterError } from "../types
|
|
11
|
-
import { toISODate, toNumber } from "../normalize
|
|
12
|
-
import { redact } from "../auth
|
|
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.
|
|
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(
|
|
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
|
|
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(
|
|
51
|
+
let line = ` ${w.label.padEnd(labelWidth)}${percent.padEnd(PERCENT_WIDTH)}`;
|
|
52
52
|
|
|
53
53
|
if (w.used !== null && w.limit !== null) {
|
|
54
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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);
|
|
@@ -97,7 +102,10 @@ async function collectOne(
|
|
|
97
102
|
error: null,
|
|
98
103
|
};
|
|
99
104
|
} catch (err) {
|
|
100
|
-
failure = redact(
|
|
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
|
-
*
|
|
130
|
-
*
|
|
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, ...
|
|
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
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
10
|
-
import {
|
|
11
|
-
|
|
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 =
|
|
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(
|
|
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(
|
|
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
|
|
4
|
-
import { pluginStateDir } from "./paths
|
|
5
|
-
import { formatReset } from "./render
|
|
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) ||
|
|
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];
|