opencode-usage-report 0.4.0 → 0.4.1
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 +4 -1
- package/package.json +1 -2
- package/src/context/collect.ts +12 -53
- package/src/context/estimate.ts +2 -27
- package/src/context/format.ts +6 -38
- package/src/context/grid.ts +4 -18
- package/src/context/headroom.ts +1 -4
- package/src/context/system.ts +2 -34
- package/src/context/types.ts +0 -1
- package/src/fallback.ts +1 -6
- package/src/normalize.ts +17 -27
- package/src/providers/chatgpt.ts +24 -78
- package/src/providers/copilot.ts +28 -86
- package/src/providers/http.ts +90 -0
- package/src/providers/kimi.ts +47 -59
- package/src/providers/opencode-go.ts +33 -89
- package/src/quota-lines.ts +4 -21
- package/src/render.ts +11 -14
- package/src/tui-format.ts +12 -0
- package/src/tui.tsx +4 -7
package/README.md
CHANGED
|
@@ -283,7 +283,7 @@ Releases are **staged**, not published directly. Pushing a `v*` tag triggers
|
|
|
283
283
|
the staged release with 2FA:
|
|
284
284
|
|
|
285
285
|
```sh
|
|
286
|
-
git tag v0.4.
|
|
286
|
+
git tag v0.4.1 && git push origin main v0.4.1
|
|
287
287
|
npm stage list # find the stage id
|
|
288
288
|
npm stage approve <stage-id> # 2FA prompt; or approve in the npm UI
|
|
289
289
|
```
|
|
@@ -293,6 +293,9 @@ so a failing check blocks the stage.
|
|
|
293
293
|
|
|
294
294
|
## Changelog
|
|
295
295
|
|
|
296
|
+
- **0.4.1** — internal: shared provider HTTP plumbing (`src/providers/http.ts`)
|
|
297
|
+
replaces the copy-pasted fetch/retry/error code; context estimates simplified;
|
|
298
|
+
reset times render as `Wed, Sep 16`.
|
|
296
299
|
- **0.4.0** — context usage panel (`/usage` now shows context breakdown, grid,
|
|
297
300
|
compaction headroom and cost); `/usage` became a local TUI command that costs
|
|
298
301
|
no model tokens; sidebar rendering extracted to `src/quota-lines.ts`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-usage-report",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "opencode plugin: /usage command showing subscription quota windows for configured providers",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"kimi",
|
|
@@ -49,7 +49,6 @@
|
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@opencode-ai/plugin": "^1.0.0",
|
|
51
51
|
"@opentui/core": "^0.5.11",
|
|
52
|
-
"@opentui/keymap": "^0.5.11",
|
|
53
52
|
"@opentui/solid": "^0.5.11",
|
|
54
53
|
"@types/node": "^26.6.2",
|
|
55
54
|
"oxfmt": "^0.70.0",
|
package/src/context/collect.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AssistantMessage, Part } from "@opencode-ai/sdk/v2";
|
|
2
2
|
import type { CollectInput, ContextBreakdown, ContextRow, RowKey, SystemCapture } from "./types";
|
|
3
3
|
import { estimateJson, estimateTokens } from "./estimate";
|
|
4
|
+
import { finiteOrNull, isRecord, safeString } from "../normalize";
|
|
4
5
|
import { buildGrid } from "./grid";
|
|
5
6
|
import { computeHeadroom } from "./headroom";
|
|
6
7
|
|
|
@@ -15,24 +16,6 @@ const ROW_LABELS: Record<RowKey, string> = {
|
|
|
15
16
|
|
|
16
17
|
const MEASURED_KEYS: readonly RowKey[] = ["user", "agent", "reasoning", "tools", "system"];
|
|
17
18
|
|
|
18
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
19
|
-
return typeof value === "object" && value !== null;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function finiteOrNull(value: unknown): number | null {
|
|
23
|
-
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function safeString(value: unknown): string {
|
|
27
|
-
if (typeof value === "string") return value;
|
|
28
|
-
if (value === null || value === undefined) return "";
|
|
29
|
-
try {
|
|
30
|
-
return String(value);
|
|
31
|
-
} catch {
|
|
32
|
-
return "";
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
19
|
function isAssistant(value: unknown): value is AssistantMessage {
|
|
37
20
|
return isRecord(value) && value.role === "assistant";
|
|
38
21
|
}
|
|
@@ -72,49 +55,25 @@ function isUsableCapture(capture: SystemCapture | null | undefined): capture is
|
|
|
72
55
|
}
|
|
73
56
|
|
|
74
57
|
/**
|
|
75
|
-
* Scales `values` so they sum to `target
|
|
76
|
-
*
|
|
58
|
+
* Scales `values` proportionally so they sum to at most `target`; each row is an
|
|
59
|
+
* estimate, so flooring may leave the five rows a few tokens short of the total.
|
|
77
60
|
*/
|
|
78
|
-
function
|
|
79
|
-
values: readonly number[],
|
|
80
|
-
target: number,
|
|
81
|
-
divisor: number,
|
|
82
|
-
): number[] {
|
|
61
|
+
function scaleToTotal(values: readonly number[], target: number, divisor: number): number[] {
|
|
83
62
|
if (!Number.isFinite(divisor) || divisor <= 0) return values.map(() => 0);
|
|
84
63
|
const total = Math.max(0, Math.round(target));
|
|
85
|
-
|
|
86
|
-
const rounded = scaled.map((value) => Math.floor(value));
|
|
87
|
-
let deficit = total - rounded.reduce((sum, value) => sum + value, 0);
|
|
88
|
-
if (deficit <= 0) return rounded;
|
|
89
|
-
|
|
90
|
-
const order = scaled
|
|
91
|
-
.map((value, index) => ({ index, fraction: value - Math.floor(value) }))
|
|
92
|
-
.sort((a, b) => b.fraction - a.fraction);
|
|
93
|
-
for (const { index } of order) {
|
|
94
|
-
if (deficit <= 0) break;
|
|
95
|
-
rounded[index] += 1;
|
|
96
|
-
deficit -= 1;
|
|
97
|
-
}
|
|
98
|
-
return rounded;
|
|
64
|
+
return values.map((value) => (value > 0 ? Math.floor((value * total) / divisor) : 0));
|
|
99
65
|
}
|
|
100
66
|
|
|
101
|
-
function makeRow(
|
|
102
|
-
key: RowKey,
|
|
103
|
-
tokens: number | null,
|
|
104
|
-
exact: boolean,
|
|
105
|
-
limit: number | null,
|
|
106
|
-
): ContextRow {
|
|
67
|
+
function makeRow(key: RowKey, tokens: number | null, limit: number | null): ContextRow {
|
|
107
68
|
const percent =
|
|
108
69
|
tokens !== null && limit !== null && limit > 0
|
|
109
70
|
? Math.min(100, Math.max(0, Math.round((tokens / limit) * 100)))
|
|
110
71
|
: null;
|
|
111
|
-
return { key, label: ROW_LABELS[key], tokens, percent
|
|
72
|
+
return { key, label: ROW_LABELS[key], tokens, percent };
|
|
112
73
|
}
|
|
113
74
|
|
|
114
75
|
function emptyRows(limit: number | null): ContextRow[] {
|
|
115
|
-
return [...MEASURED_KEYS, "free" as RowKey].map((key) =>
|
|
116
|
-
makeRow(key, null, key === "free", limit),
|
|
117
|
-
);
|
|
76
|
+
return [...MEASURED_KEYS, "free" as RowKey].map((key) => makeRow(key, null, limit));
|
|
118
77
|
}
|
|
119
78
|
|
|
120
79
|
/**
|
|
@@ -212,9 +171,9 @@ export function collectContext(input: CollectInput): ContextBreakdown {
|
|
|
212
171
|
let systemTokens = Math.max(0, total - estimatedSum);
|
|
213
172
|
|
|
214
173
|
// chars/4 is an estimate in both directions; when it overshoots the exact
|
|
215
|
-
// total, scale all five measured rows down so they sum to `total` (6.5).
|
|
174
|
+
// total, scale all five measured rows down so they sum to at most `total` (6.5).
|
|
216
175
|
if (estimatedSum > total) {
|
|
217
|
-
const scaled =
|
|
176
|
+
const scaled = scaleToTotal(
|
|
218
177
|
[userTokens, agentTokens, reasoningTokens, toolsTokens, systemTokens],
|
|
219
178
|
total,
|
|
220
179
|
estimatedSum,
|
|
@@ -227,9 +186,9 @@ export function collectContext(input: CollectInput): ContextBreakdown {
|
|
|
227
186
|
}
|
|
228
187
|
|
|
229
188
|
const measured = [userTokens, agentTokens, reasoningTokens, toolsTokens, systemTokens];
|
|
230
|
-
const rows = MEASURED_KEYS.map((key, index) => makeRow(key, measured[index],
|
|
189
|
+
const rows = MEASURED_KEYS.map((key, index) => makeRow(key, measured[index], limit));
|
|
231
190
|
const freeTokens = limit !== null ? Math.max(0, limit - total) : null;
|
|
232
|
-
rows.push(makeRow("free", freeTokens,
|
|
191
|
+
rows.push(makeRow("free", freeTokens, limit));
|
|
233
192
|
|
|
234
193
|
return {
|
|
235
194
|
ready: true,
|
package/src/context/estimate.ts
CHANGED
|
@@ -5,32 +5,7 @@ export function estimateTokens(text: string): number {
|
|
|
5
5
|
return Math.max(0, Math.round(value.length / 4));
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Estimates tokens for the JSON serialization of an arbitrary value. Circular
|
|
12
|
-
* references and unserializable values are handled without throwing, and the
|
|
13
|
-
* serialized prefix is capped so allocation stays bounded.
|
|
14
|
-
*/
|
|
8
|
+
/** Estimates tokens for the JSON serialization of a value that came from JSON. */
|
|
15
9
|
export function estimateJson(value: unknown): number {
|
|
16
|
-
|
|
17
|
-
try {
|
|
18
|
-
const seen = new WeakSet<object>();
|
|
19
|
-
json =
|
|
20
|
-
JSON.stringify(value, (_key, val) => {
|
|
21
|
-
if (typeof val === "bigint") return val.toString();
|
|
22
|
-
if (typeof val === "object" && val !== null) {
|
|
23
|
-
if (seen.has(val)) return "[Circular]";
|
|
24
|
-
seen.add(val);
|
|
25
|
-
}
|
|
26
|
-
return val;
|
|
27
|
-
}) ?? "";
|
|
28
|
-
} catch {
|
|
29
|
-
try {
|
|
30
|
-
json = String(value);
|
|
31
|
-
} catch {
|
|
32
|
-
json = "";
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
return estimateTokens(json.length > MAX_JSON_CHARS ? json.slice(0, MAX_JSON_CHARS) : json);
|
|
10
|
+
return estimateTokens(JSON.stringify(value) ?? "");
|
|
36
11
|
}
|
package/src/context/format.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* same shape `buildLines` uses in `src/quota-lines.ts`.
|
|
5
5
|
*/
|
|
6
6
|
import type { ContextBreakdown, ContextRow, GridCell, RowKey } from "./types";
|
|
7
|
+
import { finiteOrNull, nonNegative } from "../normalize";
|
|
8
|
+
import { padLabel } from "../tui-format";
|
|
7
9
|
|
|
8
10
|
/** Theme color names used by the context block. */
|
|
9
11
|
export type ContextTone =
|
|
@@ -40,15 +42,6 @@ const LEGEND_PERCENT_WIDTH = 6;
|
|
|
40
42
|
const LEGEND_GAP = " ";
|
|
41
43
|
const COLUMNS_PER_LEGEND_ROW = 3;
|
|
42
44
|
|
|
43
|
-
const ROW_LABELS: Record<RowKey, string> = {
|
|
44
|
-
user: "User messages",
|
|
45
|
-
agent: "Agent responses",
|
|
46
|
-
reasoning: "Reasoning",
|
|
47
|
-
tools: "Tool calls",
|
|
48
|
-
system: "System & tools",
|
|
49
|
-
free: "Free space",
|
|
50
|
-
};
|
|
51
|
-
|
|
52
45
|
const CATEGORY_TONES: Record<RowKey, ContextTone> = {
|
|
53
46
|
user: "info",
|
|
54
47
|
agent: "success",
|
|
@@ -79,15 +72,6 @@ export function formatMoney(n: number): string {
|
|
|
79
72
|
return MONEY.format(value);
|
|
80
73
|
}
|
|
81
74
|
|
|
82
|
-
function finite(value: unknown): number | null {
|
|
83
|
-
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function nonNegative(value: unknown): number | null {
|
|
87
|
-
const n = finite(value);
|
|
88
|
-
return n !== null && n >= 0 ? n : null;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
75
|
function glyphFor(fill: number): string {
|
|
92
76
|
if (fill <= 0) return "░░";
|
|
93
77
|
if (fill <= 0.25) return "▒▒";
|
|
@@ -100,19 +84,6 @@ function toneForRowKey(key: unknown): ContextTone {
|
|
|
100
84
|
return CATEGORY_TONES[key as RowKey] ?? "borderSubtle";
|
|
101
85
|
}
|
|
102
86
|
|
|
103
|
-
function truncateLabel(label: string, width: number): string {
|
|
104
|
-
if (label.length <= width) return label;
|
|
105
|
-
return label.slice(0, Math.max(0, width - 1)) + "…";
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function padLabel(label: string, width: number): string {
|
|
109
|
-
return truncateLabel(label, width).padEnd(width);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function rightAlign(value: string, width: number): string {
|
|
113
|
-
return value.length >= width ? value : " ".repeat(width - value.length) + value;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
87
|
/**
|
|
117
88
|
* One-decimal percentage. A non-zero share that would round to `0.0%` reads as
|
|
118
89
|
* "nothing at all", so anything under a tenth of a percent collapses to `<0.1%`.
|
|
@@ -144,10 +115,7 @@ function rowMap(breakdown: ContextBreakdown): Map<RowKey, ContextRow> {
|
|
|
144
115
|
}
|
|
145
116
|
|
|
146
117
|
function legendEntry(key: RowKey, row: ContextRow | undefined, systemDerived: boolean): Segment[] {
|
|
147
|
-
const baseLabel =
|
|
148
|
-
row !== undefined && typeof row.label === "string" && row.label !== ""
|
|
149
|
-
? row.label
|
|
150
|
-
: ROW_LABELS[key];
|
|
118
|
+
const baseLabel = row?.label ?? key;
|
|
151
119
|
const label = key === "system" && systemDerived ? `${baseLabel} (derived)` : baseLabel;
|
|
152
120
|
const tokens = nonNegative(row?.tokens);
|
|
153
121
|
const percent = nonNegative(row?.percent);
|
|
@@ -156,11 +124,11 @@ function legendEntry(key: RowKey, row: ContextRow | undefined, systemDerived: bo
|
|
|
156
124
|
{ text: "● ", fg: CATEGORY_TONES[key] },
|
|
157
125
|
{ text: padLabel(label, LEGEND_LABEL_WIDTH), fg: "text" },
|
|
158
126
|
{
|
|
159
|
-
text:
|
|
127
|
+
text: (tokens === null ? "—" : formatTokens(tokens)).padStart(LEGEND_COUNT_WIDTH, " "),
|
|
160
128
|
fg: "text",
|
|
161
129
|
},
|
|
162
130
|
{ text: " ", fg: "text" },
|
|
163
|
-
{ text:
|
|
131
|
+
{ text: percentText.padStart(LEGEND_PERCENT_WIDTH, " "), fg: "textMuted" },
|
|
164
132
|
];
|
|
165
133
|
}
|
|
166
134
|
|
|
@@ -178,7 +146,7 @@ function gridLines(breakdown: ContextBreakdown): Line[] {
|
|
|
178
146
|
const segments: Segment[] = [];
|
|
179
147
|
for (let c = 0; c < renderCols; c++) {
|
|
180
148
|
const cell = cells[r * cols + c] as GridCell | undefined;
|
|
181
|
-
const fill = cell === undefined ? 0 : Math.min(1, Math.max(0,
|
|
149
|
+
const fill = cell === undefined ? 0 : Math.min(1, Math.max(0, finiteOrNull(cell.fill) ?? 0));
|
|
182
150
|
segments.push({ text: glyphFor(fill), fg: toneForRowKey(cell?.rowKey) });
|
|
183
151
|
}
|
|
184
152
|
lines.push({ segments });
|
package/src/context/grid.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { ContextRow, GridCell, RowKey } from "./types";
|
|
2
|
+
import { finiteOrNull } from "../normalize";
|
|
2
3
|
|
|
3
4
|
const GRID_ROWS_DEFAULT = 6;
|
|
4
|
-
const
|
|
5
|
-
const MAX_COLS = 60;
|
|
5
|
+
const GRID_COLS_DEFAULT = 8;
|
|
6
6
|
const CATEGORY_ORDER: readonly RowKey[] = ["user", "agent", "reasoning", "tools", "system"];
|
|
7
7
|
|
|
8
8
|
export interface BuildGridInput {
|
|
@@ -13,20 +13,6 @@ export interface BuildGridInput {
|
|
|
13
13
|
gridRows?: number | null;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
function finiteOrNull(value: unknown): number | null {
|
|
17
|
-
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function clampCols(value: number | null | undefined): number {
|
|
21
|
-
const n = finiteOrNull(value);
|
|
22
|
-
return Math.min(MAX_COLS, Math.max(MIN_COLS, n === null ? MIN_COLS : Math.floor(n)));
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function clampGridRows(value: number | null | undefined): number {
|
|
26
|
-
const n = finiteOrNull(value);
|
|
27
|
-
return n !== null && Math.floor(n) >= 1 ? Math.floor(n) : GRID_ROWS_DEFAULT;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
16
|
function rowTokens(rows: readonly ContextRow[], key: RowKey): number {
|
|
31
17
|
if (!Array.isArray(rows)) return 0;
|
|
32
18
|
for (const row of rows) {
|
|
@@ -58,8 +44,8 @@ export function buildGrid(input: BuildGridInput): {
|
|
|
58
44
|
rows: number;
|
|
59
45
|
cells: GridCell[];
|
|
60
46
|
} {
|
|
61
|
-
const cols =
|
|
62
|
-
const rows =
|
|
47
|
+
const cols = finiteOrNull(input.cols) ?? GRID_COLS_DEFAULT;
|
|
48
|
+
const rows = finiteOrNull(input.gridRows) ?? GRID_ROWS_DEFAULT;
|
|
63
49
|
const limit = finiteOrNull(input.limit);
|
|
64
50
|
if (limit === null || limit <= 0) return { cols, rows, cells: [] };
|
|
65
51
|
|
package/src/context/headroom.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Model } from "@opencode-ai/sdk/v2";
|
|
2
|
+
import { finiteOrNull } from "../normalize";
|
|
2
3
|
|
|
3
4
|
export interface Headroom {
|
|
4
5
|
usable: number | null;
|
|
@@ -12,10 +13,6 @@ export interface HeadroomInput {
|
|
|
12
13
|
compaction?: { reserved?: number } | null;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
function finiteOrNull(value: unknown): number | null {
|
|
16
|
-
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
16
|
/** Compaction headroom, mirroring opencode's overflow check; nulls when unusable. */
|
|
20
17
|
export function computeHeadroom(input: HeadroomInput): Headroom {
|
|
21
18
|
const limit = input.limit;
|
package/src/context/system.ts
CHANGED
|
@@ -7,23 +7,12 @@
|
|
|
7
7
|
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import type { SystemCapture } from "./types";
|
|
10
|
+
import { isRecord, nonNegative } from "../normalize";
|
|
10
11
|
import { pluginStateDir } from "../paths";
|
|
11
12
|
|
|
12
13
|
const CAPTURE_VERSION = 1;
|
|
13
|
-
const WRITE_THROTTLE_MS = 60_000;
|
|
14
14
|
const MAX_SESSION_ID_LENGTH = 200;
|
|
15
15
|
|
|
16
|
-
/** sessionID -> epoch ms of the last successful write, for throttling. */
|
|
17
|
-
const writeThrottle = new Map<string, number>();
|
|
18
|
-
|
|
19
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
20
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function nonNegative(value: unknown): number | null {
|
|
24
|
-
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
16
|
/** <pluginStateDir>/context/<sanitized-sessionID>.json */
|
|
28
17
|
export function contextCapturePath(sessionID: string, env?: NodeJS.ProcessEnv): string {
|
|
29
18
|
const raw = typeof sessionID === "string" ? sessionID : "";
|
|
@@ -35,10 +24,6 @@ export function contextCapturePath(sessionID: string, env?: NodeJS.ProcessEnv):
|
|
|
35
24
|
* Reads and validates a capture. Returns null for a missing/unreadable file,
|
|
36
25
|
* invalid JSON, an unsupported version, a session mismatch, non-string
|
|
37
26
|
* provider/model, or non-finite/negative sizes and timestamps.
|
|
38
|
-
*
|
|
39
|
-
* Deliberately independent of the write-throttle map: the server plugin writes
|
|
40
|
-
* and the TUI plugin reads in separate processes, so a reader can never have a
|
|
41
|
-
* throttle entry of its own.
|
|
42
27
|
*/
|
|
43
28
|
export async function readCapture(
|
|
44
29
|
sessionID: string,
|
|
@@ -74,10 +59,7 @@ export async function readCapture(
|
|
|
74
59
|
}
|
|
75
60
|
}
|
|
76
61
|
|
|
77
|
-
/**
|
|
78
|
-
* Atomically persists a capture (temp file + rename), throttled to at most one
|
|
79
|
-
* write per session per 60 s unless the provider/model changed. Never throws.
|
|
80
|
-
*/
|
|
62
|
+
/** Atomically persists a capture (temp file + rename). Never throws. */
|
|
81
63
|
export async function writeCapture(capture: SystemCapture, env?: NodeJS.ProcessEnv): Promise<void> {
|
|
82
64
|
try {
|
|
83
65
|
if (!isRecord(capture)) return;
|
|
@@ -92,14 +74,6 @@ export async function writeCapture(capture: SystemCapture, env?: NodeJS.ProcessE
|
|
|
92
74
|
const capturedAt = nonNegative(capture.capturedAt);
|
|
93
75
|
if (systemChars === null || systemTokens === null || capturedAt === null) return;
|
|
94
76
|
|
|
95
|
-
const previous = await readCapture(sessionID, env);
|
|
96
|
-
const modelChanged =
|
|
97
|
-
previous === null || previous.providerID !== providerID || previous.modelID !== modelID;
|
|
98
|
-
|
|
99
|
-
const now = Date.now();
|
|
100
|
-
const last = writeThrottle.get(sessionID);
|
|
101
|
-
if (!modelChanged && typeof last === "number" && now - last < WRITE_THROTTLE_MS) return;
|
|
102
|
-
|
|
103
77
|
const directory = join(pluginStateDir(env), "context");
|
|
104
78
|
await mkdir(directory, { recursive: true });
|
|
105
79
|
const file = contextCapturePath(sessionID, env);
|
|
@@ -115,7 +89,6 @@ export async function writeCapture(capture: SystemCapture, env?: NodeJS.ProcessE
|
|
|
115
89
|
const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
|
|
116
90
|
await writeFile(tmp, JSON.stringify(normalized), "utf8");
|
|
117
91
|
await rename(tmp, file);
|
|
118
|
-
writeThrottle.set(sessionID, now);
|
|
119
92
|
} catch {
|
|
120
93
|
// Capture writes are best-effort; a failure must never break a request.
|
|
121
94
|
}
|
|
@@ -140,8 +113,3 @@ export function captureIsFresh(
|
|
|
140
113
|
if (capturedAt === null || since === null) return false;
|
|
141
114
|
return capturedAt >= since;
|
|
142
115
|
}
|
|
143
|
-
|
|
144
|
-
/** Clears the write-throttle map so tests can reset between cases. */
|
|
145
|
-
export function resetCaptureThrottle(): void {
|
|
146
|
-
writeThrottle.clear();
|
|
147
|
-
}
|
package/src/context/types.ts
CHANGED
package/src/fallback.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import type { AdapterResult, UsageWindow } from "./types";
|
|
3
3
|
import { dbPath } from "./paths";
|
|
4
|
+
import { asRecord } from "./providers/http";
|
|
4
5
|
|
|
5
6
|
const HOUR_MS = 60 * 60 * 1000;
|
|
6
7
|
const DAY_MS = 24 * HOUR_MS;
|
|
@@ -15,12 +16,6 @@ interface SqliteDatabase {
|
|
|
15
16
|
}
|
|
16
17
|
type DatabaseSyncCtor = new (path: string, options?: { readOnly?: boolean }) => SqliteDatabase;
|
|
17
18
|
|
|
18
|
-
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
19
|
-
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
20
|
-
? (value as Record<string, unknown>)
|
|
21
|
-
: null;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
19
|
function toNum(value: unknown): number | null {
|
|
25
20
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
26
21
|
}
|
package/src/normalize.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import type { UsageWindow, WindowKind } from "./types";
|
|
2
|
-
|
|
3
1
|
/** Accepts a number or a numeric string; anything else (including NaN/Infinity) yields null. */
|
|
4
2
|
export function toNumber(v: unknown): number | null {
|
|
5
3
|
if (typeof v === "number") {
|
|
@@ -45,31 +43,23 @@ export function pick(obj: unknown, ...keys: string[]): unknown {
|
|
|
45
43
|
return undefined;
|
|
46
44
|
}
|
|
47
45
|
|
|
48
|
-
/**
|
|
49
|
-
export function
|
|
50
|
-
|
|
51
|
-
|
|
46
|
+
/** Finite number or null; numeric strings are not coerced. */
|
|
47
|
+
export function finiteOrNull(value: unknown): number | null {
|
|
48
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Finite number >= 0 or null. */
|
|
52
|
+
export function nonNegative(value: unknown): number | null {
|
|
53
|
+
const n = finiteOrNull(value);
|
|
54
|
+
return n !== null && n >= 0 ? n : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Plain-object guard: null and arrays are not records. */
|
|
58
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
59
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
52
60
|
}
|
|
53
61
|
|
|
54
|
-
/**
|
|
55
|
-
export function
|
|
56
|
-
|
|
57
|
-
label: string,
|
|
58
|
-
detail: { limit?: unknown; used?: unknown; remaining?: unknown; reset?: unknown },
|
|
59
|
-
): UsageWindow {
|
|
60
|
-
const used = toNumber(detail.used);
|
|
61
|
-
const limit = toNumber(detail.limit);
|
|
62
|
-
const remaining = toNumber(detail.remaining);
|
|
63
|
-
const usedPercent =
|
|
64
|
-
used !== null && limit !== null && limit > 0 ? Math.round((used / limit) * 100) : null;
|
|
65
|
-
return {
|
|
66
|
-
kind,
|
|
67
|
-
label,
|
|
68
|
-
usedPercent,
|
|
69
|
-
used,
|
|
70
|
-
limit,
|
|
71
|
-
remaining,
|
|
72
|
-
resetsAt: toISODate(detail.reset),
|
|
73
|
-
status: "ok",
|
|
74
|
-
};
|
|
62
|
+
/** Tolerant string coercion; null and undefined become "". */
|
|
63
|
+
export function safeString(value: unknown): string {
|
|
64
|
+
return String(value ?? "");
|
|
75
65
|
}
|
package/src/providers/chatgpt.ts
CHANGED
|
@@ -9,17 +9,10 @@ import type {
|
|
|
9
9
|
import { AdapterError } from "../types";
|
|
10
10
|
import { toISODate, toNumber } from "../normalize";
|
|
11
11
|
import { redact } from "../auth";
|
|
12
|
+
import { asRecord, handleError, readBody, request } from "./http";
|
|
12
13
|
|
|
13
|
-
const VERSION = "0.3.0";
|
|
14
|
-
const USER_AGENT = "opencode-usage-report/" + VERSION;
|
|
15
14
|
const ENDPOINT = "https://chatgpt.com/backend-api/wham/usage";
|
|
16
15
|
|
|
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
16
|
/** "foo_bar-baz" -> "Foo Bar Baz". */
|
|
24
17
|
function titleCase(v: string): string {
|
|
25
18
|
return v
|
|
@@ -54,74 +47,6 @@ function windowKind(seconds: number | null): { kind: WindowKind; label: string }
|
|
|
54
47
|
return { kind: "other", label: "Other" };
|
|
55
48
|
}
|
|
56
49
|
|
|
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
50
|
export const chatgptAdapter: ProviderAdapter = {
|
|
126
51
|
id: "openai",
|
|
127
52
|
displayName: "ChatGPT",
|
|
@@ -130,8 +55,29 @@ export const chatgptAdapter: ProviderAdapter = {
|
|
|
130
55
|
throw new AdapterError("auth", "ChatGPT account id missing (re-run: opencode auth login)");
|
|
131
56
|
}
|
|
132
57
|
|
|
133
|
-
const res = await request(cred,
|
|
134
|
-
|
|
58
|
+
const res = await request(cred, opts, {
|
|
59
|
+
endpoint: ENDPOINT,
|
|
60
|
+
label: "ChatGPT request failed",
|
|
61
|
+
headers: { "ChatGPT-Account-Id": cred.accountId },
|
|
62
|
+
});
|
|
63
|
+
if (!res.ok) {
|
|
64
|
+
return handleError(res, cred, (status, { text }) => {
|
|
65
|
+
if (status === 401) return { kind: "auth", message: "invalid ChatGPT login" };
|
|
66
|
+
if (status === 403) {
|
|
67
|
+
return { kind: "no-plan", message: "ChatGPT is not enabled for this account" };
|
|
68
|
+
}
|
|
69
|
+
if (status === 429) {
|
|
70
|
+
return { kind: "rate-limited", message: "ChatGPT rate limit reached" };
|
|
71
|
+
}
|
|
72
|
+
if (status >= 500) {
|
|
73
|
+
return { kind: "network", message: `ChatGPT server error ${status}` };
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
kind: "bad-response",
|
|
77
|
+
message: `ChatGPT unexpected status ${status}: ${text.slice(0, 200)}`,
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
}
|
|
135
81
|
|
|
136
82
|
const { json } = await readBody(res);
|
|
137
83
|
const body = asRecord(json);
|
package/src/providers/copilot.ts
CHANGED
|
@@ -8,9 +8,8 @@ import type {
|
|
|
8
8
|
import { AdapterError } from "../types";
|
|
9
9
|
import { toISODate, toNumber } from "../normalize";
|
|
10
10
|
import { redact } from "../auth";
|
|
11
|
+
import { asRecord, handleError, readBody, request } from "./http";
|
|
11
12
|
|
|
12
|
-
const VERSION = "0.3.0";
|
|
13
|
-
const USER_AGENT = "opencode-usage-report/" + VERSION;
|
|
14
13
|
const ENDPOINT = "https://api.github.com/copilot_internal/user";
|
|
15
14
|
|
|
16
15
|
/** Snapshot keys in display order, paired with their human-readable labels. */
|
|
@@ -20,12 +19,6 @@ const SNAPSHOTS: Array<{ key: string; label: string }> = [
|
|
|
20
19
|
{ key: "premium_interactions", label: "Premium interactions" },
|
|
21
20
|
];
|
|
22
21
|
|
|
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
22
|
/** "foo_bar-baz" -> "Foo Bar Baz". */
|
|
30
23
|
function titleCase(v: string): string {
|
|
31
24
|
return v
|
|
@@ -60,88 +53,37 @@ export function copilotTier(sku: unknown, plan: unknown): string {
|
|
|
60
53
|
return "Copilot";
|
|
61
54
|
}
|
|
62
55
|
|
|
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
56
|
export const copilotAdapter: ProviderAdapter = {
|
|
140
57
|
id: "github-copilot",
|
|
141
58
|
displayName: "GitHub Copilot",
|
|
142
59
|
async fetch(cred: Credential, opts: FetchOptions): Promise<AdapterResult> {
|
|
143
|
-
const res = await request(cred, opts
|
|
144
|
-
|
|
60
|
+
const res = await request(cred, opts, {
|
|
61
|
+
endpoint: ENDPOINT,
|
|
62
|
+
label: "GitHub Copilot request failed",
|
|
63
|
+
});
|
|
64
|
+
if (!res.ok) {
|
|
65
|
+
return handleError(res, cred, (status, { text }) => {
|
|
66
|
+
if (status === 401 || status === 404) {
|
|
67
|
+
return {
|
|
68
|
+
kind: "auth",
|
|
69
|
+
message: "GitHub Copilot login invalid or not available for this account",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (status === 403) {
|
|
73
|
+
return { kind: "no-plan", message: "GitHub Copilot is not enabled for this account" };
|
|
74
|
+
}
|
|
75
|
+
if (status === 429) {
|
|
76
|
+
return { kind: "rate-limited", message: "GitHub Copilot rate limit reached" };
|
|
77
|
+
}
|
|
78
|
+
if (status >= 500) {
|
|
79
|
+
return { kind: "network", message: `GitHub Copilot server error ${status}` };
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
kind: "bad-response",
|
|
83
|
+
message: `GitHub Copilot unexpected status ${status}: ${text.slice(0, 200)}`,
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
}
|
|
145
87
|
|
|
146
88
|
const { json } = await readBody(res);
|
|
147
89
|
const body = asRecord(json);
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared HTTP plumbing for provider adapters. Every adapter performs the same
|
|
3
|
+
* authenticated GET with one retry, so the fetch, body-reading and error-mapping
|
|
4
|
+
* helpers live here instead of being copy-pasted per provider.
|
|
5
|
+
*/
|
|
6
|
+
import type { AdapterErrorKind, Credential, FetchOptions } from "../types";
|
|
7
|
+
import { AdapterError } from "../types";
|
|
8
|
+
import { redact } from "../auth";
|
|
9
|
+
|
|
10
|
+
/** Sent as the User-Agent on every provider request; keep in sync with package.json. */
|
|
11
|
+
export const USER_AGENT = "opencode-usage-report/0.4.1";
|
|
12
|
+
|
|
13
|
+
export function asRecord(v: unknown): Record<string, unknown> | null {
|
|
14
|
+
return v !== null && typeof v === "object" && !Array.isArray(v)
|
|
15
|
+
? (v as Record<string, unknown>)
|
|
16
|
+
: null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface Body {
|
|
20
|
+
text: string;
|
|
21
|
+
json: unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Reads the body once as text and best-effort parses JSON, so error and success paths share it. */
|
|
25
|
+
export async function readBody(res: Response): Promise<Body> {
|
|
26
|
+
const text = await res.text().catch(() => "");
|
|
27
|
+
let json: unknown = null;
|
|
28
|
+
try {
|
|
29
|
+
json = JSON.parse(text);
|
|
30
|
+
} catch {
|
|
31
|
+
json = null;
|
|
32
|
+
}
|
|
33
|
+
return { text, json };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface RequestSpec {
|
|
37
|
+
endpoint: string;
|
|
38
|
+
/** Prefix of the thrown network error, e.g. "Kimi API network error". */
|
|
39
|
+
label: string;
|
|
40
|
+
headers?: Record<string, string>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function attempt(cred: Credential, opts: FetchOptions, spec: RequestSpec): Promise<Response> {
|
|
44
|
+
return fetch(spec.endpoint, {
|
|
45
|
+
method: "GET",
|
|
46
|
+
headers: {
|
|
47
|
+
Authorization: "Bearer " + cred.key,
|
|
48
|
+
Accept: "application/json",
|
|
49
|
+
"User-Agent": USER_AGENT,
|
|
50
|
+
...spec.headers,
|
|
51
|
+
},
|
|
52
|
+
signal: AbortSignal.timeout(opts.timeoutMs),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Exactly one retry on a network throw or 5xx; 4xx is returned immediately (never retried). */
|
|
57
|
+
export async function request(
|
|
58
|
+
cred: Credential,
|
|
59
|
+
opts: FetchOptions,
|
|
60
|
+
spec: RequestSpec,
|
|
61
|
+
): Promise<Response> {
|
|
62
|
+
let lastError: unknown = null;
|
|
63
|
+
for (let i = 0; i < 2; i++) {
|
|
64
|
+
try {
|
|
65
|
+
const res = await attempt(cred, opts, spec);
|
|
66
|
+
if (res.status >= 500 && i === 0) continue;
|
|
67
|
+
return res;
|
|
68
|
+
} catch (err) {
|
|
69
|
+
lastError = err;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const detail = lastError instanceof Error ? lastError.message : String(lastError);
|
|
73
|
+
throw new AdapterError("network", redact(`${spec.label}: ${detail}`, cred.key));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ErrorResolution {
|
|
77
|
+
kind: AdapterErrorKind;
|
|
78
|
+
message: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Reads the error body, asks the adapter to map it, and throws the resulting AdapterError. */
|
|
82
|
+
export async function handleError(
|
|
83
|
+
res: Response,
|
|
84
|
+
cred: Credential,
|
|
85
|
+
resolve: (status: number, body: Body) => ErrorResolution,
|
|
86
|
+
): Promise<never> {
|
|
87
|
+
const body = await readBody(res);
|
|
88
|
+
const { kind, message } = resolve(res.status, body);
|
|
89
|
+
throw new AdapterError(kind, redact(message, cred.key));
|
|
90
|
+
}
|
package/src/providers/kimi.ts
CHANGED
|
@@ -4,12 +4,12 @@ import type {
|
|
|
4
4
|
FetchOptions,
|
|
5
5
|
ProviderAdapter,
|
|
6
6
|
UsageWindow,
|
|
7
|
+
WindowKind,
|
|
7
8
|
} from "../types";
|
|
8
9
|
import { AdapterError } from "../types";
|
|
9
|
-
import { pick,
|
|
10
|
+
import { pick, toISODate, toNumber } from "../normalize";
|
|
10
11
|
import { redact } from "../auth";
|
|
11
|
-
|
|
12
|
-
const VERSION = "0.3.0";
|
|
12
|
+
import { handleError, readBody, request } from "./http";
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Kimi Code is split by region in opencode/models.dev: the global plan lives on
|
|
@@ -23,39 +23,33 @@ export interface KimiAdapterConfig {
|
|
|
23
23
|
baseUrl: string;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
Authorization: "Bearer " + cred.key,
|
|
31
|
-
Accept: "application/json",
|
|
32
|
-
"User-Agent": "opencode-usage-report/" + VERSION,
|
|
33
|
-
},
|
|
34
|
-
signal: AbortSignal.timeout(opts.timeoutMs),
|
|
35
|
-
});
|
|
26
|
+
/** Converts a 0-1 ratio into a 0-100 percent, clamped to [0,100] and rounded to 1 decimal. */
|
|
27
|
+
function ratioToPercent(ratio: number): number {
|
|
28
|
+
const rounded = Math.round(ratio * 1000) / 10;
|
|
29
|
+
return Math.min(100, Math.max(0, rounded));
|
|
36
30
|
}
|
|
37
31
|
|
|
38
|
-
/**
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
):
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
32
|
+
/** Builds a window from tolerant count fields, deriving usedPercent only when used and a positive limit are known. */
|
|
33
|
+
function windowFromCounts(
|
|
34
|
+
kind: WindowKind,
|
|
35
|
+
label: string,
|
|
36
|
+
detail: { limit?: unknown; used?: unknown; remaining?: unknown; reset?: unknown },
|
|
37
|
+
): UsageWindow {
|
|
38
|
+
const used = toNumber(detail.used);
|
|
39
|
+
const limit = toNumber(detail.limit);
|
|
40
|
+
const remaining = toNumber(detail.remaining);
|
|
41
|
+
const usedPercent =
|
|
42
|
+
used !== null && limit !== null && limit > 0 ? Math.round((used / limit) * 100) : null;
|
|
43
|
+
return {
|
|
44
|
+
kind,
|
|
45
|
+
label,
|
|
46
|
+
usedPercent,
|
|
47
|
+
used,
|
|
48
|
+
limit,
|
|
49
|
+
remaining,
|
|
50
|
+
resetsAt: toISODate(detail.reset),
|
|
51
|
+
status: "ok",
|
|
52
|
+
};
|
|
59
53
|
}
|
|
60
54
|
|
|
61
55
|
function findLimitDetail(limits: unknown, durationMinutes: number): unknown {
|
|
@@ -174,39 +168,33 @@ export function createKimiAdapter(config: KimiAdapterConfig): ProviderAdapter {
|
|
|
174
168
|
id: config.id,
|
|
175
169
|
displayName: config.displayName,
|
|
176
170
|
async fetch(cred: Credential, opts: FetchOptions): Promise<AdapterResult> {
|
|
177
|
-
const res = await
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
if (res.
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
);
|
|
171
|
+
const res = await request(cred, opts, {
|
|
172
|
+
endpoint,
|
|
173
|
+
label: "Kimi API network error",
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
if (!res.ok) {
|
|
177
|
+
return handleError(res, cred, (status) => {
|
|
178
|
+
if (status === 401) return { kind: "auth", message: "invalid Kimi API key" };
|
|
179
|
+
if (status === 429) {
|
|
180
|
+
return { kind: "rate-limited", message: "Kimi API rate limit exceeded" };
|
|
181
|
+
}
|
|
182
|
+
if (status >= 500) {
|
|
183
|
+
return { kind: "network", message: `Kimi API network error: HTTP ${status}` };
|
|
184
|
+
}
|
|
185
|
+
return { kind: "bad-response", message: `Kimi API returned HTTP ${status}` };
|
|
186
|
+
});
|
|
200
187
|
}
|
|
201
188
|
|
|
202
|
-
|
|
189
|
+
const { json } = await readBody(res);
|
|
190
|
+
if (json === null || typeof json !== "object" || Array.isArray(json)) {
|
|
203
191
|
throw new AdapterError(
|
|
204
192
|
"bad-response",
|
|
205
193
|
redact("Kimi API returned an unexpected response shape", cred.key),
|
|
206
194
|
);
|
|
207
195
|
}
|
|
208
196
|
|
|
209
|
-
return parse(
|
|
197
|
+
return parse(json as Record<string, unknown>, cred);
|
|
210
198
|
},
|
|
211
199
|
};
|
|
212
200
|
}
|
|
@@ -10,9 +10,8 @@ import type {
|
|
|
10
10
|
import { AdapterError } from "../types";
|
|
11
11
|
import { toISODate, toNumber } from "../normalize";
|
|
12
12
|
import { redact } from "../auth";
|
|
13
|
+
import { asRecord, handleError, readBody, request } from "./http";
|
|
13
14
|
|
|
14
|
-
const VERSION = "0.3.0";
|
|
15
|
-
const USER_AGENT = "opencode-usage-report/" + VERSION;
|
|
16
15
|
const ENDPOINT = "https://opencode.ai/zen/go/v1/usage";
|
|
17
16
|
|
|
18
17
|
/** Additive options: `sessionId` is optional, so the adapter still satisfies `ProviderAdapter`. */
|
|
@@ -28,12 +27,6 @@ function mapStatus(v: unknown): WindowStatus {
|
|
|
28
27
|
return v === "ok" || v === "rate-limited" ? v : "unknown";
|
|
29
28
|
}
|
|
30
29
|
|
|
31
|
-
function asRecord(v: unknown): Record<string, unknown> | null {
|
|
32
|
-
return v !== null && typeof v === "object" && !Array.isArray(v)
|
|
33
|
-
? (v as Record<string, unknown>)
|
|
34
|
-
: null;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
30
|
function buildWindow(kind: WindowKind, label: string, src: unknown): UsageWindow | null {
|
|
38
31
|
const record = asRecord(src);
|
|
39
32
|
if (!record) return null;
|
|
@@ -49,91 +42,42 @@ function buildWindow(kind: WindowKind, label: string, src: unknown): UsageWindow
|
|
|
49
42
|
};
|
|
50
43
|
}
|
|
51
44
|
|
|
52
|
-
/** Reads the body once as text and best-effort parses JSON, so error and success paths share it. */
|
|
53
|
-
async function readBody(res: Response): Promise<{ text: string; json: unknown }> {
|
|
54
|
-
const text = await res.text().catch(() => "");
|
|
55
|
-
let json: unknown = null;
|
|
56
|
-
try {
|
|
57
|
-
json = JSON.parse(text);
|
|
58
|
-
} catch {
|
|
59
|
-
json = null;
|
|
60
|
-
}
|
|
61
|
-
return { text, json };
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
async function attempt(cred: Credential, opts: OpenCodeGoFetchOptions): Promise<Response> {
|
|
65
|
-
return fetch(ENDPOINT, {
|
|
66
|
-
method: "GET",
|
|
67
|
-
headers: {
|
|
68
|
-
Authorization: "Bearer " + cred.key,
|
|
69
|
-
Accept: "application/json",
|
|
70
|
-
"User-Agent": USER_AGENT,
|
|
71
|
-
"x-opencode-session": opts.sessionId ?? "unknown",
|
|
72
|
-
},
|
|
73
|
-
signal: AbortSignal.timeout(opts.timeoutMs),
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** Exactly one retry on a network throw or 5xx; 4xx is returned immediately (never retried). */
|
|
78
|
-
async function request(cred: Credential, opts: OpenCodeGoFetchOptions): Promise<Response> {
|
|
79
|
-
try {
|
|
80
|
-
const first = await attempt(cred, opts);
|
|
81
|
-
if (first.status < 500) return first;
|
|
82
|
-
} catch {
|
|
83
|
-
// fall through to the single retry
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
try {
|
|
87
|
-
return await attempt(cred, opts);
|
|
88
|
-
} catch (err) {
|
|
89
|
-
throw new AdapterError(
|
|
90
|
-
"network",
|
|
91
|
-
redact(
|
|
92
|
-
`OpenCode Go request failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
93
|
-
cred.key,
|
|
94
|
-
),
|
|
95
|
-
);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
async function handleError(res: Response, cred: Credential): Promise<never> {
|
|
100
|
-
const { text, json } = await readBody(res);
|
|
101
|
-
|
|
102
|
-
if (res.status === 401) {
|
|
103
|
-
throw new AdapterError("auth", redact("invalid OpenCode Go API key", cred.key));
|
|
104
|
-
}
|
|
105
|
-
if (res.status === 403) {
|
|
106
|
-
const record = asRecord(json);
|
|
107
|
-
const error = record ? asRecord(record.error) : null;
|
|
108
|
-
if (error && error.type === "EntitlementError") {
|
|
109
|
-
throw new AdapterError(
|
|
110
|
-
"no-plan",
|
|
111
|
-
redact("OpenCode Go subscription not active on this key", cred.key),
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
throw new AdapterError(
|
|
115
|
-
"network",
|
|
116
|
-
redact("OpenCode Go request blocked (check User-Agent)", cred.key),
|
|
117
|
-
);
|
|
118
|
-
}
|
|
119
|
-
if (res.status === 429) {
|
|
120
|
-
throw new AdapterError("rate-limited", redact("OpenCode Go rate limit reached", cred.key));
|
|
121
|
-
}
|
|
122
|
-
if (res.status >= 500) {
|
|
123
|
-
throw new AdapterError("network", redact(`OpenCode Go server error ${res.status}`, cred.key));
|
|
124
|
-
}
|
|
125
|
-
throw new AdapterError(
|
|
126
|
-
"bad-response",
|
|
127
|
-
redact(`OpenCode Go unexpected status ${res.status}: ${text.slice(0, 200)}`, cred.key),
|
|
128
|
-
);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
45
|
export const opencodeGoAdapter: ProviderAdapter = {
|
|
132
46
|
id: "opencode-go",
|
|
133
47
|
displayName: "OpenCode Go",
|
|
134
48
|
async fetch(cred: Credential, opts: OpenCodeGoFetchOptions): Promise<AdapterResult> {
|
|
135
|
-
const res = await request(cred, opts
|
|
136
|
-
|
|
49
|
+
const res = await request(cred, opts, {
|
|
50
|
+
endpoint: ENDPOINT,
|
|
51
|
+
label: "OpenCode Go request failed",
|
|
52
|
+
headers: { "x-opencode-session": opts.sessionId ?? "unknown" },
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
if (!res.ok) {
|
|
56
|
+
return handleError(res, cred, (status, { text, json }) => {
|
|
57
|
+
if (status === 401) return { kind: "auth", message: "invalid OpenCode Go API key" };
|
|
58
|
+
if (status === 403) {
|
|
59
|
+
const record = asRecord(json);
|
|
60
|
+
const error = record ? asRecord(record.error) : null;
|
|
61
|
+
if (error && error.type === "EntitlementError") {
|
|
62
|
+
return {
|
|
63
|
+
kind: "no-plan",
|
|
64
|
+
message: "OpenCode Go subscription not active on this key",
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return { kind: "network", message: "OpenCode Go request blocked (check User-Agent)" };
|
|
68
|
+
}
|
|
69
|
+
if (status === 429) {
|
|
70
|
+
return { kind: "rate-limited", message: "OpenCode Go rate limit reached" };
|
|
71
|
+
}
|
|
72
|
+
if (status >= 500) {
|
|
73
|
+
return { kind: "network", message: `OpenCode Go server error ${status}` };
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
kind: "bad-response",
|
|
77
|
+
message: `OpenCode Go unexpected status ${status}: ${text.slice(0, 200)}`,
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
}
|
|
137
81
|
|
|
138
82
|
const { json } = await readBody(res);
|
|
139
83
|
const body = asRecord(json);
|
package/src/quota-lines.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { TuiPluginApi } from "@opencode-ai/plugin/tui";
|
|
8
8
|
import type { ProviderReport, WindowKind } from "./types";
|
|
9
|
-
import { bar, countdown, percentText, toneFor, windowLabel } from "./tui-format";
|
|
9
|
+
import { bar, clip, countdown, padLabel, percentText, toneFor, windowLabel } from "./tui-format";
|
|
10
10
|
|
|
11
11
|
type Theme = TuiPluginApi["theme"]["current"];
|
|
12
12
|
type ThemeColor = Theme["text"];
|
|
@@ -25,7 +25,7 @@ export const NAMED_WIDTH = 14;
|
|
|
25
25
|
export const EXTRAS_MAX_LENGTH = 80;
|
|
26
26
|
|
|
27
27
|
/** Order windows for display; unknown kinds sort last. */
|
|
28
|
-
|
|
28
|
+
const WINDOW_ORDER: Record<WindowKind, number> = {
|
|
29
29
|
"5h": 0,
|
|
30
30
|
weekly: 1,
|
|
31
31
|
monthly: 2,
|
|
@@ -33,23 +33,6 @@ export const WINDOW_ORDER: Record<WindowKind, number> = {
|
|
|
33
33
|
other: 4,
|
|
34
34
|
};
|
|
35
35
|
|
|
36
|
-
export function windowRank(kind: WindowKind): number {
|
|
37
|
-
return WINDOW_ORDER[kind] ?? WINDOW_ORDER.other;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** Collapses whitespace and truncates to a single-line label. */
|
|
41
|
-
export function clip(value: string, max = 60): string {
|
|
42
|
-
const single = value.replace(/\s+/g, " ").trim();
|
|
43
|
-
if (single.length <= max) return single;
|
|
44
|
-
return single.slice(0, Math.max(0, max - 1)) + "…";
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** Pads a label to a fixed width, truncating (with a trailing space) when long. */
|
|
48
|
-
export function alignLabel(label: string): string {
|
|
49
|
-
const text = label.length > NAMED_WIDTH ? label.slice(0, NAMED_WIDTH - 1) + " " : label;
|
|
50
|
-
return text.padEnd(NAMED_WIDTH);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
36
|
/** Flattens provider reports into plain, pre-colored text lines. */
|
|
54
37
|
export function buildLines(
|
|
55
38
|
theme: Theme,
|
|
@@ -89,11 +72,11 @@ export function buildLines(
|
|
|
89
72
|
continue;
|
|
90
73
|
}
|
|
91
74
|
|
|
92
|
-
const windows = report.windows.toSorted((a, b) =>
|
|
75
|
+
const windows = report.windows.toSorted((a, b) => WINDOW_ORDER[a.kind] - WINDOW_ORDER[b.kind]);
|
|
93
76
|
for (const w of windows) {
|
|
94
77
|
const tone = toneFor(w.usedPercent, w.status);
|
|
95
78
|
const row: Segment[] = [
|
|
96
|
-
{ text:
|
|
79
|
+
{ text: padLabel(windowLabel(w.kind, w.label), NAMED_WIDTH), fg: theme.textMuted },
|
|
97
80
|
{ text: bar(w.usedPercent, barWidth), fg: theme[tone] },
|
|
98
81
|
{ text: " ", fg: theme.text },
|
|
99
82
|
{ text: percentText(w), fg: theme.text },
|
package/src/render.ts
CHANGED
|
@@ -1,39 +1,36 @@
|
|
|
1
1
|
import type { ProviderReport, UsageWindow } from "./types";
|
|
2
|
-
|
|
3
|
-
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
4
|
-
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
2
|
+
import { formatAge } from "./tui-format";
|
|
5
3
|
|
|
6
4
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
7
5
|
const LABEL_WIDTH = 8;
|
|
8
6
|
const PERCENT_WIDTH = 10;
|
|
9
7
|
const ABSOLUTES_WIDTH = 24;
|
|
10
8
|
|
|
9
|
+
const RESET_DATE = new Intl.DateTimeFormat("en-US", {
|
|
10
|
+
weekday: "short",
|
|
11
|
+
month: "short",
|
|
12
|
+
day: "numeric",
|
|
13
|
+
});
|
|
14
|
+
|
|
11
15
|
function formatNumber(n: number): string {
|
|
12
16
|
return n.toLocaleString("en-US");
|
|
13
17
|
}
|
|
14
18
|
|
|
15
|
-
/** "14:05" for a same-day reset, "Wed Sep 16" otherwise. */
|
|
19
|
+
/** "14:05" for a same-day reset, "Wed, Sep 16" otherwise. */
|
|
16
20
|
export function formatReset(resetsAt: string, now: Date): string | null {
|
|
17
21
|
const d = new Date(resetsAt);
|
|
18
22
|
if (Number.isNaN(d.getTime())) return null;
|
|
19
23
|
const within24h = Math.abs(d.getTime() - now.getTime()) < DAY_MS;
|
|
20
24
|
if (within24h) {
|
|
21
|
-
return
|
|
25
|
+
return d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
|
|
22
26
|
}
|
|
23
|
-
return
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function formatAge(fetchedAt: string, now: Date): string {
|
|
27
|
-
const then = new Date(fetchedAt).getTime();
|
|
28
|
-
const minutes = Number.isNaN(then) ? 0 : Math.max(0, Math.floor((now.getTime() - then) / 60000));
|
|
29
|
-
if (minutes < 60) return `${minutes}m`;
|
|
30
|
-
return `${Math.floor(minutes / 60)}h`;
|
|
27
|
+
return RESET_DATE.format(d);
|
|
31
28
|
}
|
|
32
29
|
|
|
33
30
|
function header(report: ProviderReport, now: Date): string {
|
|
34
31
|
const parts: string[] = [report.source];
|
|
35
32
|
if (report.stale) {
|
|
36
|
-
parts.push(`stale ${formatAge(report.fetchedAt, now)} old`);
|
|
33
|
+
parts.push(`stale ${formatAge(report.fetchedAt, now) ?? "0m"} old`);
|
|
37
34
|
}
|
|
38
35
|
return `${report.displayName} (${parts.join(", ")})`;
|
|
39
36
|
}
|
package/src/tui-format.ts
CHANGED
|
@@ -13,6 +13,18 @@ export const MIN_REFRESH_INTERVAL_SECONDS = 5;
|
|
|
13
13
|
export const MAX_REFRESH_INTERVAL_SECONDS = 3600;
|
|
14
14
|
export const DEFAULT_REFRESH_INTERVAL_SECONDS = 60;
|
|
15
15
|
|
|
16
|
+
/** Collapses whitespace and truncates to a single-line label. */
|
|
17
|
+
export function clip(value: string, max = 60): string {
|
|
18
|
+
const single = value.replace(/\s+/g, " ").trim();
|
|
19
|
+
if (single.length <= max) return single;
|
|
20
|
+
return single.slice(0, Math.max(0, max - 1)) + "…";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Pads a label to a fixed width, truncating with an ellipsis when long. */
|
|
24
|
+
export function padLabel(label: string, width: number): string {
|
|
25
|
+
return clip(label, width).padEnd(width);
|
|
26
|
+
}
|
|
27
|
+
|
|
16
28
|
/**
|
|
17
29
|
* Renders a fixed-width progress bar. `percent` is clamped to 0-100 and rounded
|
|
18
30
|
* to the nearest filled cell; `null` (or any non-finite value) renders an empty
|
package/src/tui.tsx
CHANGED
|
@@ -72,22 +72,19 @@ export const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
72
72
|
};
|
|
73
73
|
|
|
74
74
|
const intervalMs = Math.max(1, Math.floor(options.refreshIntervalSeconds)) * 1000;
|
|
75
|
-
const
|
|
75
|
+
const refresh = (): void => {
|
|
76
76
|
setTick(Date.now());
|
|
77
77
|
void load();
|
|
78
78
|
void refreshCapture();
|
|
79
|
-
}
|
|
79
|
+
};
|
|
80
|
+
const timer = setInterval(refresh, intervalMs);
|
|
80
81
|
|
|
81
82
|
api.lifecycle.onDispose(() => {
|
|
82
83
|
disposed = true;
|
|
83
84
|
clearInterval(timer);
|
|
84
85
|
});
|
|
85
86
|
|
|
86
|
-
const offIdle = api.event.on("session.idle",
|
|
87
|
-
setTick(Date.now());
|
|
88
|
-
void load();
|
|
89
|
-
void refreshCapture();
|
|
90
|
-
});
|
|
87
|
+
const offIdle = api.event.on("session.idle", refresh);
|
|
91
88
|
api.lifecycle.onDispose(offIdle);
|
|
92
89
|
|
|
93
90
|
const openDialog = (): void => {
|