pi-opencode-go-provider 1.0.23 → 1.0.25
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 +52 -1
- package/config.ts +127 -0
- package/deprecated-models.json +29 -0
- package/format.ts +84 -0
- package/index.ts +113 -1
- package/models.json +28 -28
- package/package.json +8 -4
- package/tests/format.test.ts +35 -0
- package/tests/usage.test.ts +255 -0
- package/tsconfig.json +22 -0
- package/usage-controller.ts +328 -0
- package/usage.ts +341 -0
package/usage.ts
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode Go usage windows.
|
|
3
|
+
*
|
|
4
|
+
* `GET https://opencode.ai/zen/go/v1/usage` is the only place the Go plan's
|
|
5
|
+
* three meter windows are published. Server source:
|
|
6
|
+
* sst/opencode packages/console/app/src/routes/zen/go/v1/usage.ts
|
|
7
|
+
* sst/opencode packages/console/core/src/subscription.ts
|
|
8
|
+
*
|
|
9
|
+
* Authorization: Bearer <api-key>
|
|
10
|
+
* 200 { "usage": { "rolling" | "weekly" | "monthly":
|
|
11
|
+
* { "status": "ok" | "rate-limited", "percent": 0-100,
|
|
12
|
+
* "resetsAt": "<ISO-8601>" } } }
|
|
13
|
+
* 401 AuthError (missing/unknown key), 403 EntitlementError (no Go plan)
|
|
14
|
+
*
|
|
15
|
+
* `percent` is the share of the window budget already spent
|
|
16
|
+
* (`floor(min(100, usage / limit * 100))`, forced to 100 once the window is rate
|
|
17
|
+
* limited), so every surface here reports `100 - percent` — the budget you have
|
|
18
|
+
* left. The dollar limits are not on the wire; see USAGE_LIMITS_NOTE.
|
|
19
|
+
*
|
|
20
|
+
* The widget line mirrors the pi-better-openai usage line so both providers read
|
|
21
|
+
* the same way:
|
|
22
|
+
*
|
|
23
|
+
* Usage: 5h: 63% · 7d: 41% · 30d: 12% · 5h ↺ 2h14m · 7d ↺ 3d20h ·
|
|
24
|
+
* 30d ↺ 20d0h
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export const USAGE_URL = "https://opencode.ai/zen/go/v1/usage";
|
|
28
|
+
|
|
29
|
+
/** Published Go budget per window; informational only, not sent by the API. */
|
|
30
|
+
export const USAGE_LIMITS_NOTE = "5h $12 · 7d $30 · 30d $60";
|
|
31
|
+
|
|
32
|
+
export type UsageWindowKey = "rolling" | "weekly" | "monthly";
|
|
33
|
+
export type UsageStatus = "ok" | "rate-limited" | "unknown";
|
|
34
|
+
export type UsageSeverity = "ok" | "warning" | "critical" | "muted";
|
|
35
|
+
|
|
36
|
+
export interface UsageWindow {
|
|
37
|
+
key: UsageWindowKey;
|
|
38
|
+
/** Short human label: 5h / 7d / 30d. */
|
|
39
|
+
label: string;
|
|
40
|
+
status: UsageStatus;
|
|
41
|
+
/** 0-100, share of the window budget already consumed (raw API value). */
|
|
42
|
+
usedPercent: number;
|
|
43
|
+
/** 0-100, share of the window budget still available (100 - usedPercent). */
|
|
44
|
+
remainingPercent: number;
|
|
45
|
+
/** Window rollover instant in epoch milliseconds, or null when unknown. */
|
|
46
|
+
resetsAt: number | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface UsageSnapshot {
|
|
50
|
+
capturedAt: number;
|
|
51
|
+
windows: UsageWindow[];
|
|
52
|
+
isLimited: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Banked reset credits. The Go API does not expose this today — its payload
|
|
55
|
+
* carries only status/percent/resetsAt per window — so the segment stays off
|
|
56
|
+
* the line until a response actually includes `bankedResets`.
|
|
57
|
+
*/
|
|
58
|
+
bankedResets: number | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface UsageSegment {
|
|
62
|
+
text: string;
|
|
63
|
+
severity: UsageSeverity;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface UsageFormatOptions {
|
|
67
|
+
showResetTimes: boolean;
|
|
68
|
+
showBankedResets?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const USAGE_WINDOW_LABELS: Record<UsageWindowKey, string> = {
|
|
72
|
+
rolling: "5h",
|
|
73
|
+
weekly: "7d",
|
|
74
|
+
monthly: "30d",
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Whether a window's reset needs a calendar date as well as a weekday. */
|
|
78
|
+
const USAGE_WINDOW_INCLUDE_DATE: Record<UsageWindowKey, boolean> = {
|
|
79
|
+
rolling: false,
|
|
80
|
+
weekly: true,
|
|
81
|
+
monthly: true,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const WINDOW_KEYS: UsageWindowKey[] = ["rolling", "weekly", "monthly"];
|
|
85
|
+
/** Remaining budget at or below these thresholds turns the percentage amber/red. */
|
|
86
|
+
const WARNING_REMAINING_PERCENT = 30;
|
|
87
|
+
const CRITICAL_REMAINING_PERCENT = 10;
|
|
88
|
+
|
|
89
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
90
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function clampPercent(value: number): number {
|
|
94
|
+
return Math.min(100, Math.max(0, value));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function toFiniteNumber(value: unknown): number | undefined {
|
|
98
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
|
|
99
|
+
if (typeof value === "string" && value.trim()) {
|
|
100
|
+
const parsed = Number(value.trim());
|
|
101
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
102
|
+
}
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Accept ISO-8601 strings, epoch seconds, or epoch milliseconds. */
|
|
107
|
+
export function parseResetAt(value: unknown): number | null {
|
|
108
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
109
|
+
return value < 1e12 ? value * 1000 : value;
|
|
110
|
+
}
|
|
111
|
+
if (typeof value === "string" && value.trim()) {
|
|
112
|
+
const trimmed = value.trim();
|
|
113
|
+
if (/^-?\d+(?:\.\d+)?$/.test(trimmed)) return parseResetAt(Number(trimmed));
|
|
114
|
+
const parsed = Date.parse(trimmed);
|
|
115
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function parseWindow(key: UsageWindowKey, raw: unknown): UsageWindow | undefined {
|
|
121
|
+
if (!isRecord(raw)) return undefined;
|
|
122
|
+
const percent = toFiniteNumber(raw.percent);
|
|
123
|
+
if (percent === undefined) return undefined;
|
|
124
|
+
const status: UsageStatus =
|
|
125
|
+
raw.status === "rate-limited" ? "rate-limited" : raw.status === "ok" ? "ok" : "unknown";
|
|
126
|
+
const usedPercent = clampPercent(percent);
|
|
127
|
+
return {
|
|
128
|
+
key,
|
|
129
|
+
label: USAGE_WINDOW_LABELS[key],
|
|
130
|
+
status,
|
|
131
|
+
usedPercent,
|
|
132
|
+
remainingPercent: 100 - usedPercent,
|
|
133
|
+
resetsAt: parseResetAt(raw.resetsAt),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function parseBankedResets(usage: Record<string, unknown>): number | null {
|
|
138
|
+
const count = toFiniteNumber(usage.bankedResets);
|
|
139
|
+
return count !== undefined && Number.isInteger(count) && count >= 0 ? count : null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Parse a usage response into a snapshot. Returns undefined when the payload
|
|
144
|
+
* carries no usable window so callers can report "no data" instead of zeros.
|
|
145
|
+
*/
|
|
146
|
+
export function parseUsageSnapshot(payload: unknown, now = Date.now()): UsageSnapshot | undefined {
|
|
147
|
+
if (!isRecord(payload) || !isRecord(payload.usage)) return undefined;
|
|
148
|
+
const usage = payload.usage;
|
|
149
|
+
const windows: UsageWindow[] = [];
|
|
150
|
+
for (const key of WINDOW_KEYS) {
|
|
151
|
+
const window = parseWindow(key, usage[key]);
|
|
152
|
+
if (window) windows.push(window);
|
|
153
|
+
}
|
|
154
|
+
if (windows.length === 0) return undefined;
|
|
155
|
+
return {
|
|
156
|
+
capturedAt: now,
|
|
157
|
+
windows,
|
|
158
|
+
isLimited: windows.some((window) => window.status === "rate-limited"),
|
|
159
|
+
bankedResets: parseBankedResets(usage),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Turn a non-2xx usage response into an actionable message. */
|
|
164
|
+
export function usageHttpErrorMessage(status: number, payload: unknown): string {
|
|
165
|
+
const detail =
|
|
166
|
+
isRecord(payload) && isRecord(payload.error) && typeof payload.error.message === "string"
|
|
167
|
+
? payload.error.message
|
|
168
|
+
: undefined;
|
|
169
|
+
const suffix = detail ? `: ${detail}` : ".";
|
|
170
|
+
if (status === 401) return `OpenCode Go rejected the API key (401)${suffix}`;
|
|
171
|
+
if (status === 403) return `This key has no OpenCode Go subscription (403)${suffix}`;
|
|
172
|
+
return `OpenCode Go usage request failed (${status})${suffix}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function formatPercent(value: number): string {
|
|
176
|
+
return `${Math.round(clampPercent(value))}%`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Compact duration for a countdown: 2h14m / 5d3h / 45m / 12s / now. */
|
|
180
|
+
export function formatCountdown(milliseconds: number): string {
|
|
181
|
+
if (!Number.isFinite(milliseconds)) return "unknown";
|
|
182
|
+
const total = Math.max(0, Math.round(milliseconds / 1000));
|
|
183
|
+
if (total === 0) return "now";
|
|
184
|
+
const days = Math.floor(total / 86_400);
|
|
185
|
+
const hours = Math.floor((total % 86_400) / 3600);
|
|
186
|
+
const minutes = Math.floor((total % 3600) / 60);
|
|
187
|
+
const seconds = total % 60;
|
|
188
|
+
if (days > 0) return `${days}d${hours}h`;
|
|
189
|
+
if (hours > 0) return `${hours}h${minutes}m`;
|
|
190
|
+
if (minutes > 0) return `${minutes}m`;
|
|
191
|
+
return `${seconds}s`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
interface ResetClockFormatters {
|
|
195
|
+
time: Intl.DateTimeFormat;
|
|
196
|
+
weekday: Intl.DateTimeFormat;
|
|
197
|
+
date: Intl.DateTimeFormat;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const RESET_CLOCK_FORMATTER_LIMIT = 8;
|
|
201
|
+
const resetClockFormatters = new Map<string, ResetClockFormatters>();
|
|
202
|
+
|
|
203
|
+
function timeZoneId(): string {
|
|
204
|
+
try {
|
|
205
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || "local";
|
|
206
|
+
} catch {
|
|
207
|
+
return "local";
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Intl formatters are expensive; cache one set per time zone and offset. */
|
|
212
|
+
function resetClockFormatterFor(reset: Date): ResetClockFormatters {
|
|
213
|
+
const key = `${timeZoneId()}:${reset.getTimezoneOffset()}`;
|
|
214
|
+
let formatters = resetClockFormatters.get(key);
|
|
215
|
+
if (!formatters) {
|
|
216
|
+
formatters = {
|
|
217
|
+
time: new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }),
|
|
218
|
+
weekday: new Intl.DateTimeFormat(undefined, { weekday: "short" }),
|
|
219
|
+
date: new Intl.DateTimeFormat(undefined, { month: "numeric", day: "numeric" }),
|
|
220
|
+
};
|
|
221
|
+
resetClockFormatters.set(key, formatters);
|
|
222
|
+
while (resetClockFormatters.size > RESET_CLOCK_FORMATTER_LIMIT) {
|
|
223
|
+
const oldest = resetClockFormatters.keys().next().value;
|
|
224
|
+
if (oldest === undefined) break;
|
|
225
|
+
resetClockFormatters.delete(oldest);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return formatters;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Local wall-clock instant a window rolls over: "3:14 PM" later today,
|
|
233
|
+
* "Tue 3:14 PM" within the week, or "Tue 9/15 10:42 AM" with `includeDate`.
|
|
234
|
+
*/
|
|
235
|
+
export function formatResetClock(
|
|
236
|
+
resetAt: number,
|
|
237
|
+
options?: { includeDate?: boolean },
|
|
238
|
+
now = Date.now(),
|
|
239
|
+
): string | null {
|
|
240
|
+
const reset = new Date(resetAt);
|
|
241
|
+
if (Number.isNaN(reset.getTime())) return null;
|
|
242
|
+
const formatters = resetClockFormatterFor(reset);
|
|
243
|
+
const time = formatters.time.format(reset);
|
|
244
|
+
if (!options?.includeDate && reset.toDateString() === new Date(now).toDateString()) return time;
|
|
245
|
+
const weekday = formatters.weekday.format(reset);
|
|
246
|
+
if (!options?.includeDate) return `${weekday} ${time}`;
|
|
247
|
+
return `${weekday} ${formatters.date.format(reset)} ${time}`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* "↺ 2h14m", with the window label when several are listed. The widget carries
|
|
252
|
+
* countdowns only: three windows plus three wall-clock times run past the
|
|
253
|
+
* terminal width, so the exact reset time lives in the breakdown instead.
|
|
254
|
+
*/
|
|
255
|
+
function formatCompactReset(
|
|
256
|
+
label: string | undefined,
|
|
257
|
+
resetAt: number | null,
|
|
258
|
+
now: number,
|
|
259
|
+
): string | null {
|
|
260
|
+
if (resetAt === null) return null;
|
|
261
|
+
return `${label ? `${label} ` : ""}↺ ${formatCountdown(resetAt - now)}`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** "3 banked resets", or null when the count is absent or zero. */
|
|
265
|
+
export function formatBankedResetsSuffix(count: number | null): string | null {
|
|
266
|
+
if (count === null || !Number.isInteger(count) || count <= 0) return null;
|
|
267
|
+
return `${count} banked reset${count === 1 ? "" : "s"}`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Remaining budget drives the colour: plenty left is green, nearly spent is red. */
|
|
271
|
+
export function severityForWindow(window: UsageWindow): UsageSeverity {
|
|
272
|
+
if (window.status === "rate-limited" || window.remainingPercent <= CRITICAL_REMAINING_PERCENT) {
|
|
273
|
+
return "critical";
|
|
274
|
+
}
|
|
275
|
+
if (window.remainingPercent <= WARNING_REMAINING_PERCENT) return "warning";
|
|
276
|
+
return "ok";
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* One-line widget, split into severity-tagged segments:
|
|
281
|
+
* "Usage: 5h: 63% · 7d: 41% · 30d: 12% · 5h ↺ 2h14m · 7d ↺ 3d20h · 30d ↺ …".
|
|
282
|
+
*/
|
|
283
|
+
export function usageSegments(
|
|
284
|
+
snapshot: UsageSnapshot,
|
|
285
|
+
options: UsageFormatOptions,
|
|
286
|
+
now = Date.now(),
|
|
287
|
+
): UsageSegment[] {
|
|
288
|
+
const windows = snapshot.windows;
|
|
289
|
+
const labelled = windows.length > 1;
|
|
290
|
+
const segments: UsageSegment[] = [{ text: "Usage: ", severity: "muted" }];
|
|
291
|
+
windows.forEach((window, index) => {
|
|
292
|
+
if (index > 0) segments.push({ text: " · ", severity: "muted" });
|
|
293
|
+
segments.push({ text: `${window.label}: `, severity: "muted" });
|
|
294
|
+
segments.push({
|
|
295
|
+
text: formatPercent(window.remainingPercent),
|
|
296
|
+
severity: severityForWindow(window),
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
if (options.showResetTimes) {
|
|
300
|
+
for (const window of windows) {
|
|
301
|
+
const reset = formatCompactReset(labelled ? window.label : undefined, window.resetsAt, now);
|
|
302
|
+
if (reset) segments.push({ text: ` · ${reset}`, severity: "muted" });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (options.showBankedResets !== false) {
|
|
306
|
+
const banked = formatBankedResetsSuffix(snapshot.bankedResets);
|
|
307
|
+
if (banked) segments.push({ text: ` · ${banked}`, severity: "muted" });
|
|
308
|
+
}
|
|
309
|
+
return segments;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function formatUsageLine(
|
|
313
|
+
snapshot: UsageSnapshot,
|
|
314
|
+
options: UsageFormatOptions,
|
|
315
|
+
now = Date.now(),
|
|
316
|
+
): string {
|
|
317
|
+
return usageSegments(snapshot, options, now)
|
|
318
|
+
.map((segment) => segment.text)
|
|
319
|
+
.join("");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Progress bar: 20 cells, filled by the remaining percentage. */
|
|
323
|
+
export function formatBar(percent: number, width = 20): string {
|
|
324
|
+
const filled = Math.round((clampPercent(percent) / 100) * width);
|
|
325
|
+
return `${"█".repeat(filled)}${"░".repeat(width - filled)}`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Multi-line breakdown with bars, used by the command output. */
|
|
329
|
+
export function formatUsageDetail(snapshot: UsageSnapshot, now = Date.now()): string[] {
|
|
330
|
+
return snapshot.windows.map((window) => {
|
|
331
|
+
const clock = window.resetsAt === null
|
|
332
|
+
? null
|
|
333
|
+
: formatResetClock(window.resetsAt, { includeDate: USAGE_WINDOW_INCLUDE_DATE[window.key] }, now);
|
|
334
|
+
const reset =
|
|
335
|
+
window.resetsAt === null || clock === null
|
|
336
|
+
? ""
|
|
337
|
+
: ` ↺ ${formatCountdown(window.resetsAt - now)} - ${clock}`;
|
|
338
|
+
const limited = window.status === "rate-limited" ? " RATE LIMITED" : "";
|
|
339
|
+
return `${window.label.padEnd(3)} ${formatBar(window.remainingPercent)} ${formatPercent(window.remainingPercent).padStart(4)} left${reset}${limited}`;
|
|
340
|
+
});
|
|
341
|
+
}
|