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 +12 -1
- package/index.ts +40 -0
- package/multiprovider.ts +74 -0
- package/package.json +1 -1
- package/tests/multiprovider.test.ts +132 -0
- package/usage-controller.ts +21 -0
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
|
}
|
package/multiprovider.ts
ADDED
|
@@ -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
|
@@ -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
|
+
});
|
package/usage-controller.ts
CHANGED
|
@@ -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);
|