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.
- package/README.md +63 -1
- package/config.ts +127 -0
- package/format.ts +84 -0
- package/index.ts +153 -1
- package/multiprovider.ts +74 -0
- package/package.json +13 -9
- package/tests/format.test.ts +35 -0
- package/tests/multiprovider.test.ts +132 -0
- package/tests/usage.test.ts +255 -0
- package/tsconfig.json +22 -0
- package/usage-controller.ts +349 -0
- package/usage.ts +341 -0
- package/bun.lock +0 -335
package/README.md
CHANGED
|
@@ -21,6 +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). 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.
|
|
24
25
|
|
|
25
26
|
## Installation
|
|
26
27
|
|
|
@@ -110,6 +111,65 @@ Then select "opencode-go" as the provider and choose from the available models.
|
|
|
110
111
|
|
|
111
112
|
The default model for this provider is `kimi-k2.6` (matching pi core's built-in default); use `/model` to pick another.
|
|
112
113
|
|
|
114
|
+
## Usage Widget
|
|
115
|
+
|
|
116
|
+
OpenCode Go meters the plan with three dollar budgets — a rolling 5-hour window,
|
|
117
|
+
a weekly window and a monthly window. The extension polls
|
|
118
|
+
`GET https://opencode.ai/zen/go/v1/usage` with your API key and shows how much of
|
|
119
|
+
each budget is **left**, below the editor. When no terminal UI is attached (print
|
|
120
|
+
or JSON mode) the same line goes to the footer status bar instead.
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
Usage: 5h: 63% · 7d: 41% · 30d: 12% · 5h ↺ 2h14m · 7d ↺ 3d20h · 30d ↺ 20d0h
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The line matches the pi-better-openai usage line: the remaining percentages come
|
|
127
|
+
first, then a countdown per window. Countdowns only — three windows with three
|
|
128
|
+
wall-clock reset times run past the terminal width, and `/opencode-go-usage`
|
|
129
|
+
lists the exact local reset time for each. Colours track what is left — green,
|
|
130
|
+
amber at 30% or less, red at 10% or less or when a window reports
|
|
131
|
+
`rate-limited` — and the widget only appears while an `opencode-go` model is
|
|
132
|
+
selected. The Go API publishes no banked reset credits,
|
|
133
|
+
so that trailing segment appears only if a response ever carries a
|
|
134
|
+
`bankedResets` count.
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
/opencode-go-usage # refresh and show the full breakdown
|
|
138
|
+
/opencode-go-usage refresh # same, but always re-reads the API
|
|
139
|
+
/opencode-go-usage off # hide the widget (persisted)
|
|
140
|
+
/opencode-go-usage on # show it again
|
|
141
|
+
/opencode-go-usage debug # config, last fetch/error, endpoint
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Polling runs every 60 seconds, plus after every turn and whenever the selected
|
|
145
|
+
model changes. Settings are read from `~/.pi/agent/opencode-go-provider.json`:
|
|
146
|
+
|
|
147
|
+
```json
|
|
148
|
+
{
|
|
149
|
+
"usage": {
|
|
150
|
+
"enabled": true,
|
|
151
|
+
"refreshIntervalMs": 60000,
|
|
152
|
+
"showOnlyOnProvider": true,
|
|
153
|
+
"showResetTimes": true,
|
|
154
|
+
"placement": "belowEditor"
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
`placement` accepts `belowEditor` (default) or `aboveEditor`. Every key is
|
|
160
|
+
optional; `/opencode-go-usage on|off` writes only `enabled`.
|
|
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
|
+
|
|
113
173
|
## Authentication
|
|
114
174
|
|
|
115
175
|
The opencode-go API key can be configured in multiple ways. Credentials are resolved in this order:
|
|
@@ -126,7 +186,9 @@ The opencode-go API key can be configured in multiple ways. Credentials are reso
|
|
|
126
186
|
|
|
127
187
|
| Variable | Required | Description |
|
|
128
188
|
|----------|----------|-------------|
|
|
129
|
-
| `OPENCODE_API_KEY` | No | Your opencode.ai API key (fallback if not in auth.json) |
|
|
189
|
+
| `OPENCODE_API_KEY` | No | Your opencode.ai API key (fallback if not in auth.json). Also used for usage requests |
|
|
190
|
+
| `OPENCODE_GO_USAGE` | No | Set to `off`/`false`/`0` to disable the usage widget without editing the config file |
|
|
191
|
+
| `OPENCODE_GO_USAGE_INTERVAL_MS` | No | Override the usage poll interval (clamped to 15s–10m) |
|
|
130
192
|
|
|
131
193
|
## Configuration
|
|
132
194
|
|
package/config.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent settings for the opencode-go provider extension.
|
|
3
|
+
*
|
|
4
|
+
* The file is deliberately tiny and optional: every field falls back to
|
|
5
|
+
* DEFAULT_USAGE_CONFIG, then an environment override wins. Written only by
|
|
6
|
+
* `/opencode-go-usage on|off` so the widget choice survives a restart.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
|
|
13
|
+
export const PROVIDER_ID = "opencode-go";
|
|
14
|
+
export const CONFIG_BASENAME = "opencode-go-provider.json";
|
|
15
|
+
/** Shared key so the widget and the status fallback never collide with other extensions. */
|
|
16
|
+
export const USAGE_WIDGET_KEY = "opencode-go-usage";
|
|
17
|
+
|
|
18
|
+
export type UsagePlacement = "aboveEditor" | "belowEditor";
|
|
19
|
+
|
|
20
|
+
export interface UsageConfig {
|
|
21
|
+
/** Show the usage widget/status at all. */
|
|
22
|
+
enabled: boolean;
|
|
23
|
+
/** Poll interval while a session is open. */
|
|
24
|
+
refreshIntervalMs: number;
|
|
25
|
+
/** Only display usage while the selected model belongs to opencode-go. */
|
|
26
|
+
showOnlyOnProvider: boolean;
|
|
27
|
+
/** Include reset countdowns in the widget line. */
|
|
28
|
+
showResetTimes: boolean;
|
|
29
|
+
placement: UsagePlacement;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const DEFAULT_USAGE_CONFIG: UsageConfig = {
|
|
33
|
+
enabled: true,
|
|
34
|
+
refreshIntervalMs: 60_000,
|
|
35
|
+
showOnlyOnProvider: true,
|
|
36
|
+
showResetTimes: true,
|
|
37
|
+
placement: "belowEditor",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const MIN_USAGE_REFRESH_MS = 15_000;
|
|
41
|
+
export const MAX_USAGE_REFRESH_MS = 10 * 60_000;
|
|
42
|
+
|
|
43
|
+
const USAGE_PLACEMENTS: UsagePlacement[] = ["aboveEditor", "belowEditor"];
|
|
44
|
+
const DISABLED_VALUES = new Set(["0", "false", "off", "no", "disable", "disabled"]);
|
|
45
|
+
const ENABLED_VALUES = new Set(["1", "true", "on", "yes", "enable", "enabled"]);
|
|
46
|
+
|
|
47
|
+
export function configPath(): string {
|
|
48
|
+
return path.join(getAgentDir(), CONFIG_BASENAME);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function clampRefreshInterval(milliseconds: number): number {
|
|
52
|
+
if (!Number.isFinite(milliseconds)) return DEFAULT_USAGE_CONFIG.refreshIntervalMs;
|
|
53
|
+
return Math.max(MIN_USAGE_REFRESH_MS, Math.min(MAX_USAGE_REFRESH_MS, Math.round(milliseconds)));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseFlag(value: string | undefined): boolean | undefined {
|
|
57
|
+
const normalized = value?.trim().toLowerCase();
|
|
58
|
+
if (!normalized) return undefined;
|
|
59
|
+
if (DISABLED_VALUES.has(normalized)) return false;
|
|
60
|
+
if (ENABLED_VALUES.has(normalized)) return true;
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readFileUsage(): Record<string, unknown> {
|
|
65
|
+
try {
|
|
66
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(configPath(), "utf8"));
|
|
67
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
68
|
+
const usage = (parsed as Record<string, unknown>).usage;
|
|
69
|
+
if (usage && typeof usage === "object" && !Array.isArray(usage)) {
|
|
70
|
+
return usage as Record<string, unknown>;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
// Missing or unreadable config is the normal first-run state.
|
|
75
|
+
}
|
|
76
|
+
return {};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Resolve the effective usage config: defaults <- config file <- environment.
|
|
81
|
+
* Never throws; a broken config file degrades to defaults.
|
|
82
|
+
*/
|
|
83
|
+
export function readUsageConfig(env: NodeJS.ProcessEnv = process.env): UsageConfig {
|
|
84
|
+
const config = { ...DEFAULT_USAGE_CONFIG };
|
|
85
|
+
const file = readFileUsage();
|
|
86
|
+
|
|
87
|
+
if (typeof file.enabled === "boolean") config.enabled = file.enabled;
|
|
88
|
+
if (typeof file.showOnlyOnProvider === "boolean") config.showOnlyOnProvider = file.showOnlyOnProvider;
|
|
89
|
+
if (typeof file.showResetTimes === "boolean") config.showResetTimes = file.showResetTimes;
|
|
90
|
+
if (typeof file.refreshIntervalMs === "number") {
|
|
91
|
+
config.refreshIntervalMs = clampRefreshInterval(file.refreshIntervalMs);
|
|
92
|
+
}
|
|
93
|
+
if (USAGE_PLACEMENTS.includes(file.placement as UsagePlacement)) {
|
|
94
|
+
config.placement = file.placement as UsagePlacement;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const envEnabled = parseFlag(env.OPENCODE_GO_USAGE);
|
|
98
|
+
if (envEnabled !== undefined) config.enabled = envEnabled;
|
|
99
|
+
const envInterval = env.OPENCODE_GO_USAGE_INTERVAL_MS
|
|
100
|
+
? Number(env.OPENCODE_GO_USAGE_INTERVAL_MS)
|
|
101
|
+
: undefined;
|
|
102
|
+
if (envInterval !== undefined) config.refreshIntervalMs = clampRefreshInterval(envInterval);
|
|
103
|
+
|
|
104
|
+
return config;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Merge a patch into the config file, preserving unrelated keys. */
|
|
108
|
+
export function writeUsageConfig(patch: Partial<UsageConfig>): boolean {
|
|
109
|
+
try {
|
|
110
|
+
const file = configPath();
|
|
111
|
+
let document: Record<string, unknown> = {};
|
|
112
|
+
try {
|
|
113
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
114
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
115
|
+
document = parsed as Record<string, unknown>;
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
document = {};
|
|
119
|
+
}
|
|
120
|
+
document.usage = { ...readFileUsage(), ...patch };
|
|
121
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
122
|
+
fs.writeFileSync(file, `${JSON.stringify(document, null, 2)}\n`);
|
|
123
|
+
return true;
|
|
124
|
+
} catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
package/format.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal text helpers for the usage widget.
|
|
3
|
+
*
|
|
4
|
+
* Kept dependency-free (no @earendil-works/pi-tui import) so the usage logic
|
|
5
|
+
* stays unit-testable with a bare Node test run and the extension keeps its
|
|
6
|
+
* single devDependency.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const ANSI_PATTERN = "\u001B\\[[0-?]*[ -/]*[@-~]";
|
|
10
|
+
const ANSI_SPLIT_REGEXP = new RegExp("(" + ANSI_PATTERN + ")");
|
|
11
|
+
const ANSI_TEST_REGEXP = new RegExp(ANSI_PATTERN);
|
|
12
|
+
|
|
13
|
+
export function stripAnsi(value: string): string {
|
|
14
|
+
return value.replace(new RegExp(ANSI_PATTERN, "g"), "");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Approximate terminal cell width of a single code point. */
|
|
18
|
+
function charWidth(char: string): number {
|
|
19
|
+
const code = char.codePointAt(0) ?? 0;
|
|
20
|
+
if (code === 0) return 0;
|
|
21
|
+
// Combining marks and zero-width joiners occupy no cells.
|
|
22
|
+
if (code >= 0x0300 && code <= 0x036f) return 0;
|
|
23
|
+
if (code === 0x200d || code === 0xfe0f) return 0;
|
|
24
|
+
if (
|
|
25
|
+
(code >= 0x1100 && code <= 0x115f) ||
|
|
26
|
+
(code >= 0x2e80 && code <= 0xa4cf) ||
|
|
27
|
+
(code >= 0xac00 && code <= 0xd7a3) ||
|
|
28
|
+
(code >= 0xf900 && code <= 0xfaff) ||
|
|
29
|
+
(code >= 0xfe30 && code <= 0xfe6f) ||
|
|
30
|
+
(code >= 0xff00 && code <= 0xff60) ||
|
|
31
|
+
(code >= 0xffe0 && code <= 0xffe6) ||
|
|
32
|
+
(code >= 0x1f300 && code <= 0x1faff) ||
|
|
33
|
+
(code >= 0x20000 && code <= 0x3fffd)
|
|
34
|
+
) {
|
|
35
|
+
return 2;
|
|
36
|
+
}
|
|
37
|
+
return 1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Visible width of a string, ignoring ANSI escape sequences. */
|
|
41
|
+
export function visibleWidth(value: string): number {
|
|
42
|
+
let width = 0;
|
|
43
|
+
for (const char of stripAnsi(value)) width += charWidth(char);
|
|
44
|
+
return width;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Slice a possibly-colored string to a terminal width, appending an ellipsis.
|
|
49
|
+
* ANSI sequences before the cut are preserved and a reset is emitted so the
|
|
50
|
+
* ellipsis (and anything after it) keeps the caller's own styling.
|
|
51
|
+
*/
|
|
52
|
+
export function truncateToWidth(value: string, width: number, ellipsis = "\u2026"): string {
|
|
53
|
+
if (width <= 0) return "";
|
|
54
|
+
if (visibleWidth(value) <= width) return value;
|
|
55
|
+
const budget = Math.max(0, width - visibleWidth(ellipsis));
|
|
56
|
+
const tokens = value.split(ANSI_SPLIT_REGEXP);
|
|
57
|
+
let result = "";
|
|
58
|
+
let used = 0;
|
|
59
|
+
let truncated = false;
|
|
60
|
+
for (const token of tokens) {
|
|
61
|
+
if (token === "") continue;
|
|
62
|
+
if (ANSI_TEST_REGEXP.test(token)) {
|
|
63
|
+
result += token;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
for (const char of token) {
|
|
67
|
+
const cellWidth = charWidth(char);
|
|
68
|
+
if (used + cellWidth > budget) {
|
|
69
|
+
truncated = true;
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
result += char;
|
|
73
|
+
used += cellWidth;
|
|
74
|
+
}
|
|
75
|
+
if (truncated) break;
|
|
76
|
+
}
|
|
77
|
+
const reset = ANSI_TEST_REGEXP.test(value) ? "\u001B[0m" : "";
|
|
78
|
+
return result + reset + ellipsis;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Collapse whitespace so a multi-segment line survives a one-line status bar. */
|
|
82
|
+
export function sanitizeStatusText(text: string): string {
|
|
83
|
+
return text.replace(/[ \r\n\t]+/g, " ").trim();
|
|
84
|
+
}
|
package/index.ts
CHANGED
|
@@ -22,7 +22,23 @@
|
|
|
22
22
|
* Then use /model to select from available models
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
getAgentDir,
|
|
27
|
+
type ExtensionAPI,
|
|
28
|
+
type ExtensionContext,
|
|
29
|
+
type ModelRegistry,
|
|
30
|
+
type ThemeColor,
|
|
31
|
+
} from "@earendil-works/pi-coding-agent";
|
|
32
|
+
import { USAGE_WIDGET_KEY, readUsageConfig, writeUsageConfig } from "./config.ts";
|
|
33
|
+
import { sanitizeStatusText, truncateToWidth } from "./format.ts";
|
|
34
|
+
import { usageSegments, type UsageSegment, type UsageSeverity } from "./usage.ts";
|
|
35
|
+
import { UsageController } from "./usage-controller.ts";
|
|
36
|
+
import {
|
|
37
|
+
isMultiproviderService,
|
|
38
|
+
MULTIPROVIDER_SERVICE_EVENT,
|
|
39
|
+
setActiveMultiproviderService,
|
|
40
|
+
type MultiproviderService,
|
|
41
|
+
} from "./multiprovider.ts";
|
|
26
42
|
import modelsData from "./models.json" with { type: "json" };
|
|
27
43
|
import customModelsData from "./custom-models.json" with { type: "json" };
|
|
28
44
|
import patchData from "./patch.json" with { type: "json" };
|
|
@@ -402,12 +418,134 @@ export default function (pi: ExtensionAPI) {
|
|
|
402
418
|
})),
|
|
403
419
|
});
|
|
404
420
|
|
|
421
|
+
// Usage widget (5h / 7d / 30d).
|
|
422
|
+
// opencode-go meters the Go plan with a rolling 5h window, a weekly window and
|
|
423
|
+
// a monthly window. GET /zen/go/v1/usage publishes all three. The widget below
|
|
424
|
+
// the editor mirrors pi-better-openai's usage widget; the footer status line is
|
|
425
|
+
// the fallback when no terminal UI is attached.
|
|
426
|
+
|
|
427
|
+
let usageConfig = readUsageConfig();
|
|
428
|
+
let usageWidgetInstalled = false;
|
|
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
|
+
}
|
|
458
|
+
|
|
459
|
+
const USAGE_SEVERITY_COLORS: Record<UsageSeverity, ThemeColor> = {
|
|
460
|
+
ok: "success",
|
|
461
|
+
warning: "warning",
|
|
462
|
+
critical: "error",
|
|
463
|
+
muted: "dim",
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
function usageSegmentsFor(ctx: ExtensionContext): UsageSegment[] | undefined {
|
|
467
|
+
const snapshot = usageController.snapshot;
|
|
468
|
+
if (!snapshot || !usageConfig.enabled || !usageController.isEligible(ctx)) return undefined;
|
|
469
|
+
const segments = usageSegments(snapshot, { showResetTimes: usageConfig.showResetTimes });
|
|
470
|
+
if (usageController.isStale()) segments.push({ text: " · stale", severity: "warning" });
|
|
471
|
+
return segments;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function updateUsageWidget(ctx: ExtensionContext): void {
|
|
475
|
+
try {
|
|
476
|
+
const segments = usageSegmentsFor(ctx);
|
|
477
|
+
if (!segments) {
|
|
478
|
+
if (usageWidgetInstalled) {
|
|
479
|
+
ctx.ui.setWidget(USAGE_WIDGET_KEY, undefined);
|
|
480
|
+
ctx.ui.setStatus(USAGE_WIDGET_KEY, undefined);
|
|
481
|
+
usageWidgetInstalled = false;
|
|
482
|
+
}
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
if (ctx.mode === "tui") {
|
|
486
|
+
ctx.ui.setStatus(USAGE_WIDGET_KEY, undefined);
|
|
487
|
+
ctx.ui.setWidget(
|
|
488
|
+
USAGE_WIDGET_KEY,
|
|
489
|
+
(_tui, theme) => ({
|
|
490
|
+
invalidate() {},
|
|
491
|
+
render(width: number): string[] {
|
|
492
|
+
// Segments are captured per install, keeping render() free of the
|
|
493
|
+
// extension context so a stale ctx can never be touched mid-draw.
|
|
494
|
+
const line = segments
|
|
495
|
+
.map((segment) => theme.fg(USAGE_SEVERITY_COLORS[segment.severity], segment.text))
|
|
496
|
+
.join("");
|
|
497
|
+
return [truncateToWidth(line, width, theme.fg("dim", "\u2026"))];
|
|
498
|
+
},
|
|
499
|
+
}),
|
|
500
|
+
{ placement: usageConfig.placement },
|
|
501
|
+
);
|
|
502
|
+
} else {
|
|
503
|
+
ctx.ui.setWidget(USAGE_WIDGET_KEY, undefined);
|
|
504
|
+
ctx.ui.setStatus(USAGE_WIDGET_KEY, sanitizeStatusText(segments.map((s) => s.text).join("")));
|
|
505
|
+
}
|
|
506
|
+
usageWidgetInstalled = true;
|
|
507
|
+
} catch {
|
|
508
|
+
// A stale extension context can surface here; the next session re-installs.
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
pi.registerCommand("opencode-go-usage", {
|
|
513
|
+
description: "Show, refresh, or toggle the OpenCode Go 5h / 7d / 30d usage widget",
|
|
514
|
+
handler: async (args, ctx) => {
|
|
515
|
+
const action = args.trim().toLowerCase();
|
|
516
|
+
if (action === "on" || action === "off") {
|
|
517
|
+
const enabled = action === "on";
|
|
518
|
+
usageConfig = { ...usageConfig, enabled };
|
|
519
|
+
const persisted = writeUsageConfig({ enabled });
|
|
520
|
+
const suffix = persisted ? "" : " for this session (could not write the config file)";
|
|
521
|
+
if (enabled) {
|
|
522
|
+
usageController.start(ctx);
|
|
523
|
+
ctx.ui.notify(`OpenCode Go usage widget enabled${suffix}.`, "info");
|
|
524
|
+
} else {
|
|
525
|
+
usageController.stop();
|
|
526
|
+
usageController.clear();
|
|
527
|
+
updateUsageWidget(ctx);
|
|
528
|
+
ctx.ui.notify(`OpenCode Go usage widget disabled${suffix}.`, "info");
|
|
529
|
+
}
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
if (action === "debug") {
|
|
533
|
+
ctx.ui.notify(usageController.formatDebug(ctx), "info");
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
// No argument (and "refresh") always re-reads the API and reports the breakdown.
|
|
537
|
+
await usageController.refresh(ctx, { notify: true, force: true });
|
|
538
|
+
},
|
|
539
|
+
});
|
|
540
|
+
|
|
405
541
|
pi.on("before_provider_headers", (event, ctx) => {
|
|
406
542
|
if (ctx.model?.provider !== PROVIDER_ID) return;
|
|
407
543
|
applyOpenCodeSessionHeaders(event.headers, ctx.sessionManager.getSessionId());
|
|
408
544
|
});
|
|
409
545
|
|
|
410
546
|
pi.on("session_start", async (_event, ctx) => {
|
|
547
|
+
usageContext = ctx;
|
|
548
|
+
usageController.start(ctx);
|
|
411
549
|
revalidateAbort?.abort();
|
|
412
550
|
revalidateAbort = new AbortController();
|
|
413
551
|
const signal = revalidateAbort.signal;
|
|
@@ -438,7 +576,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
438
576
|
});
|
|
439
577
|
});
|
|
440
578
|
|
|
579
|
+
pi.on("turn_end", (_event, ctx) => {
|
|
580
|
+
void usageController.refresh(ctx);
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
pi.on("model_select", (_event, ctx) => {
|
|
584
|
+
void usageController.refresh(ctx);
|
|
585
|
+
});
|
|
586
|
+
|
|
441
587
|
pi.on("session_shutdown", () => {
|
|
442
588
|
revalidateAbort?.abort();
|
|
589
|
+
usageContext = undefined;
|
|
590
|
+
unsubscribeMultiprovider?.();
|
|
591
|
+
unsubscribeMultiprovider = undefined;
|
|
592
|
+
multiproviderService = undefined;
|
|
593
|
+
setActiveMultiproviderService(undefined);
|
|
594
|
+
usageController.shutdown();
|
|
443
595
|
});
|
|
444
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
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-opencode-go-provider",
|
|
3
|
-
"version": "1.0
|
|
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",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"clean": "echo 'nothing to clean'",
|
|
9
|
+
"build": "echo 'nothing to build'",
|
|
10
|
+
"check": "npm run typecheck && npm run test",
|
|
11
|
+
"update-models": "node scripts/update-models.js",
|
|
12
|
+
"test": "node --test",
|
|
13
|
+
"typecheck": "tsc --noEmit"
|
|
14
|
+
},
|
|
7
15
|
"keywords": [
|
|
8
16
|
"pi",
|
|
9
17
|
"extension",
|
|
@@ -24,12 +32,8 @@
|
|
|
24
32
|
]
|
|
25
33
|
},
|
|
26
34
|
"devDependencies": {
|
|
27
|
-
"@earendil-works/pi-coding-agent": "0.85.1"
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
"clean": "echo 'nothing to clean'",
|
|
31
|
-
"build": "echo 'nothing to build'",
|
|
32
|
-
"check": "echo 'nothing to check'",
|
|
33
|
-
"update-models": "node scripts/update-models.js"
|
|
35
|
+
"@earendil-works/pi-coding-agent": "0.85.1",
|
|
36
|
+
"@types/node": "~22.19.0",
|
|
37
|
+
"typescript": "^6.0.3"
|
|
34
38
|
}
|
|
35
|
-
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { sanitizeStatusText, stripAnsi, truncateToWidth, visibleWidth } from "../format.ts";
|
|
4
|
+
|
|
5
|
+
const RED = "[31m";
|
|
6
|
+
const RESET = "[0m";
|
|
7
|
+
|
|
8
|
+
test("measures visible width without ANSI escapes", () => {
|
|
9
|
+
assert.equal(visibleWidth("abc"), 3);
|
|
10
|
+
assert.equal(visibleWidth(`${RED}abc${RESET}`), 3);
|
|
11
|
+
assert.equal(visibleWidth(""), 0);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("strips ANSI escapes", () => {
|
|
15
|
+
assert.equal(stripAnsi(`${RED}abc${RESET}`), "abc");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("truncates to a width with an ellipsis", () => {
|
|
19
|
+
assert.equal(truncateToWidth("abcdef", 10), "abcdef");
|
|
20
|
+
assert.equal(truncateToWidth("abcdef", 4), "abc…");
|
|
21
|
+
assert.equal(truncateToWidth("abcdef", 0), "");
|
|
22
|
+
assert.equal(truncateToWidth("abcdef", 4, "..."), "a...");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("truncation keeps colour codes and stays within the width", () => {
|
|
26
|
+
const colored = `${RED}abcdef${RESET}`;
|
|
27
|
+
const cut = truncateToWidth(colored, 4);
|
|
28
|
+
assert.equal(cut.includes(RED), true);
|
|
29
|
+
assert.equal(visibleWidth(cut), 4);
|
|
30
|
+
assert.equal(stripAnsi(cut), "abc…");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("collapses whitespace for the status bar", () => {
|
|
34
|
+
assert.equal(sanitizeStatusText(" a \n b \t c "), "a b c");
|
|
35
|
+
});
|