opencode-usage-report 0.4.0 → 0.5.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 +10 -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/index.ts +172 -16
- 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
|
@@ -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 => {
|