pi-opencode-go-provider 1.0.24 → 1.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.
@@ -0,0 +1,132 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { DEFAULT_USAGE_CONFIG, PROVIDER_ID } from "../config.ts";
5
+ import {
6
+ isMultiproviderService,
7
+ setActiveMultiproviderService,
8
+ type MultiproviderAccountAuth,
9
+ type MultiproviderService,
10
+ } from "../multiprovider.ts";
11
+ import { UsageController } from "../usage-controller.ts";
12
+
13
+ function fakeService(
14
+ resolve: () => Promise<MultiproviderAccountAuth | undefined>,
15
+ ): { value: MultiproviderService; resolveActiveAccountAuth: () => Promise<MultiproviderAccountAuth | undefined> } {
16
+ const resolveActiveAccountAuth = async () => resolve();
17
+ const value: MultiproviderService = {
18
+ getActiveAccount: async () => undefined,
19
+ resolveActiveAccountAuth,
20
+ onActiveAccountChanged: () => () => {},
21
+ };
22
+ return { value, resolveActiveAccountAuth };
23
+ }
24
+
25
+ function sessionContext(registryKey: string | undefined): ExtensionContext {
26
+ return {
27
+ model: { provider: PROVIDER_ID, id: "kimi-k3" },
28
+ hasUI: true,
29
+ signal: undefined,
30
+ ui: { notify: () => {} },
31
+ modelRegistry: { getApiKeyForProvider: async () => registryKey },
32
+ sessionManager: { getSessionId: () => "session-1" },
33
+ } as unknown as ExtensionContext;
34
+ }
35
+
36
+ function makeController(): UsageController {
37
+ return new UsageController(() => DEFAULT_USAGE_CONFIG, () => {});
38
+ }
39
+
40
+ /** Records the bearer token each usage request billed. */
41
+ function stubUsageFetch(): { headers: string[]; restore: () => void } {
42
+ const original = globalThis.fetch;
43
+ const headers: string[] = [];
44
+ globalThis.fetch = (async (_input: unknown, init?: RequestInit) => {
45
+ const requestHeaders = (init?.headers ?? {}) as Record<string, string>;
46
+ headers.push(String(requestHeaders.authorization ?? ""));
47
+ return new Response(
48
+ JSON.stringify({
49
+ usage: {
50
+ rolling: { status: "ok", percent: 10, resetsAt: null },
51
+ weekly: { status: "ok", percent: 20, resetsAt: null },
52
+ monthly: { status: "ok", percent: 30, resetsAt: null },
53
+ },
54
+ }),
55
+ );
56
+ }) as typeof fetch;
57
+ return {
58
+ headers,
59
+ restore() {
60
+ globalThis.fetch = original;
61
+ },
62
+ };
63
+ }
64
+
65
+ test("detects the pi-multiprovider service payload", () => {
66
+ assert.equal(isMultiproviderService(fakeService(async () => undefined).value), true);
67
+ assert.equal(isMultiproviderService(undefined), false);
68
+ assert.equal(isMultiproviderService({ getActiveAccount: () => {} }), false);
69
+ assert.equal(
70
+ isMultiproviderService({
71
+ getActiveAccount: () => {},
72
+ resolveActiveAccountAuth: () => {},
73
+ onActiveAccountChanged: null,
74
+ }),
75
+ false,
76
+ );
77
+ });
78
+
79
+ test("bills the session's pooled account when pi-multiprovider pins one", async () => {
80
+ const fetchStub = stubUsageFetch();
81
+ const service = fakeService(async () => ({ accessToken: "pooled-key", label: "Work" }));
82
+ setActiveMultiproviderService(service.value);
83
+ try {
84
+ const controller = makeController();
85
+ await controller.refresh(sessionContext("registry-key"), { force: true });
86
+ assert.deepEqual(fetchStub.headers, ["Bearer pooled-key"]);
87
+ assert.ok(controller.snapshot);
88
+ } finally {
89
+ setActiveMultiproviderService(undefined);
90
+ fetchStub.restore();
91
+ }
92
+ });
93
+
94
+ test("falls back to the registry key when the session has no pooled account", async () => {
95
+ const fetchStub = stubUsageFetch();
96
+ const service = fakeService(async () => undefined);
97
+ setActiveMultiproviderService(service.value);
98
+ try {
99
+ const controller = makeController();
100
+ await controller.refresh(sessionContext("registry-key"), { force: true });
101
+ assert.deepEqual(fetchStub.headers, ["Bearer registry-key"]);
102
+ } finally {
103
+ setActiveMultiproviderService(undefined);
104
+ fetchStub.restore();
105
+ }
106
+ });
107
+
108
+ test("falls back to the registry key when the bridge rejects", async () => {
109
+ const fetchStub = stubUsageFetch();
110
+ const service = fakeService(() => Promise.reject(new Error("store locked")));
111
+ setActiveMultiproviderService(service.value);
112
+ try {
113
+ const controller = makeController();
114
+ await controller.refresh(sessionContext("registry-key"), { force: true });
115
+ assert.deepEqual(fetchStub.headers, ["Bearer registry-key"]);
116
+ } finally {
117
+ setActiveMultiproviderService(undefined);
118
+ fetchStub.restore();
119
+ }
120
+ });
121
+
122
+ test("keeps its own resolution when pi-multiprovider is absent", async () => {
123
+ const fetchStub = stubUsageFetch();
124
+ setActiveMultiproviderService(undefined);
125
+ try {
126
+ const controller = makeController();
127
+ await controller.refresh(sessionContext("registry-key"), { force: true });
128
+ assert.deepEqual(fetchStub.headers, ["Bearer registry-key"]);
129
+ } finally {
130
+ fetchStub.restore();
131
+ }
132
+ });
@@ -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
+ }