pi-opencode-go-provider 1.0.25 → 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.
package/README.md CHANGED
@@ -21,7 +21,7 @@ _Go-optimized endpoints for lower latency — 14+ models for [pi](https://github
21
21
  - **Cost Tracking** with per-model pricing for budget management
22
22
  - **Reasoning Models** with thinking level maps for proper effort control
23
23
  - **Prompt-cache session affinity** — sends `x-opencode-session` and `x-opencode-client` so OpenCode Go can pin a session to the same cache node
24
- - **Usage widget** — shows how much of the OpenCode Go 5h / 7d / 30d budgets you have spent, below the editor (footer status line outside the TUI)
24
+ - **Usage widget** — shows how much of the OpenCode Go 5h / 7d / 30d budgets you have spent, below the editor (footer status line outside the TUI). Each account carries its own budgets, so when [pi-multiprovider](https://github.com/monotykamary/pi-multiprovider) 0.8.0+ pools several `opencode-go` accounts the widget bills the session's active one and repaints on every switch or resume.
25
25
 
26
26
  ## Installation
27
27
 
@@ -159,6 +159,17 @@ model changes. Settings are read from `~/.pi/agent/opencode-go-provider.json`:
159
159
  `placement` accepts `belowEditor` (default) or `aboveEditor`. Every key is
160
160
  optional; `/opencode-go-usage on|off` writes only `enabled`.
161
161
 
162
+ ### Pooled accounts
163
+
164
+ When [pi-multiprovider](https://github.com/monotykamary/pi-multiprovider)
165
+ 0.8.0+ pools several `opencode-go` accounts, usage reads bill the session's
166
+ active account instead of Pi's default credential: every account has its own
167
+ 5h / 7d / 30d budgets. The widget also repaints when the account changes,
168
+ including when a resumed session restores the account last chosen with
169
+ `/switch-account`, rather than showing the previous account until the next
170
+ poll. Without pi-multiprovider nothing changes: the widget bills the key from
171
+ the resolution order above.
172
+
162
173
  ## Authentication
163
174
 
164
175
  The opencode-go API key can be configured in multiple ways. Credentials are resolved in this order:
package/index.ts CHANGED
@@ -33,6 +33,12 @@ import { USAGE_WIDGET_KEY, readUsageConfig, writeUsageConfig } from "./config.ts
33
33
  import { sanitizeStatusText, truncateToWidth } from "./format.ts";
34
34
  import { usageSegments, type UsageSegment, type UsageSeverity } from "./usage.ts";
35
35
  import { UsageController } from "./usage-controller.ts";
36
+ import {
37
+ isMultiproviderService,
38
+ MULTIPROVIDER_SERVICE_EVENT,
39
+ setActiveMultiproviderService,
40
+ type MultiproviderService,
41
+ } from "./multiprovider.ts";
36
42
  import modelsData from "./models.json" with { type: "json" };
37
43
  import customModelsData from "./custom-models.json" with { type: "json" };
38
44
  import patchData from "./patch.json" with { type: "json" };
@@ -421,6 +427,34 @@ export default function (pi: ExtensionAPI) {
421
427
  let usageConfig = readUsageConfig();
422
428
  let usageWidgetInstalled = false;
423
429
  const usageController = new UsageController(() => usageConfig, updateUsageWidget);
430
+ let usageContext: ExtensionContext | undefined;
431
+
432
+ // Follow pi-multiprovider's active pooled account. Usage is per-account, so a
433
+ // switch — and a resume, which replays the account the session last switched
434
+ // to — must repaint the widget instead of waiting for the next poll. Without
435
+ // pi-multiprovider nothing here activates and resolution stays unchanged.
436
+ let multiproviderService: MultiproviderService | undefined;
437
+ let unsubscribeMultiprovider: (() => void) | undefined;
438
+ const refreshUsageForActiveAccount = (ctx: ExtensionContext | undefined): void => {
439
+ if (ctx === undefined) return;
440
+ void usageController.refresh(ctx, { force: true });
441
+ };
442
+ if (typeof pi.events?.on === "function") {
443
+ pi.events.on(MULTIPROVIDER_SERVICE_EVENT, (value: unknown) => {
444
+ if (!isMultiproviderService(value)) return;
445
+ if (value !== multiproviderService) {
446
+ unsubscribeMultiprovider?.();
447
+ multiproviderService = value;
448
+ setActiveMultiproviderService(value);
449
+ unsubscribeMultiprovider = value.onActiveAccountChanged(PROVIDER_ID, (event) => {
450
+ refreshUsageForActiveAccount(usageContext ?? event.ctx);
451
+ });
452
+ }
453
+ // The event re-fires at every session start with the same stable object,
454
+ // so this also catches a service that appeared mid-session.
455
+ refreshUsageForActiveAccount(usageContext);
456
+ });
457
+ }
424
458
 
425
459
  const USAGE_SEVERITY_COLORS: Record<UsageSeverity, ThemeColor> = {
426
460
  ok: "success",
@@ -510,6 +544,7 @@ export default function (pi: ExtensionAPI) {
510
544
  });
511
545
 
512
546
  pi.on("session_start", async (_event, ctx) => {
547
+ usageContext = ctx;
513
548
  usageController.start(ctx);
514
549
  revalidateAbort?.abort();
515
550
  revalidateAbort = new AbortController();
@@ -551,6 +586,11 @@ export default function (pi: ExtensionAPI) {
551
586
 
552
587
  pi.on("session_shutdown", () => {
553
588
  revalidateAbort?.abort();
589
+ usageContext = undefined;
590
+ unsubscribeMultiprovider?.();
591
+ unsubscribeMultiprovider = undefined;
592
+ multiproviderService = undefined;
593
+ setActiveMultiproviderService(undefined);
554
594
  usageController.shutdown();
555
595
  });
556
596
  }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Soft bridge to pi-multiprovider.
3
+ *
4
+ * When pi-multiprovider pools several opencode-go accounts it announces a
5
+ * service on Pi's event bus and notifies followers whenever the session's
6
+ * active account changes — including a resume, which replays the account the
7
+ * session last switched to. Usage is account-scoped: each account has its own
8
+ * 5h / 7d / 30d budget, so the widget has to bill the active pooled account
9
+ * instead of whichever credential Pi resolves on its own. Without that
10
+ * extension everything here stays inert and resolution is unchanged.
11
+ */
12
+
13
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
14
+
15
+ export const MULTIPROVIDER_SERVICE_EVENT = "pi-multiprovider:service";
16
+
17
+ export type MultiproviderActiveAccount = {
18
+ id: string;
19
+ label: string;
20
+ authKind: string;
21
+ };
22
+
23
+ export type MultiproviderAccountAuth = {
24
+ accessToken: string;
25
+ label: string;
26
+ source?: string;
27
+ };
28
+
29
+ export type MultiproviderAccountChangedEvent = {
30
+ providerId: string;
31
+ account: MultiproviderActiveAccount | undefined;
32
+ ctx: ExtensionContext;
33
+ };
34
+
35
+ export type MultiproviderServiceContext = Pick<
36
+ ExtensionContext,
37
+ "modelRegistry" | "model" | "sessionManager"
38
+ >;
39
+
40
+ export type MultiproviderService = {
41
+ getActiveAccount(
42
+ providerId: string,
43
+ ctx: MultiproviderServiceContext,
44
+ ): Promise<MultiproviderActiveAccount | undefined>;
45
+ resolveActiveAccountAuth(
46
+ providerId: string,
47
+ ctx: MultiproviderServiceContext,
48
+ signal?: AbortSignal,
49
+ ): Promise<MultiproviderAccountAuth | undefined>;
50
+ onActiveAccountChanged(
51
+ providerId: string,
52
+ callback: (event: MultiproviderAccountChangedEvent) => void,
53
+ ): () => void;
54
+ };
55
+
56
+ export function isMultiproviderService(value: unknown): value is MultiproviderService {
57
+ if (typeof value !== "object" || value === null) return false;
58
+ const candidate = value as Partial<MultiproviderService>;
59
+ return (
60
+ typeof candidate.getActiveAccount === "function" &&
61
+ typeof candidate.resolveActiveAccountAuth === "function" &&
62
+ typeof candidate.onActiveAccountChanged === "function"
63
+ );
64
+ }
65
+
66
+ let activeService: MultiproviderService | undefined;
67
+
68
+ export function setActiveMultiproviderService(service: MultiproviderService | undefined): void {
69
+ activeService = service;
70
+ }
71
+
72
+ export function getActiveMultiproviderService(): MultiproviderService | undefined {
73
+ return activeService;
74
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-opencode-go-provider",
3
- "version": "1.0.25",
3
+ "version": "1.1.0",
4
4
  "description": "Opencode Go provider extension for pi - Fast, efficient GLM, Kimi, and MiniMax models through the opencode.ai API",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -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
+ });
@@ -8,6 +8,7 @@
8
8
 
9
9
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
10
10
  import { PROVIDER_ID, configPath, type UsageConfig } from "./config.ts";
11
+ import { getActiveMultiproviderService } from "./multiprovider.ts";
11
12
  import {
12
13
  USAGE_LIMITS_NOTE,
13
14
  USAGE_URL,
@@ -156,7 +157,27 @@ export class UsageController {
156
157
  }
157
158
  }
158
159
 
160
+ /**
161
+ * The session's active pooled account, when pi-multiprovider pools
162
+ * opencode-go. Every account carries its own budget, so a switched or
163
+ * restored account must bill itself instead of Pi's default credential.
164
+ */
165
+ private async resolvePooledApiKey(ctx: ExtensionContext): Promise<string | undefined> {
166
+ const service = getActiveMultiproviderService();
167
+ if (service === undefined) return undefined;
168
+ try {
169
+ const resolved = await service.resolveActiveAccountAuth(PROVIDER_ID, ctx);
170
+ const token = resolved?.accessToken.trim();
171
+ return token ? token : undefined;
172
+ } catch {
173
+ // A failing bridge must never block the default resolution below.
174
+ return undefined;
175
+ }
176
+ }
177
+
159
178
  private async resolveApiKey(ctx: ExtensionContext): Promise<string | undefined> {
179
+ const pooled = await this.resolvePooledApiKey(ctx);
180
+ if (pooled !== undefined) return pooled;
160
181
  let key: string | undefined;
161
182
  try {
162
183
  key = await ctx.modelRegistry.getApiKeyForProvider(PROVIDER_ID);