opencode-usage-report 0.1.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/LICENSE +21 -0
- package/README.md +144 -0
- package/package.json +60 -0
- package/src/auth.ts +58 -0
- package/src/cache.ts +97 -0
- package/src/fallback.ts +175 -0
- package/src/index.ts +111 -0
- package/src/normalize.ts +75 -0
- package/src/paths.ts +19 -0
- package/src/providers/index.ts +5 -0
- package/src/providers/kimi.ts +198 -0
- package/src/providers/opencode-go.ts +153 -0
- package/src/render.ts +85 -0
- package/src/report.ts +161 -0
- package/src/tui-format.ts +201 -0
- package/src/tui.tsx +244 -0
- package/src/types.ts +57 -0
- package/src/warn.ts +97 -0
package/src/normalize.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { UsageWindow, WindowKind } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/** Accepts a number or a numeric string; anything else (including NaN/Infinity) yields null. */
|
|
4
|
+
export function toNumber(v: unknown): number | null {
|
|
5
|
+
if (typeof v === "number") {
|
|
6
|
+
return Number.isFinite(v) ? v : null;
|
|
7
|
+
}
|
|
8
|
+
if (typeof v === "string") {
|
|
9
|
+
const trimmed = v.trim();
|
|
10
|
+
if (trimmed === "") return null;
|
|
11
|
+
const n = Number(trimmed);
|
|
12
|
+
return Number.isFinite(n) ? n : null;
|
|
13
|
+
}
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Accepts an ISO date string, epoch seconds (< 1e12) or epoch milliseconds (>= 1e12).
|
|
19
|
+
* Returns a normalized ISO-8601 string, or null when the value cannot be parsed.
|
|
20
|
+
*/
|
|
21
|
+
export function toISODate(v: unknown): string | null {
|
|
22
|
+
if (v === null || v === undefined) return null;
|
|
23
|
+
if (typeof v === "number") {
|
|
24
|
+
if (!Number.isFinite(v)) return null;
|
|
25
|
+
const ms = v < 1e12 ? v * 1000 : v;
|
|
26
|
+
const d = new Date(ms);
|
|
27
|
+
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
|
28
|
+
}
|
|
29
|
+
if (typeof v === "string") {
|
|
30
|
+
if (v.trim() === "") return null;
|
|
31
|
+
const d = new Date(v);
|
|
32
|
+
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Returns the first non-null/undefined value of obj[keys]; non-plain-object input yields undefined. */
|
|
38
|
+
export function pick(obj: unknown, ...keys: string[]): unknown {
|
|
39
|
+
if (obj === null || typeof obj !== "object" || Array.isArray(obj)) return undefined;
|
|
40
|
+
const record = obj as Record<string, unknown>;
|
|
41
|
+
for (const key of keys) {
|
|
42
|
+
const value = record[key];
|
|
43
|
+
if (value !== null && value !== undefined) return value;
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Converts a 0-1 ratio into a 0-100 percent, clamped to [0,100] and rounded to 1 decimal. */
|
|
49
|
+
export function ratioToPercent(ratio: number): number {
|
|
50
|
+
const rounded = Math.round(ratio * 1000) / 10;
|
|
51
|
+
return Math.min(100, Math.max(0, rounded));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Builds a window from tolerant count fields, deriving usedPercent only when used and a positive limit are known. */
|
|
55
|
+
export function windowFromCounts(
|
|
56
|
+
kind: WindowKind,
|
|
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
|
+
};
|
|
75
|
+
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/** Resolves the opencode data home: $OPENCODE_DATA_HOME if set, else ~/.local/share/opencode. */
|
|
5
|
+
export function dataHome(env: NodeJS.ProcessEnv = process.env): string {
|
|
6
|
+
const override = env.OPENCODE_DATA_HOME;
|
|
7
|
+
if (typeof override === "string" && override.trim() !== "") return override;
|
|
8
|
+
return join(homedir(), ".local", "share", "opencode");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** <data-home>/usage-report */
|
|
12
|
+
export function pluginStateDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
13
|
+
return join(dataHome(env), "usage-report");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** <data-home>/opencode.db */
|
|
17
|
+
export function dbPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
18
|
+
return join(dataHome(env), "opencode.db");
|
|
19
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ProviderAdapter } from "../types.js";
|
|
2
|
+
import { kimiAdapter } from "./kimi.js";
|
|
3
|
+
import { opencodeGoAdapter } from "./opencode-go.js";
|
|
4
|
+
export const adapters: ProviderAdapter[] = [kimiAdapter, opencodeGoAdapter];
|
|
5
|
+
export function getAdapter(id: string): ProviderAdapter | undefined { return adapters.find(a => a.id === id); }
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AdapterResult,
|
|
3
|
+
Credential,
|
|
4
|
+
FetchOptions,
|
|
5
|
+
ProviderAdapter,
|
|
6
|
+
UsageWindow,
|
|
7
|
+
} from "../types.js";
|
|
8
|
+
import { AdapterError } from "../types.js";
|
|
9
|
+
import { pick, ratioToPercent, toISODate, toNumber, windowFromCounts } from "../normalize.js";
|
|
10
|
+
import { redact } from "../auth.js";
|
|
11
|
+
|
|
12
|
+
const VERSION = "0.1.0";
|
|
13
|
+
const ENDPOINT = "https://api.kimi.com/coding/v1/usages";
|
|
14
|
+
|
|
15
|
+
function request(cred: Credential, opts: FetchOptions): Promise<Response> {
|
|
16
|
+
return fetch(ENDPOINT, {
|
|
17
|
+
method: "GET",
|
|
18
|
+
headers: {
|
|
19
|
+
Authorization: "Bearer " + cred.key,
|
|
20
|
+
Accept: "application/json",
|
|
21
|
+
"User-Agent": "opencode-usage-report/" + VERSION,
|
|
22
|
+
},
|
|
23
|
+
signal: AbortSignal.timeout(opts.timeoutMs),
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One retry on network throw or 5xx; 4xx and 2xx return immediately. */
|
|
28
|
+
async function fetchWithRetry(cred: Credential, opts: FetchOptions): Promise<Response> {
|
|
29
|
+
let lastError: unknown = null;
|
|
30
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
31
|
+
try {
|
|
32
|
+
const res = await request(cred, opts);
|
|
33
|
+
if (res.status >= 500) {
|
|
34
|
+
lastError = new Error(`HTTP ${res.status}`);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
return res;
|
|
38
|
+
} catch (err) {
|
|
39
|
+
lastError = err;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const detail = lastError instanceof Error ? lastError.message : String(lastError);
|
|
43
|
+
throw new AdapterError("network", redact(`Kimi API network error: ${detail}`, cred.key));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function findLimitDetail(limits: unknown, durationMinutes: number): unknown {
|
|
47
|
+
if (!Array.isArray(limits)) return undefined;
|
|
48
|
+
for (const entry of limits) {
|
|
49
|
+
const window = pick(entry, "window");
|
|
50
|
+
const duration = toNumber(pick(window, "duration"));
|
|
51
|
+
const unit = pick(window, "timeUnit", "time_unit");
|
|
52
|
+
if (duration === durationMinutes && unit === "TIME_UNIT_MINUTE") {
|
|
53
|
+
return pick(entry, "detail");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function ratioWindow(
|
|
60
|
+
kind: UsageWindow["kind"],
|
|
61
|
+
label: string,
|
|
62
|
+
value: unknown,
|
|
63
|
+
): UsageWindow {
|
|
64
|
+
const ratio = toNumber(pick(value, "used_ratio", "usedRatio"));
|
|
65
|
+
return {
|
|
66
|
+
kind,
|
|
67
|
+
label,
|
|
68
|
+
usedPercent: ratio !== null ? ratioToPercent(ratio) : null,
|
|
69
|
+
used: null,
|
|
70
|
+
limit: null,
|
|
71
|
+
remaining: null,
|
|
72
|
+
resetsAt: toISODate(pick(value, "reset_time", "resetTime", "resetAt", "resetsAt")),
|
|
73
|
+
status: "ok",
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function detailWindow(kind: UsageWindow["kind"], label: string, detail: unknown): UsageWindow {
|
|
78
|
+
return windowFromCounts(kind, label, {
|
|
79
|
+
limit: pick(detail, "limit"),
|
|
80
|
+
used: pick(detail, "used"),
|
|
81
|
+
remaining: pick(detail, "remaining"),
|
|
82
|
+
reset: pick(detail, "resetTime", "reset_time", "resetAt", "resetsAt"),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parse(body: Record<string, unknown>, cred: Credential): AdapterResult {
|
|
87
|
+
const windows: UsageWindow[] = [];
|
|
88
|
+
const extras: Record<string, string> = {};
|
|
89
|
+
|
|
90
|
+
const usages = pick(body, "usages");
|
|
91
|
+
const usage = pick(body, "usage");
|
|
92
|
+
const limits = pick(body, "limits");
|
|
93
|
+
|
|
94
|
+
// Count-based windows are authoritative; the `usages.limit_5h/limit_7d`
|
|
95
|
+
// ratios are legacy fields the live API returns as stale zeros even while
|
|
96
|
+
// real usage accrues, so they are only a fallback when counts are missing.
|
|
97
|
+
const detail5h = findLimitDetail(limits, 300);
|
|
98
|
+
if (detail5h !== undefined) {
|
|
99
|
+
windows.push(detailWindow("5h", "5-hour", detail5h));
|
|
100
|
+
} else {
|
|
101
|
+
const limit5h = pick(usages, "limit_5h");
|
|
102
|
+
if (limit5h !== undefined) windows.push(ratioWindow("5h", "5-hour", limit5h));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (usage !== undefined) {
|
|
106
|
+
windows.push(detailWindow("weekly", "Weekly", usage));
|
|
107
|
+
} else {
|
|
108
|
+
const limit7d = pick(usages, "limit_7d");
|
|
109
|
+
if (limit7d !== undefined) windows.push(ratioWindow("weekly", "Weekly", limit7d));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const level = pick(pick(body, "user"), "membership");
|
|
113
|
+
const membershipLevel = pick(level, "level");
|
|
114
|
+
if (membershipLevel !== undefined) extras["Plan"] = String(membershipLevel);
|
|
115
|
+
|
|
116
|
+
const wallet = pick(body, "booster_wallet");
|
|
117
|
+
|
|
118
|
+
// `balance` is a wallet descriptor object on the live API; only render scalar values.
|
|
119
|
+
const balance = pick(wallet, "balance");
|
|
120
|
+
if (typeof balance === "string" || typeof balance === "number") {
|
|
121
|
+
extras["Booster wallet"] = String(balance);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const monthlyUsed = pick(wallet, "monthlyUsed", "monthly_used");
|
|
125
|
+
const currency = pick(monthlyUsed, "currency");
|
|
126
|
+
const priceInCents = toNumber(pick(monthlyUsed, "priceInCents", "price_in_cents"));
|
|
127
|
+
if (
|
|
128
|
+
(typeof currency === "string" || typeof currency === "number") &&
|
|
129
|
+
String(currency) !== "" &&
|
|
130
|
+
priceInCents !== null
|
|
131
|
+
) {
|
|
132
|
+
extras["Booster used this month"] = `$${(priceInCents / 100).toFixed(2)} ${String(currency)}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const totalQuota = pick(body, "totalQuota", "total_quota");
|
|
136
|
+
const quotaUsed = toNumber(pick(totalQuota, "used"));
|
|
137
|
+
if (totalQuota !== undefined && quotaUsed !== null && quotaUsed > 0) {
|
|
138
|
+
windows.push({
|
|
139
|
+
kind: "monthly",
|
|
140
|
+
label: "Monthly",
|
|
141
|
+
usedPercent: null,
|
|
142
|
+
used: quotaUsed,
|
|
143
|
+
limit: toNumber(pick(totalQuota, "limit")),
|
|
144
|
+
remaining: toNumber(pick(totalQuota, "remaining")),
|
|
145
|
+
resetsAt: toISODate(pick(totalQuota, "resetTime", "reset_time", "resetAt", "resetsAt")),
|
|
146
|
+
status: "frozen",
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (windows.length === 0) {
|
|
151
|
+
throw new AdapterError(
|
|
152
|
+
"bad-response",
|
|
153
|
+
redact("Kimi API response contained no usage windows", cred.key),
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return { windows, extras };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export const kimiAdapter: ProviderAdapter = {
|
|
161
|
+
id: "kimi-for-coding",
|
|
162
|
+
displayName: "Kimi Code",
|
|
163
|
+
async fetch(cred: Credential, opts: FetchOptions): Promise<AdapterResult> {
|
|
164
|
+
const res = await fetchWithRetry(cred, opts);
|
|
165
|
+
|
|
166
|
+
if (res.status === 401) {
|
|
167
|
+
throw new AdapterError("auth", redact("invalid Kimi API key", cred.key));
|
|
168
|
+
}
|
|
169
|
+
if (res.status === 429) {
|
|
170
|
+
throw new AdapterError("rate-limited", redact("Kimi API rate limit exceeded", cred.key));
|
|
171
|
+
}
|
|
172
|
+
if (res.status < 200 || res.status >= 300) {
|
|
173
|
+
throw new AdapterError(
|
|
174
|
+
"bad-response",
|
|
175
|
+
redact(`Kimi API returned HTTP ${res.status}`, cred.key),
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let body: unknown;
|
|
180
|
+
try {
|
|
181
|
+
body = await res.json();
|
|
182
|
+
} catch {
|
|
183
|
+
throw new AdapterError(
|
|
184
|
+
"bad-response",
|
|
185
|
+
redact("Kimi API returned a non-JSON response", cred.key),
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
|
190
|
+
throw new AdapterError(
|
|
191
|
+
"bad-response",
|
|
192
|
+
redact("Kimi API returned an unexpected response shape", cred.key),
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return parse(body as Record<string, unknown>, cred);
|
|
197
|
+
},
|
|
198
|
+
};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AdapterResult,
|
|
3
|
+
Credential,
|
|
4
|
+
FetchOptions,
|
|
5
|
+
ProviderAdapter,
|
|
6
|
+
UsageWindow,
|
|
7
|
+
WindowKind,
|
|
8
|
+
WindowStatus,
|
|
9
|
+
} from "../types.js";
|
|
10
|
+
import { AdapterError } from "../types.js";
|
|
11
|
+
import { toISODate, toNumber } from "../normalize.js";
|
|
12
|
+
import { redact } from "../auth.js";
|
|
13
|
+
|
|
14
|
+
const VERSION = "0.1.0";
|
|
15
|
+
const USER_AGENT = "opencode-usage-report/" + VERSION;
|
|
16
|
+
const ENDPOINT = "https://opencode.ai/zen/go/v1/usage";
|
|
17
|
+
|
|
18
|
+
/** Additive options: `sessionId` is optional, so the adapter still satisfies `ProviderAdapter`. */
|
|
19
|
+
export type OpenCodeGoFetchOptions = FetchOptions & { sessionId?: string };
|
|
20
|
+
|
|
21
|
+
const WINDOWS: Array<{ key: string; kind: WindowKind; label: string }> = [
|
|
22
|
+
{ key: "rolling", kind: "5h", label: "5-hour" },
|
|
23
|
+
{ key: "weekly", kind: "weekly", label: "Weekly" },
|
|
24
|
+
{ key: "monthly", kind: "monthly", label: "Monthly" },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
function mapStatus(v: unknown): WindowStatus {
|
|
28
|
+
return v === "ok" || v === "rate-limited" ? v : "unknown";
|
|
29
|
+
}
|
|
30
|
+
|
|
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
|
+
function buildWindow(kind: WindowKind, label: string, src: unknown): UsageWindow | null {
|
|
38
|
+
const record = asRecord(src);
|
|
39
|
+
if (!record) return null;
|
|
40
|
+
return {
|
|
41
|
+
kind,
|
|
42
|
+
label,
|
|
43
|
+
usedPercent: toNumber(record.percent),
|
|
44
|
+
used: null,
|
|
45
|
+
limit: null,
|
|
46
|
+
remaining: null,
|
|
47
|
+
resetsAt: toISODate(record.resetsAt),
|
|
48
|
+
status: mapStatus(record.status),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
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
|
+
export const opencodeGoAdapter: ProviderAdapter = {
|
|
132
|
+
id: "opencode-go",
|
|
133
|
+
displayName: "OpenCode Go",
|
|
134
|
+
async fetch(cred: Credential, opts: OpenCodeGoFetchOptions): Promise<AdapterResult> {
|
|
135
|
+
const res = await request(cred, opts);
|
|
136
|
+
if (!res.ok) return handleError(res, cred);
|
|
137
|
+
|
|
138
|
+
const { json } = await readBody(res);
|
|
139
|
+
const body = asRecord(json);
|
|
140
|
+
const usage = body ? asRecord(body.usage) : null;
|
|
141
|
+
if (!usage) {
|
|
142
|
+
throw new AdapterError("bad-response", redact("OpenCode Go response missing usage", cred.key));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const windows: UsageWindow[] = [];
|
|
146
|
+
for (const { key, kind, label } of WINDOWS) {
|
|
147
|
+
const window = buildWindow(kind, label, usage[key]);
|
|
148
|
+
if (window) windows.push(window);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { windows };
|
|
152
|
+
},
|
|
153
|
+
};
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { ProviderReport, UsageWindow } from "./types.js";
|
|
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"];
|
|
5
|
+
|
|
6
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
7
|
+
const LABEL_WIDTH = 8;
|
|
8
|
+
const PERCENT_WIDTH = 10;
|
|
9
|
+
const ABSOLUTES_WIDTH = 24;
|
|
10
|
+
|
|
11
|
+
function formatNumber(n: number): string {
|
|
12
|
+
return n.toLocaleString("en-US");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** "14:05" for a same-day reset, "Wed Sep 16" otherwise. */
|
|
16
|
+
export function formatReset(resetsAt: string, now: Date): string | null {
|
|
17
|
+
const d = new Date(resetsAt);
|
|
18
|
+
if (Number.isNaN(d.getTime())) return null;
|
|
19
|
+
const within24h = Math.abs(d.getTime() - now.getTime()) < DAY_MS;
|
|
20
|
+
if (within24h) {
|
|
21
|
+
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
22
|
+
}
|
|
23
|
+
return `${WEEKDAYS[d.getDay()]} ${MONTHS[d.getMonth()]} ${d.getDate()}`;
|
|
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`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function header(report: ProviderReport, now: Date): string {
|
|
34
|
+
const parts: string[] = [report.source];
|
|
35
|
+
if (report.stale) {
|
|
36
|
+
parts.push(`stale ${formatAge(report.fetchedAt, now)} old`);
|
|
37
|
+
}
|
|
38
|
+
return `${report.displayName} (${parts.join(", ")})`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function windowLine(report: ProviderReport, w: UsageWindow, now: Date): string {
|
|
42
|
+
// Local estimates always carry the `~` marker, even when only absolutes are
|
|
43
|
+
// known (percent is null): a bare em dash would hide that the row is estimated.
|
|
44
|
+
const isEstimate = report.source === "local-estimate";
|
|
45
|
+
const percent =
|
|
46
|
+
w.usedPercent !== null
|
|
47
|
+
? `${isEstimate ? "~" : ""}${w.usedPercent}%`
|
|
48
|
+
: isEstimate
|
|
49
|
+
? "~estimate"
|
|
50
|
+
: "—";
|
|
51
|
+
let line = ` ${w.label.padEnd(LABEL_WIDTH)}${percent.padEnd(PERCENT_WIDTH)}`;
|
|
52
|
+
|
|
53
|
+
if (w.used !== null && w.limit !== null) {
|
|
54
|
+
line += `${formatNumber(w.used)} / ${formatNumber(w.limit)} reqs`.padEnd(ABSOLUTES_WIDTH);
|
|
55
|
+
}
|
|
56
|
+
if (w.resetsAt !== null) {
|
|
57
|
+
const reset = formatReset(w.resetsAt, now);
|
|
58
|
+
if (reset !== null) line += `resets ${reset}`;
|
|
59
|
+
}
|
|
60
|
+
return line.trimEnd();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Renders provider reports as a human-readable text table (spec §3.8). */
|
|
64
|
+
export function renderText(reports: ProviderReport[], opts?: { now?: Date }): string {
|
|
65
|
+
const now = opts?.now ?? new Date();
|
|
66
|
+
const blocks = reports.map((report) => {
|
|
67
|
+
if (report.source === "error") {
|
|
68
|
+
return `⚠ ${report.displayName}: ${report.error ?? "unknown error"}`;
|
|
69
|
+
}
|
|
70
|
+
const lines = [header(report, now)];
|
|
71
|
+
for (const w of report.windows) {
|
|
72
|
+
lines.push(windowLine(report, w, now));
|
|
73
|
+
}
|
|
74
|
+
for (const [key, value] of Object.entries(report.extras)) {
|
|
75
|
+
lines.push(` ${key}: ${value}`);
|
|
76
|
+
}
|
|
77
|
+
return lines.join("\n");
|
|
78
|
+
});
|
|
79
|
+
return blocks.join("\n\n");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Renders provider reports as pretty-printed JSON. */
|
|
83
|
+
export function renderJson(reports: ProviderReport[]): string {
|
|
84
|
+
return JSON.stringify(reports, null, 2);
|
|
85
|
+
}
|
package/src/report.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type { PluginOptions, ProviderAdapter, ProviderReport } from "./types.js";
|
|
2
|
+
import { adapters, getAdapter } from "./providers/index.js";
|
|
3
|
+
import { redact, resolveCredential } from "./auth.js";
|
|
4
|
+
import { ensureSessionId, readCache, writeCache } from "./cache.js";
|
|
5
|
+
import type { CacheEntry } from "./cache.js";
|
|
6
|
+
import { localEstimate } from "./fallback.js";
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_OPTIONS: PluginOptions = {
|
|
9
|
+
thresholdPercent: 80,
|
|
10
|
+
cacheTtlSeconds: 120,
|
|
11
|
+
providers: null,
|
|
12
|
+
fallback: true,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const FETCH_TIMEOUT_MS = 5000;
|
|
16
|
+
|
|
17
|
+
export interface CollectReportsOptions {
|
|
18
|
+
providers?: string[];
|
|
19
|
+
refresh?: boolean;
|
|
20
|
+
options?: Partial<PluginOptions>;
|
|
21
|
+
env?: NodeJS.ProcessEnv;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Normalizes a provider filter: null/undefined/empty all mean "all providers". */
|
|
25
|
+
function firstNonEmpty(list: string[] | null | undefined): string[] | null {
|
|
26
|
+
return Array.isArray(list) && list.length > 0 ? list : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function nowIso(): string {
|
|
30
|
+
return new Date().toISOString();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function errorReport(provider: string, displayName: string, error: string): ProviderReport {
|
|
34
|
+
return {
|
|
35
|
+
provider,
|
|
36
|
+
displayName,
|
|
37
|
+
fetchedAt: nowIso(),
|
|
38
|
+
source: "error",
|
|
39
|
+
stale: false,
|
|
40
|
+
windows: [],
|
|
41
|
+
extras: {},
|
|
42
|
+
error,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function cacheReport(
|
|
47
|
+
provider: string,
|
|
48
|
+
displayName: string,
|
|
49
|
+
entry: CacheEntry,
|
|
50
|
+
stale: boolean,
|
|
51
|
+
error: string | null,
|
|
52
|
+
): ProviderReport {
|
|
53
|
+
return {
|
|
54
|
+
provider,
|
|
55
|
+
displayName,
|
|
56
|
+
fetchedAt: entry.fetchedAt,
|
|
57
|
+
source: "cache",
|
|
58
|
+
stale,
|
|
59
|
+
windows: entry.result.windows,
|
|
60
|
+
extras: entry.result.extras ?? {},
|
|
61
|
+
error,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Collects one provider: credential -> fresh cache -> live fetch -> stale cache -> local estimate. */
|
|
66
|
+
async function collectOne(
|
|
67
|
+
id: string,
|
|
68
|
+
adapter: ProviderAdapter,
|
|
69
|
+
options: PluginOptions,
|
|
70
|
+
refresh: boolean,
|
|
71
|
+
env: NodeJS.ProcessEnv,
|
|
72
|
+
): Promise<ProviderReport> {
|
|
73
|
+
const cred = resolveCredential(id, { env });
|
|
74
|
+
if (cred === null) return errorReport(id, adapter.displayName, "no credential found");
|
|
75
|
+
|
|
76
|
+
if (!refresh) {
|
|
77
|
+
const cached = await readCache(id, options.cacheTtlSeconds, env);
|
|
78
|
+
if (cached !== null && cached.fresh) {
|
|
79
|
+
return cacheReport(id, adapter.displayName, cached.entry, false, null);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let failure: string;
|
|
84
|
+
try {
|
|
85
|
+
const sessionId = await ensureSessionId(env);
|
|
86
|
+
const fetchOptions = { timeoutMs: FETCH_TIMEOUT_MS, sessionId };
|
|
87
|
+
const result = await adapter.fetch(cred, fetchOptions);
|
|
88
|
+
await writeCache(id, result, env);
|
|
89
|
+
return {
|
|
90
|
+
provider: id,
|
|
91
|
+
displayName: adapter.displayName,
|
|
92
|
+
fetchedAt: nowIso(),
|
|
93
|
+
source: "api",
|
|
94
|
+
stale: false,
|
|
95
|
+
windows: result.windows,
|
|
96
|
+
extras: result.extras ?? {},
|
|
97
|
+
error: null,
|
|
98
|
+
};
|
|
99
|
+
} catch (err) {
|
|
100
|
+
failure = redact(err instanceof Error ? err.message : String(err), cred.key);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const cached = await readCache(id, options.cacheTtlSeconds, env);
|
|
104
|
+
if (cached !== null) {
|
|
105
|
+
return cacheReport(id, adapter.displayName, cached.entry, true, failure);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (options.fallback) {
|
|
109
|
+
const estimate = await localEstimate(id, env);
|
|
110
|
+
if (estimate !== null) {
|
|
111
|
+
return {
|
|
112
|
+
provider: id,
|
|
113
|
+
displayName: adapter.displayName,
|
|
114
|
+
fetchedAt: nowIso(),
|
|
115
|
+
source: "local-estimate",
|
|
116
|
+
stale: true,
|
|
117
|
+
windows: estimate.windows,
|
|
118
|
+
extras: estimate.extras ?? {},
|
|
119
|
+
error: failure,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return errorReport(id, adapter.displayName, failure);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Collects normalized usage reports for the requested providers (default: every
|
|
129
|
+
* registered adapter). Never throws: each provider is isolated and failures
|
|
130
|
+
* surface as `source: "error"` rows.
|
|
131
|
+
*/
|
|
132
|
+
export async function collectReports(opts: CollectReportsOptions = {}): Promise<ProviderReport[]> {
|
|
133
|
+
const options: PluginOptions = { ...DEFAULT_OPTIONS, ...(opts.options ?? {}) };
|
|
134
|
+
const env = opts.env ?? process.env;
|
|
135
|
+
const refresh = opts.refresh === true;
|
|
136
|
+
|
|
137
|
+
// An empty filter is treated like "no filter": report every registered adapter.
|
|
138
|
+
const ids =
|
|
139
|
+
firstNonEmpty(opts.providers) ??
|
|
140
|
+
firstNonEmpty(options.providers) ??
|
|
141
|
+
adapters.map((adapter) => adapter.id);
|
|
142
|
+
const reports: ProviderReport[] = [];
|
|
143
|
+
|
|
144
|
+
for (const id of ids) {
|
|
145
|
+
const adapter = getAdapter(id);
|
|
146
|
+
if (adapter === undefined) {
|
|
147
|
+
const known = adapters.map((entry) => entry.id).join(", ");
|
|
148
|
+
reports.push(errorReport(id, id, `unknown provider '${id}' (known: ${known})`));
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
reports.push(await collectOne(id, adapter, options, refresh, env));
|
|
154
|
+
} catch {
|
|
155
|
+
// Last-resort isolation: never let one provider break the whole report.
|
|
156
|
+
reports.push(errorReport(id, adapter.displayName, "internal error while collecting usage"));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return reports;
|
|
161
|
+
}
|