pi-opencode-go-provider 1.0.24 → 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/format.ts +84 -0
- package/index.ts +113 -1
- package/package.json +13 -9
- 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/bun.lock +0 -335
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
formatBankedResetsSuffix,
|
|
5
|
+
formatBar,
|
|
6
|
+
formatCountdown,
|
|
7
|
+
formatPercent,
|
|
8
|
+
formatResetClock,
|
|
9
|
+
formatUsageDetail,
|
|
10
|
+
formatUsageLine,
|
|
11
|
+
parseResetAt,
|
|
12
|
+
parseUsageSnapshot,
|
|
13
|
+
severityForWindow,
|
|
14
|
+
usageHttpErrorMessage,
|
|
15
|
+
usageSegments,
|
|
16
|
+
type UsageSnapshot,
|
|
17
|
+
} from "../usage.ts";
|
|
18
|
+
|
|
19
|
+
const NOW = Date.parse("2026-09-11T12:00:00.000Z");
|
|
20
|
+
const HOUR = 3_600_000;
|
|
21
|
+
const DAY = 86_400_000;
|
|
22
|
+
// Localized clock: "3:14 PM" or "15:14", optionally prefixed with a weekday.
|
|
23
|
+
const CLOCK = /(?:\S+ )?\d{1,2}:\d{2}(?:\s?[AP]M)?/;
|
|
24
|
+
// Long-window clock always carries a weekday and a date: "Tue 9/15 10:42 AM".
|
|
25
|
+
const DATE_CLOCK = /\S+ \d{1,2}\/\d{1,2} \d{1,2}:\d{2}(?:\s?[AP]M)?/;
|
|
26
|
+
|
|
27
|
+
function payloadWith(usage: Record<string, unknown>): unknown {
|
|
28
|
+
return { usage };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Build a snapshot through the real parser so fixtures cannot drift. */
|
|
32
|
+
function snapshotWith(
|
|
33
|
+
used: Record<string, number>,
|
|
34
|
+
statuses: Record<string, string> = {},
|
|
35
|
+
): UsageSnapshot {
|
|
36
|
+
const usage: Record<string, unknown> = {};
|
|
37
|
+
for (const [key, value] of Object.entries(used)) {
|
|
38
|
+
usage[key] = { status: statuses[key] ?? "ok", percent: value, resetsAt: null };
|
|
39
|
+
}
|
|
40
|
+
const snapshot = parseUsageSnapshot(payloadWith(usage), NOW);
|
|
41
|
+
assert.ok(snapshot);
|
|
42
|
+
return snapshot;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test("parses the live usage shape into three labeled windows", () => {
|
|
46
|
+
const snapshot = parseUsageSnapshot(
|
|
47
|
+
payloadWith({
|
|
48
|
+
rolling: { status: "ok", percent: 63, resetsAt: new Date(NOW + 2 * HOUR).toISOString() },
|
|
49
|
+
weekly: { status: "ok", percent: 41, resetsAt: new Date(NOW + 5 * DAY).toISOString() },
|
|
50
|
+
monthly: { status: "ok", percent: 12, resetsAt: new Date(NOW + 23 * DAY).toISOString() },
|
|
51
|
+
}),
|
|
52
|
+
NOW,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
assert.ok(snapshot);
|
|
56
|
+
assert.deepEqual(
|
|
57
|
+
snapshot.windows.map((w) => [w.key, w.label, w.usedPercent, w.remainingPercent, w.status]),
|
|
58
|
+
[
|
|
59
|
+
["rolling", "5h", 63, 37, "ok"],
|
|
60
|
+
["weekly", "7d", 41, 59, "ok"],
|
|
61
|
+
["monthly", "30d", 12, 88, "ok"],
|
|
62
|
+
],
|
|
63
|
+
);
|
|
64
|
+
assert.equal(snapshot.windows[0]!.resetsAt, NOW + 2 * HOUR);
|
|
65
|
+
assert.equal(snapshot.isLimited, false);
|
|
66
|
+
assert.equal(snapshot.bankedResets, null);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("flags rate-limited windows", () => {
|
|
70
|
+
const snapshot = parseUsageSnapshot(
|
|
71
|
+
payloadWith({
|
|
72
|
+
rolling: { status: "rate-limited", percent: 100, resetsAt: new Date(NOW + HOUR).toISOString() },
|
|
73
|
+
weekly: { status: "ok", percent: 20, resetsAt: null },
|
|
74
|
+
}),
|
|
75
|
+
NOW,
|
|
76
|
+
);
|
|
77
|
+
assert.ok(snapshot);
|
|
78
|
+
assert.equal(snapshot.isLimited, true);
|
|
79
|
+
assert.equal(snapshot.windows.length, 2);
|
|
80
|
+
assert.equal(snapshot.windows[0]!.remainingPercent, 0);
|
|
81
|
+
assert.equal(snapshot.windows[1]!.resetsAt, null);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("ignores malformed windows and rejects payloads without usable data", () => {
|
|
85
|
+
assert.equal(parseUsageSnapshot(undefined, NOW), undefined);
|
|
86
|
+
assert.equal(parseUsageSnapshot({}, NOW), undefined);
|
|
87
|
+
assert.equal(parseUsageSnapshot({ usage: {} }, NOW), undefined);
|
|
88
|
+
assert.equal(parseUsageSnapshot({ usage: { rolling: { status: "ok" } } }, NOW), undefined);
|
|
89
|
+
assert.equal(parseUsageSnapshot({ rollingUsage: { usagePercent: 5 } }, NOW), undefined);
|
|
90
|
+
|
|
91
|
+
const partial = parseUsageSnapshot(
|
|
92
|
+
payloadWith({
|
|
93
|
+
rolling: { status: "ok", percent: 10, resetsAt: "not-a-date" },
|
|
94
|
+
weekly: { status: "weird", percent: "25" },
|
|
95
|
+
monthly: null,
|
|
96
|
+
}),
|
|
97
|
+
NOW,
|
|
98
|
+
);
|
|
99
|
+
assert.ok(partial);
|
|
100
|
+
assert.equal(partial.windows.length, 2);
|
|
101
|
+
assert.equal(partial.windows[0]!.resetsAt, null);
|
|
102
|
+
assert.equal(partial.windows[1]!.status, "unknown");
|
|
103
|
+
assert.equal(partial.windows[1]!.usedPercent, 25);
|
|
104
|
+
assert.equal(partial.windows[1]!.remainingPercent, 75);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("clamps out-of-range percents", () => {
|
|
108
|
+
const snapshot = parseUsageSnapshot(
|
|
109
|
+
payloadWith({
|
|
110
|
+
rolling: { status: "ok", percent: 140 },
|
|
111
|
+
weekly: { status: "ok", percent: -20 },
|
|
112
|
+
}),
|
|
113
|
+
NOW,
|
|
114
|
+
);
|
|
115
|
+
assert.ok(snapshot);
|
|
116
|
+
assert.equal(snapshot.windows[0]!.usedPercent, 100);
|
|
117
|
+
assert.equal(snapshot.windows[0]!.remainingPercent, 0);
|
|
118
|
+
assert.equal(snapshot.windows[1]!.usedPercent, 0);
|
|
119
|
+
assert.equal(snapshot.windows[1]!.remainingPercent, 100);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("parses ISO strings, epoch seconds and epoch milliseconds", () => {
|
|
123
|
+
assert.equal(parseResetAt("2026-09-11T14:00:00.000Z"), Date.parse("2026-09-11T14:00:00.000Z"));
|
|
124
|
+
assert.equal(parseResetAt("1789137600"), 1_789_137_600_000);
|
|
125
|
+
assert.equal(parseResetAt(1_789_137_600_000), 1_789_137_600_000);
|
|
126
|
+
assert.equal(parseResetAt("nope"), null);
|
|
127
|
+
assert.equal(parseResetAt(null), null);
|
|
128
|
+
assert.equal(parseResetAt(Number.NaN), null);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("formats the widget line with remaining budget and reset countdowns", () => {
|
|
132
|
+
const snapshot = parseUsageSnapshot(
|
|
133
|
+
payloadWith({
|
|
134
|
+
rolling: { status: "ok", percent: 37, resetsAt: new Date(NOW + 2 * HOUR + 14 * 60_000).toISOString() },
|
|
135
|
+
weekly: { status: "ok", percent: 59, resetsAt: new Date(NOW + 5 * DAY + 3 * HOUR).toISOString() },
|
|
136
|
+
monthly: { status: "rate-limited", percent: 100, resetsAt: new Date(NOW + 23 * DAY + 4 * HOUR).toISOString() },
|
|
137
|
+
}),
|
|
138
|
+
NOW,
|
|
139
|
+
);
|
|
140
|
+
assert.ok(snapshot);
|
|
141
|
+
|
|
142
|
+
assert.equal(
|
|
143
|
+
formatUsageLine(snapshot, { showResetTimes: true }, NOW),
|
|
144
|
+
"Usage: 5h: 63% · 7d: 41% · 30d: 0% · 5h ↺ 2h14m · 7d ↺ 5d3h · 30d ↺ 23d4h",
|
|
145
|
+
);
|
|
146
|
+
assert.equal(
|
|
147
|
+
formatUsageLine(snapshot, { showResetTimes: false }, NOW),
|
|
148
|
+
"Usage: 5h: 63% · 7d: 41% · 30d: 0%",
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("uses the singular label only when a single window is reported", () => {
|
|
153
|
+
const snapshot = parseUsageSnapshot(
|
|
154
|
+
payloadWith({ weekly: { status: "ok", percent: 77, resetsAt: new Date(NOW + 3 * DAY + 20 * HOUR).toISOString() } }),
|
|
155
|
+
NOW,
|
|
156
|
+
);
|
|
157
|
+
assert.ok(snapshot);
|
|
158
|
+
assert.equal(
|
|
159
|
+
formatUsageLine(snapshot, { showResetTimes: true }, NOW),
|
|
160
|
+
"Usage: 7d: 23% · ↺ 3d20h",
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("appends banked resets only when the API reports them", () => {
|
|
165
|
+
const withResets = parseUsageSnapshot(
|
|
166
|
+
payloadWith({ rolling: { status: "ok", percent: 10, resetsAt: null }, bankedResets: 3 }),
|
|
167
|
+
NOW,
|
|
168
|
+
);
|
|
169
|
+
assert.ok(withResets);
|
|
170
|
+
assert.equal(withResets.bankedResets, 3);
|
|
171
|
+
assert.equal(
|
|
172
|
+
formatUsageLine(withResets, { showResetTimes: false }, NOW),
|
|
173
|
+
"Usage: 5h: 90% · 3 banked resets",
|
|
174
|
+
);
|
|
175
|
+
assert.equal(
|
|
176
|
+
formatUsageLine(withResets, { showResetTimes: false, showBankedResets: false }, NOW),
|
|
177
|
+
"Usage: 5h: 90%",
|
|
178
|
+
);
|
|
179
|
+
assert.equal(formatBankedResetsSuffix(1), "1 banked reset");
|
|
180
|
+
assert.equal(formatBankedResetsSuffix(0), null);
|
|
181
|
+
assert.equal(formatBankedResetsSuffix(-2), null);
|
|
182
|
+
assert.equal(formatBankedResetsSuffix(null), null);
|
|
183
|
+
assert.equal(formatUsageLine(snapshotWith({ rolling: 10 }), { showResetTimes: false }, NOW), "Usage: 5h: 90%");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("colours the line by how much budget is left", () => {
|
|
187
|
+
assert.equal(severityForWindow(snapshotWith({ rolling: 69 }).windows[0]!), "ok");
|
|
188
|
+
assert.equal(severityForWindow(snapshotWith({ rolling: 70 }).windows[0]!), "warning");
|
|
189
|
+
assert.equal(severityForWindow(snapshotWith({ rolling: 90 }).windows[0]!), "critical");
|
|
190
|
+
assert.equal(
|
|
191
|
+
severityForWindow(snapshotWith({ rolling: 1 }, { rolling: "rate-limited" }).windows[0]!),
|
|
192
|
+
"critical",
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("splits the widget line into severity-tagged segments", () => {
|
|
197
|
+
const segments = usageSegments(snapshotWith({ rolling: 37, weekly: 70 }), { showResetTimes: false }, NOW);
|
|
198
|
+
assert.deepEqual(segments, [
|
|
199
|
+
{ text: "Usage: ", severity: "muted" },
|
|
200
|
+
{ text: "5h: ", severity: "muted" },
|
|
201
|
+
{ text: "63%", severity: "ok" },
|
|
202
|
+
{ text: " · ", severity: "muted" },
|
|
203
|
+
{ text: "7d: ", severity: "muted" },
|
|
204
|
+
{ text: "30%", severity: "warning" },
|
|
205
|
+
]);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("formats countdowns, percentages and reset clocks", () => {
|
|
209
|
+
assert.equal(formatCountdown(0), "now");
|
|
210
|
+
assert.equal(formatCountdown(45_000), "45s");
|
|
211
|
+
assert.equal(formatCountdown(5 * 60_000), "5m");
|
|
212
|
+
assert.equal(formatCountdown(2 * HOUR + 14 * 60_000), "2h14m");
|
|
213
|
+
assert.equal(formatCountdown(5 * DAY + 3 * HOUR), "5d3h");
|
|
214
|
+
assert.equal(formatCountdown(-1000), "now");
|
|
215
|
+
assert.equal(formatPercent(63.6), "64%");
|
|
216
|
+
assert.equal(formatPercent(120), "100%");
|
|
217
|
+
|
|
218
|
+
const soon = NOW + 2 * HOUR + 14 * 60_000;
|
|
219
|
+
assert.match(formatResetClock(soon, { includeDate: false }, NOW) ?? "", CLOCK);
|
|
220
|
+
const far = NOW + 5 * DAY + 3 * HOUR;
|
|
221
|
+
assert.match(formatResetClock(far, { includeDate: true }, NOW) ?? "", DATE_CLOCK);
|
|
222
|
+
assert.equal(formatResetClock(far, { includeDate: true }, NOW)?.includes("/"), true);
|
|
223
|
+
assert.equal(formatResetClock(Number.NaN, undefined, NOW), null);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("renders the detailed breakdown with remaining bars", () => {
|
|
227
|
+
const snapshot = parseUsageSnapshot(
|
|
228
|
+
payloadWith({ rolling: { status: "ok", percent: 50, resetsAt: new Date(NOW + HOUR).toISOString() } }),
|
|
229
|
+
NOW,
|
|
230
|
+
);
|
|
231
|
+
assert.ok(snapshot);
|
|
232
|
+
const [line] = formatUsageDetail(snapshot, NOW);
|
|
233
|
+
assert.ok(line);
|
|
234
|
+
assert.match(line, /^5h █{10}░{10} 50% left ↺ 1h0m - (?:\S+ )?\d{1,2}:\d{2}(?:\s?[AP]M)?$/);
|
|
235
|
+
|
|
236
|
+
const limited = snapshotWith({ monthly: 100 }, { monthly: "rate-limited" });
|
|
237
|
+
const [limitedLine] = formatUsageDetail(limited, NOW);
|
|
238
|
+
assert.ok(limitedLine);
|
|
239
|
+
assert.match(limitedLine, /^30d ░{20} 0% left {2}RATE LIMITED$/);
|
|
240
|
+
assert.equal(formatBar(0), "░".repeat(20));
|
|
241
|
+
assert.equal(formatBar(100), "█".repeat(20));
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("turns HTTP failures into actionable messages", () => {
|
|
245
|
+
const auth = { error: { type: "AuthError", message: "Missing API key." } };
|
|
246
|
+
assert.equal(
|
|
247
|
+
usageHttpErrorMessage(401, auth),
|
|
248
|
+
"OpenCode Go rejected the API key (401): Missing API key.",
|
|
249
|
+
);
|
|
250
|
+
assert.equal(
|
|
251
|
+
usageHttpErrorMessage(403, { error: { message: "OpenCode Go subscription required." } }),
|
|
252
|
+
"This key has no OpenCode Go subscription (403): OpenCode Go subscription required.",
|
|
253
|
+
);
|
|
254
|
+
assert.equal(usageHttpErrorMessage(500, null), "OpenCode Go usage request failed (500).");
|
|
255
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"noEmit": true,
|
|
8
|
+
"allowImportingTsExtensions": true,
|
|
9
|
+
"resolveJsonModule": true,
|
|
10
|
+
"erasableSyntaxOnly": true,
|
|
11
|
+
"isolatedModules": true,
|
|
12
|
+
"noFallthroughCasesInSwitch": true,
|
|
13
|
+
"noImplicitReturns": true,
|
|
14
|
+
"noUncheckedIndexedAccess": true,
|
|
15
|
+
"noUnusedLocals": true,
|
|
16
|
+
"noUnusedParameters": true,
|
|
17
|
+
"skipLibCheck": true,
|
|
18
|
+
"types": ["node"],
|
|
19
|
+
"verbatimModuleSyntax": true
|
|
20
|
+
},
|
|
21
|
+
"include": ["index.ts", "*.ts", "tests/**/*.ts"]
|
|
22
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lifecycle for the OpenCode Go usage widget.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the pi-better-openai usage controller: one snapshot per session,
|
|
5
|
+
* polled on a timer plus after each turn, pushed into the extension widget
|
|
6
|
+
* (favoured) or the footer status line when no terminal UI is available.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { PROVIDER_ID, configPath, type UsageConfig } from "./config.ts";
|
|
11
|
+
import {
|
|
12
|
+
USAGE_LIMITS_NOTE,
|
|
13
|
+
USAGE_URL,
|
|
14
|
+
formatUsageDetail,
|
|
15
|
+
formatUsageLine,
|
|
16
|
+
parseUsageSnapshot,
|
|
17
|
+
usageHttpErrorMessage,
|
|
18
|
+
type UsageSnapshot,
|
|
19
|
+
} from "./usage.ts";
|
|
20
|
+
|
|
21
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
22
|
+
const STALE_CONTEXT_MESSAGE = "This extension ctx is stale";
|
|
23
|
+
const MISSING_KEY_MESSAGE =
|
|
24
|
+
"no opencode-go API key. Set OPENCODE_API_KEY or add \"opencode-go\" to ~/.pi/agent/auth.json.";
|
|
25
|
+
|
|
26
|
+
type UsageRefreshOptions = { notify?: boolean; force?: boolean };
|
|
27
|
+
|
|
28
|
+
type QueuedRefresh = {
|
|
29
|
+
ctx: ExtensionContext;
|
|
30
|
+
generation: number;
|
|
31
|
+
notify?: boolean;
|
|
32
|
+
force?: boolean;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function isStaleContextError(error: unknown): boolean {
|
|
36
|
+
return error instanceof Error && error.message.includes(STALE_CONTEXT_MESSAGE);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class UsageController {
|
|
40
|
+
private usageSnapshot: UsageSnapshot | undefined;
|
|
41
|
+
private usageUpdatedAt: number | undefined;
|
|
42
|
+
private usageError: string | undefined;
|
|
43
|
+
private usageLastFetchAt: number | undefined;
|
|
44
|
+
private usageTimer: ReturnType<typeof setInterval> | undefined;
|
|
45
|
+
private usageRefreshInFlight = false;
|
|
46
|
+
private queuedRefresh: QueuedRefresh | undefined;
|
|
47
|
+
private shuttingDown = false;
|
|
48
|
+
private usageAbortController: AbortController | undefined;
|
|
49
|
+
private generation = 0;
|
|
50
|
+
private readonly getConfig: () => UsageConfig;
|
|
51
|
+
private readonly onUpdate: (ctx: ExtensionContext) => void;
|
|
52
|
+
|
|
53
|
+
constructor(getConfig: () => UsageConfig, onUpdate: (ctx: ExtensionContext) => void) {
|
|
54
|
+
this.getConfig = getConfig;
|
|
55
|
+
this.onUpdate = onUpdate;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
get snapshot(): UsageSnapshot | undefined {
|
|
59
|
+
return this.usageSnapshot;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get lastError(): string | undefined {
|
|
63
|
+
return this.usageError;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** True when the last poll failed but an older snapshot is still on screen. */
|
|
67
|
+
isStale(): boolean {
|
|
68
|
+
return this.usageError !== undefined && this.usageSnapshot !== undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
isEligible(ctx: ExtensionContext): boolean {
|
|
72
|
+
try {
|
|
73
|
+
return !this.getConfig().showOnlyOnProvider || ctx.model?.provider === PROVIDER_ID;
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** One-line widget/status text, or undefined when nothing should be shown. */
|
|
80
|
+
line(ctx: ExtensionContext, config = this.getConfig()): string | undefined {
|
|
81
|
+
if (!config.enabled || !this.usageSnapshot || !this.isEligible(ctx)) return undefined;
|
|
82
|
+
const line = formatUsageLine(this.usageSnapshot, { showResetTimes: config.showResetTimes });
|
|
83
|
+
return this.isStale() ? `${line} · stale` : line;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
formatStatus(ctx: ExtensionContext): string {
|
|
87
|
+
const config = this.getConfig();
|
|
88
|
+
if (!config.enabled) {
|
|
89
|
+
return "OpenCode Go usage display is disabled. Re-enable it with /opencode-go-usage on.";
|
|
90
|
+
}
|
|
91
|
+
if (!this.isEligible(ctx)) {
|
|
92
|
+
return "OpenCode Go usage is hidden: the selected model is not from the opencode-go provider.";
|
|
93
|
+
}
|
|
94
|
+
if (this.usageError) return `OpenCode Go usage unavailable: ${this.usageError}`;
|
|
95
|
+
if (!this.usageSnapshot) return "OpenCode Go usage unavailable: nothing fetched yet.";
|
|
96
|
+
const lines = [
|
|
97
|
+
`OpenCode Go usage · ${USAGE_LIMITS_NOTE}`,
|
|
98
|
+
...formatUsageDetail(this.usageSnapshot),
|
|
99
|
+
];
|
|
100
|
+
if (this.usageSnapshot.isLimited) {
|
|
101
|
+
lines.push("A window is rate limited; requests may be rejected until it resets.");
|
|
102
|
+
}
|
|
103
|
+
if (this.usageUpdatedAt !== undefined) {
|
|
104
|
+
lines.push(`Updated ${new Date(this.usageUpdatedAt).toLocaleTimeString()}`);
|
|
105
|
+
}
|
|
106
|
+
return lines.join("\n");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
formatDebug(ctx: ExtensionContext): string {
|
|
110
|
+
const config = this.getConfig();
|
|
111
|
+
let provider = "unknown";
|
|
112
|
+
try {
|
|
113
|
+
provider = ctx.model?.provider ?? "none";
|
|
114
|
+
} catch {
|
|
115
|
+
provider = "stale ctx";
|
|
116
|
+
}
|
|
117
|
+
return [
|
|
118
|
+
`Usage enabled: ${config.enabled}`,
|
|
119
|
+
`Usage placement: ${config.placement}`,
|
|
120
|
+
`Refresh interval: ${config.refreshIntervalMs}ms`,
|
|
121
|
+
`Show only on opencode-go models: ${config.showOnlyOnProvider}`,
|
|
122
|
+
`Show reset times: ${config.showResetTimes}`,
|
|
123
|
+
`Current model provider: ${provider}`,
|
|
124
|
+
`Last fetch: ${this.usageLastFetchAt ? new Date(this.usageLastFetchAt).toLocaleTimeString() : "never"}`,
|
|
125
|
+
`Last success: ${this.usageUpdatedAt ? new Date(this.usageUpdatedAt).toLocaleTimeString() : "never"}`,
|
|
126
|
+
`Last error: ${this.usageError ?? "none"}`,
|
|
127
|
+
`Windows: ${this.usageSnapshot?.windows.map((window) => `${window.label}=${window.remainingPercent}% left`).join(" ") ?? "none"}`,
|
|
128
|
+
`Endpoint: ${USAGE_URL}`,
|
|
129
|
+
`Config file: ${configPath()}`,
|
|
130
|
+
].join("\n");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private isCurrent(generation: number): boolean {
|
|
134
|
+
return !this.shuttingDown && generation === this.generation;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private deactivate(generation: number): void {
|
|
138
|
+
if (generation !== this.generation) return;
|
|
139
|
+
this.shuttingDown = true;
|
|
140
|
+
this.generation++;
|
|
141
|
+
this.queuedRefresh = undefined;
|
|
142
|
+
this.usageAbortController?.abort();
|
|
143
|
+
this.usageAbortController = undefined;
|
|
144
|
+
this.stopTimer();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private notify(
|
|
148
|
+
ctx: ExtensionContext,
|
|
149
|
+
message: string,
|
|
150
|
+
level: "info" | "warning" | "error",
|
|
151
|
+
): void {
|
|
152
|
+
try {
|
|
153
|
+
ctx.ui.notify(message, level);
|
|
154
|
+
} catch {
|
|
155
|
+
// Notifications are best-effort: a stale context must not surface as an error.
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private async resolveApiKey(ctx: ExtensionContext): Promise<string | undefined> {
|
|
160
|
+
let key: string | undefined;
|
|
161
|
+
try {
|
|
162
|
+
key = await ctx.modelRegistry.getApiKeyForProvider(PROVIDER_ID);
|
|
163
|
+
} catch {
|
|
164
|
+
key = undefined;
|
|
165
|
+
}
|
|
166
|
+
if (key?.trim()) return key.trim();
|
|
167
|
+
const envKey = process.env.OPENCODE_API_KEY?.trim();
|
|
168
|
+
return envKey ? envKey : undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private fail(ctx: ExtensionContext, message: string, options?: UsageRefreshOptions): void {
|
|
172
|
+
this.usageError = message;
|
|
173
|
+
this.onUpdate(ctx);
|
|
174
|
+
if (options?.notify) this.notify(ctx, this.formatStatus(ctx), "warning");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async refresh(
|
|
178
|
+
ctx: ExtensionContext,
|
|
179
|
+
options?: UsageRefreshOptions,
|
|
180
|
+
generation = this.generation,
|
|
181
|
+
): Promise<void> {
|
|
182
|
+
if (!this.isCurrent(generation)) return;
|
|
183
|
+
|
|
184
|
+
const config = this.getConfig();
|
|
185
|
+
if (!config.enabled) {
|
|
186
|
+
this.usageSnapshot = undefined;
|
|
187
|
+
this.usageUpdatedAt = undefined;
|
|
188
|
+
this.usageError = "usage display is disabled.";
|
|
189
|
+
this.onUpdate(ctx);
|
|
190
|
+
if (options?.notify) this.notify(ctx, this.formatStatus(ctx), "warning");
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let eligible: boolean;
|
|
195
|
+
try {
|
|
196
|
+
eligible = this.isEligible(ctx);
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (isStaleContextError(error)) this.deactivate(generation);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (!eligible) {
|
|
202
|
+
// Keep the snapshot cached so switching back to opencode-go is instant.
|
|
203
|
+
this.onUpdate(ctx);
|
|
204
|
+
if (options?.notify) this.notify(ctx, this.formatStatus(ctx), "warning");
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (this.usageRefreshInFlight) {
|
|
209
|
+
const queued = this.queuedRefresh?.generation === generation ? this.queuedRefresh : undefined;
|
|
210
|
+
this.queuedRefresh = {
|
|
211
|
+
ctx,
|
|
212
|
+
generation,
|
|
213
|
+
notify: queued?.notify || options?.notify,
|
|
214
|
+
force: queued?.force || options?.force,
|
|
215
|
+
};
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const shouldThrottle =
|
|
220
|
+
!options?.force &&
|
|
221
|
+
!options?.notify &&
|
|
222
|
+
this.usageLastFetchAt !== undefined &&
|
|
223
|
+
Date.now() - this.usageLastFetchAt < config.refreshIntervalMs;
|
|
224
|
+
if (shouldThrottle) {
|
|
225
|
+
this.onUpdate(ctx);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
this.usageRefreshInFlight = true;
|
|
230
|
+
try {
|
|
231
|
+
const apiKey = await this.resolveApiKey(ctx);
|
|
232
|
+
if (!this.isCurrent(generation)) return;
|
|
233
|
+
if (!apiKey) {
|
|
234
|
+
this.usageSnapshot = undefined;
|
|
235
|
+
this.usageUpdatedAt = undefined;
|
|
236
|
+
this.fail(ctx, MISSING_KEY_MESSAGE, options);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
this.usageLastFetchAt = Date.now();
|
|
241
|
+
this.usageAbortController = new AbortController();
|
|
242
|
+
const response = await fetch(USAGE_URL, {
|
|
243
|
+
headers: { accept: "application/json", authorization: `Bearer ${apiKey}` },
|
|
244
|
+
signal: AbortSignal.any([
|
|
245
|
+
AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
246
|
+
this.usageAbortController.signal,
|
|
247
|
+
]),
|
|
248
|
+
});
|
|
249
|
+
if (!this.isCurrent(generation)) return;
|
|
250
|
+
if (!response.ok) {
|
|
251
|
+
let body: unknown;
|
|
252
|
+
try {
|
|
253
|
+
body = await response.json();
|
|
254
|
+
} catch {
|
|
255
|
+
body = undefined;
|
|
256
|
+
}
|
|
257
|
+
throw new Error(usageHttpErrorMessage(response.status, body));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const payload: unknown = await response.json();
|
|
261
|
+
if (!this.isCurrent(generation)) return;
|
|
262
|
+
const snapshot = parseUsageSnapshot(payload);
|
|
263
|
+
if (!snapshot) throw new Error("unrecognized usage payload from OpenCode Go.");
|
|
264
|
+
|
|
265
|
+
this.usageSnapshot = snapshot;
|
|
266
|
+
this.usageUpdatedAt = Date.now();
|
|
267
|
+
this.usageError = undefined;
|
|
268
|
+
this.onUpdate(ctx);
|
|
269
|
+
if (options?.notify) this.notify(ctx, this.formatStatus(ctx), "info");
|
|
270
|
+
} catch (error) {
|
|
271
|
+
if (!this.isCurrent(generation)) return;
|
|
272
|
+
if (isStaleContextError(error)) {
|
|
273
|
+
this.deactivate(generation);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
277
|
+
this.fail(ctx, message, options);
|
|
278
|
+
} finally {
|
|
279
|
+
this.usageAbortController = undefined;
|
|
280
|
+
this.usageRefreshInFlight = false;
|
|
281
|
+
const next = this.queuedRefresh;
|
|
282
|
+
this.queuedRefresh = undefined;
|
|
283
|
+
if (next && !this.shuttingDown && next.generation === this.generation) {
|
|
284
|
+
void this.refresh(next.ctx, { notify: next.notify, force: next.force }, next.generation);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private stopTimer(): void {
|
|
290
|
+
if (this.usageTimer) clearInterval(this.usageTimer);
|
|
291
|
+
this.usageTimer = undefined;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
start(ctx: ExtensionContext): void {
|
|
295
|
+
this.usageAbortController?.abort();
|
|
296
|
+
this.queuedRefresh = undefined;
|
|
297
|
+
this.stopTimer();
|
|
298
|
+
const generation = ++this.generation;
|
|
299
|
+
this.shuttingDown = false;
|
|
300
|
+
|
|
301
|
+
if (!this.getConfig().enabled) return;
|
|
302
|
+
void this.refresh(ctx, { force: true }, generation);
|
|
303
|
+
this.usageTimer = setInterval(() => {
|
|
304
|
+
if (!this.isCurrent(generation)) return;
|
|
305
|
+
void this.refresh(ctx, undefined, generation);
|
|
306
|
+
}, this.getConfig().refreshIntervalMs);
|
|
307
|
+
this.usageTimer.unref?.();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Stop polling and drop the cached snapshot (used when the widget is turned off). */
|
|
311
|
+
stop(): void {
|
|
312
|
+
this.usageAbortController?.abort();
|
|
313
|
+
this.usageAbortController = undefined;
|
|
314
|
+
this.queuedRefresh = undefined;
|
|
315
|
+
this.stopTimer();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
clear(): void {
|
|
319
|
+
this.usageSnapshot = undefined;
|
|
320
|
+
this.usageUpdatedAt = undefined;
|
|
321
|
+
this.usageError = undefined;
|
|
322
|
+
this.usageLastFetchAt = undefined;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
shutdown(): void {
|
|
326
|
+
this.deactivate(this.generation);
|
|
327
|
+
}
|
|
328
|
+
}
|