pi-opencode-go-provider 1.0.25 → 1.1.1
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 +17 -2
- package/config.ts +7 -0
- package/glyphs.ts +57 -0
- package/index.ts +70 -7
- package/multiprovider.ts +74 -0
- package/package.json +1 -1
- package/tests/glyphs.test.ts +76 -0
- package/tests/multiprovider.test.ts +132 -0
- package/usage-controller.ts +21 -0
- package/usage.ts +16 -10
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
|
|
|
@@ -139,6 +139,7 @@ so that trailing segment appears only if a response ever carries a
|
|
|
139
139
|
/opencode-go-usage off # hide the widget (persisted)
|
|
140
140
|
/opencode-go-usage on # show it again
|
|
141
141
|
/opencode-go-usage debug # config, last fetch/error, endpoint
|
|
142
|
+
/opencode-go-usage glyphs auto|unicode|ascii # footer glyph set (persisted)
|
|
142
143
|
```
|
|
143
144
|
|
|
144
145
|
Polling runs every 60 seconds, plus after every turn and whenever the selected
|
|
@@ -151,7 +152,8 @@ model changes. Settings are read from `~/.pi/agent/opencode-go-provider.json`:
|
|
|
151
152
|
"refreshIntervalMs": 60000,
|
|
152
153
|
"showOnlyOnProvider": true,
|
|
153
154
|
"showResetTimes": true,
|
|
154
|
-
"placement": "belowEditor"
|
|
155
|
+
"placement": "belowEditor",
|
|
156
|
+
"glyphs": "auto"
|
|
155
157
|
}
|
|
156
158
|
}
|
|
157
159
|
```
|
|
@@ -159,6 +161,19 @@ model changes. Settings are read from `~/.pi/agent/opencode-go-provider.json`:
|
|
|
159
161
|
`placement` accepts `belowEditor` (default) or `aboveEditor`. Every key is
|
|
160
162
|
optional; `/opencode-go-usage on|off` writes only `enabled`.
|
|
161
163
|
|
|
164
|
+
`glyphs` accepts `auto` (default), `unicode`, or `ascii`. Older mintty/Cygwin builds measure ambiguous-width codepoints (the `·` separators, the `↺` reset marker) with their own cell-width tables, which can shift the row and desync pi’s renderer. `auto` switches the widget to ASCII equivalents on those terminals and the widget never paints the terminal’s last column; an explicit `unicode` is clamped to ASCII for widget content there (the status line is not row-budgeted and keeps the choice).
|
|
165
|
+
|
|
166
|
+
### Pooled accounts
|
|
167
|
+
|
|
168
|
+
When [pi-multiprovider](https://github.com/monotykamary/pi-multiprovider)
|
|
169
|
+
0.8.0+ pools several `opencode-go` accounts, usage reads bill the session's
|
|
170
|
+
active account instead of Pi's default credential: every account has its own
|
|
171
|
+
5h / 7d / 30d budgets. The widget also repaints when the account changes,
|
|
172
|
+
including when a resumed session restores the account last chosen with
|
|
173
|
+
`/switch-account`, rather than showing the previous account until the next
|
|
174
|
+
poll. Without pi-multiprovider nothing changes: the widget bills the key from
|
|
175
|
+
the resolution order above.
|
|
176
|
+
|
|
162
177
|
## Authentication
|
|
163
178
|
|
|
164
179
|
The opencode-go API key can be configured in multiple ways. Credentials are resolved in this order:
|
package/config.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import fs from "node:fs";
|
|
10
10
|
import path from "node:path";
|
|
11
11
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { GLYPH_MODES, type GlyphMode } from "./glyphs.ts";
|
|
12
13
|
|
|
13
14
|
export const PROVIDER_ID = "opencode-go";
|
|
14
15
|
export const CONFIG_BASENAME = "opencode-go-provider.json";
|
|
@@ -26,6 +27,8 @@ export interface UsageConfig {
|
|
|
26
27
|
showOnlyOnProvider: boolean;
|
|
27
28
|
/** Include reset countdowns in the widget line. */
|
|
28
29
|
showResetTimes: boolean;
|
|
30
|
+
/** Footer glyph set. "auto" degrades to ASCII on legacy terminals (mintty/Cygwin). */
|
|
31
|
+
glyphs: GlyphMode;
|
|
29
32
|
placement: UsagePlacement;
|
|
30
33
|
}
|
|
31
34
|
|
|
@@ -35,6 +38,7 @@ export const DEFAULT_USAGE_CONFIG: UsageConfig = {
|
|
|
35
38
|
showOnlyOnProvider: true,
|
|
36
39
|
showResetTimes: true,
|
|
37
40
|
placement: "belowEditor",
|
|
41
|
+
glyphs: "auto",
|
|
38
42
|
};
|
|
39
43
|
|
|
40
44
|
export const MIN_USAGE_REFRESH_MS = 15_000;
|
|
@@ -90,6 +94,9 @@ export function readUsageConfig(env: NodeJS.ProcessEnv = process.env): UsageConf
|
|
|
90
94
|
if (typeof file.refreshIntervalMs === "number") {
|
|
91
95
|
config.refreshIntervalMs = clampRefreshInterval(file.refreshIntervalMs);
|
|
92
96
|
}
|
|
97
|
+
if (GLYPH_MODES.includes(file.glyphs as GlyphMode)) {
|
|
98
|
+
config.glyphs = file.glyphs as GlyphMode;
|
|
99
|
+
}
|
|
93
100
|
if (USAGE_PLACEMENTS.includes(file.placement as UsagePlacement)) {
|
|
94
101
|
config.placement = file.placement as UsagePlacement;
|
|
95
102
|
}
|
package/glyphs.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Footer glyph policy for the usage widget.
|
|
3
|
+
*
|
|
4
|
+
* Older mintty/Cygwin builds measure emoji and ambiguous-width codepoints with
|
|
5
|
+
* their own cell-width tables, which can disagree with pi's. The widget line is
|
|
6
|
+
* truncated to (now) width - 1 and never padded to the edge, but a glyph the
|
|
7
|
+
* terminal measures wider than expected still shifts everything after it and
|
|
8
|
+
* can desync pi's differential renderer. "auto" swaps the footer glyphs for
|
|
9
|
+
* ASCII on detected legacy terminals; "unicode"/"ascii" force a set.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type GlyphMode = "auto" | "unicode" | "ascii";
|
|
13
|
+
|
|
14
|
+
export interface GlyphSet {
|
|
15
|
+
/** Separator between usage atoms. */
|
|
16
|
+
sep: string;
|
|
17
|
+
/** Reset-countdown prefix. */
|
|
18
|
+
reset: string;
|
|
19
|
+
/** Progress-bar fill / remainder (command output). */
|
|
20
|
+
barFilled: string;
|
|
21
|
+
barHollow: string;
|
|
22
|
+
/** Truncation marker. */
|
|
23
|
+
ellipsis: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const UNICODE_GLYPHS: GlyphSet = { sep: "\u00b7", reset: "\u21ba", barFilled: "\u2588", barHollow: "\u2591", ellipsis: "\u2026" };
|
|
27
|
+
export const ASCII_GLYPHS: GlyphSet = { sep: "-", reset: "~", barFilled: "#", barHollow: "-", ellipsis: "..." };
|
|
28
|
+
|
|
29
|
+
export const GLYPH_MODES: GlyphMode[] = ["auto", "unicode", "ascii"];
|
|
30
|
+
|
|
31
|
+
/** True for terminals whose cell-width tables are known to disagree with the
|
|
32
|
+
* width math in format.ts (older mintty/Cygwin builds). */
|
|
33
|
+
export function detectLegacyTerminal(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
34
|
+
const termProgram = env.TERM_PROGRAM ?? "";
|
|
35
|
+
const term = env.TERM ?? "";
|
|
36
|
+
return (
|
|
37
|
+
termProgram === "mintty" ||
|
|
38
|
+
termProgram === "cygwin" ||
|
|
39
|
+
termProgram === "msys" ||
|
|
40
|
+
term.startsWith("cygwin") ||
|
|
41
|
+
term.startsWith("msys")
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function resolveGlyphSet(mode: GlyphMode, env: NodeJS.ProcessEnv = process.env): GlyphSet {
|
|
46
|
+
if (mode === "unicode") return UNICODE_GLYPHS;
|
|
47
|
+
if (mode === "ascii") return ASCII_GLYPHS;
|
|
48
|
+
return detectLegacyTerminal(env) ? ASCII_GLYPHS : UNICODE_GLYPHS;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Widget-safe variant. The widget renders inside the editor's row budget, so
|
|
52
|
+
* an over-wide glyph there can desync pi's renderer; status fallback text is
|
|
53
|
+
* not row-budgeted and keeps an explicit choice. */
|
|
54
|
+
export function resolveWidgetGlyphSet(mode: GlyphMode, env: NodeJS.ProcessEnv = process.env): GlyphSet {
|
|
55
|
+
if (mode === "unicode" && detectLegacyTerminal(env)) return ASCII_GLYPHS;
|
|
56
|
+
return resolveGlyphSet(mode, env);
|
|
57
|
+
}
|
package/index.ts
CHANGED
|
@@ -29,10 +29,17 @@ import {
|
|
|
29
29
|
type ModelRegistry,
|
|
30
30
|
type ThemeColor,
|
|
31
31
|
} from "@earendil-works/pi-coding-agent";
|
|
32
|
-
import { USAGE_WIDGET_KEY, readUsageConfig, writeUsageConfig } from "./config.ts";
|
|
32
|
+
import { USAGE_WIDGET_KEY, readUsageConfig, writeUsageConfig, type UsageConfig } from "./config.ts";
|
|
33
33
|
import { sanitizeStatusText, truncateToWidth } from "./format.ts";
|
|
34
|
+
import { resolveGlyphSet, resolveWidgetGlyphSet, type GlyphSet } from "./glyphs.ts";
|
|
34
35
|
import { usageSegments, type UsageSegment, type UsageSeverity } from "./usage.ts";
|
|
35
36
|
import { UsageController } from "./usage-controller.ts";
|
|
37
|
+
import {
|
|
38
|
+
isMultiproviderService,
|
|
39
|
+
MULTIPROVIDER_SERVICE_EVENT,
|
|
40
|
+
setActiveMultiproviderService,
|
|
41
|
+
type MultiproviderService,
|
|
42
|
+
} from "./multiprovider.ts";
|
|
36
43
|
import modelsData from "./models.json" with { type: "json" };
|
|
37
44
|
import customModelsData from "./custom-models.json" with { type: "json" };
|
|
38
45
|
import patchData from "./patch.json" with { type: "json" };
|
|
@@ -420,7 +427,36 @@ export default function (pi: ExtensionAPI) {
|
|
|
420
427
|
|
|
421
428
|
let usageConfig = readUsageConfig();
|
|
422
429
|
let usageWidgetInstalled = false;
|
|
430
|
+
let usageClampNotified = false;
|
|
423
431
|
const usageController = new UsageController(() => usageConfig, updateUsageWidget);
|
|
432
|
+
let usageContext: ExtensionContext | undefined;
|
|
433
|
+
|
|
434
|
+
// Follow pi-multiprovider's active pooled account. Usage is per-account, so a
|
|
435
|
+
// switch — and a resume, which replays the account the session last switched
|
|
436
|
+
// to — must repaint the widget instead of waiting for the next poll. Without
|
|
437
|
+
// pi-multiprovider nothing here activates and resolution stays unchanged.
|
|
438
|
+
let multiproviderService: MultiproviderService | undefined;
|
|
439
|
+
let unsubscribeMultiprovider: (() => void) | undefined;
|
|
440
|
+
const refreshUsageForActiveAccount = (ctx: ExtensionContext | undefined): void => {
|
|
441
|
+
if (ctx === undefined) return;
|
|
442
|
+
void usageController.refresh(ctx, { force: true });
|
|
443
|
+
};
|
|
444
|
+
if (typeof pi.events?.on === "function") {
|
|
445
|
+
pi.events.on(MULTIPROVIDER_SERVICE_EVENT, (value: unknown) => {
|
|
446
|
+
if (!isMultiproviderService(value)) return;
|
|
447
|
+
if (value !== multiproviderService) {
|
|
448
|
+
unsubscribeMultiprovider?.();
|
|
449
|
+
multiproviderService = value;
|
|
450
|
+
setActiveMultiproviderService(value);
|
|
451
|
+
unsubscribeMultiprovider = value.onActiveAccountChanged(PROVIDER_ID, (event) => {
|
|
452
|
+
refreshUsageForActiveAccount(usageContext ?? event.ctx);
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
// The event re-fires at every session start with the same stable object,
|
|
456
|
+
// so this also catches a service that appeared mid-session.
|
|
457
|
+
refreshUsageForActiveAccount(usageContext);
|
|
458
|
+
});
|
|
459
|
+
}
|
|
424
460
|
|
|
425
461
|
const USAGE_SEVERITY_COLORS: Record<UsageSeverity, ThemeColor> = {
|
|
426
462
|
ok: "success",
|
|
@@ -429,17 +465,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
429
465
|
muted: "dim",
|
|
430
466
|
};
|
|
431
467
|
|
|
432
|
-
function usageSegmentsFor(ctx: ExtensionContext): UsageSegment[] | undefined {
|
|
468
|
+
function usageSegmentsFor(ctx: ExtensionContext, glyphs: GlyphSet): UsageSegment[] | undefined {
|
|
433
469
|
const snapshot = usageController.snapshot;
|
|
434
470
|
if (!snapshot || !usageConfig.enabled || !usageController.isEligible(ctx)) return undefined;
|
|
435
|
-
const segments = usageSegments(snapshot, { showResetTimes: usageConfig.showResetTimes });
|
|
436
|
-
if (usageController.isStale()) segments.push({ text:
|
|
471
|
+
const segments = usageSegments(snapshot, { showResetTimes: usageConfig.showResetTimes, glyphs });
|
|
472
|
+
if (usageController.isStale()) segments.push({ text: ` ${glyphs.sep} stale`, severity: "warning" });
|
|
437
473
|
return segments;
|
|
438
474
|
}
|
|
439
475
|
|
|
440
476
|
function updateUsageWidget(ctx: ExtensionContext): void {
|
|
441
477
|
try {
|
|
442
|
-
|
|
478
|
+
// Legacy terminals measure the footer glyphs with their own tables; widget
|
|
479
|
+
// content clamps to ASCII there, the status fallback keeps the choice.
|
|
480
|
+
const glyphs = resolveGlyphSet(usageConfig.glyphs);
|
|
481
|
+
const widgetGlyphs = resolveWidgetGlyphSet(usageConfig.glyphs);
|
|
482
|
+
if (widgetGlyphs !== glyphs && !usageClampNotified) {
|
|
483
|
+
usageClampNotified = true;
|
|
484
|
+
ctx.ui.notify("OpenCode Go: widget glyphs stay ASCII on this terminal — unicode glyphs overflow legacy mintty/Cygwin cell widths. The status line is unaffected.", "info");
|
|
485
|
+
}
|
|
486
|
+
const segments = usageSegmentsFor(ctx, widgetGlyphs);
|
|
443
487
|
if (!segments) {
|
|
444
488
|
if (usageWidgetInstalled) {
|
|
445
489
|
ctx.ui.setWidget(USAGE_WIDGET_KEY, undefined);
|
|
@@ -460,14 +504,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
460
504
|
const line = segments
|
|
461
505
|
.map((segment) => theme.fg(USAGE_SEVERITY_COLORS[segment.severity], segment.text))
|
|
462
506
|
.join("");
|
|
463
|
-
|
|
507
|
+
// Budget width - 1: never paint the terminal’s last column (a
|
|
508
|
+
// pending wrap there desyncs pi’s renderer on legacy terminals).
|
|
509
|
+
const w = Math.max(1, width - 1);
|
|
510
|
+
return [truncateToWidth(line, w, theme.fg("dim", widgetGlyphs.ellipsis))];
|
|
464
511
|
},
|
|
465
512
|
}),
|
|
466
513
|
{ placement: usageConfig.placement },
|
|
467
514
|
);
|
|
468
515
|
} else {
|
|
469
516
|
ctx.ui.setWidget(USAGE_WIDGET_KEY, undefined);
|
|
470
|
-
ctx
|
|
517
|
+
const barSegments = usageSegmentsFor(ctx, glyphs) ?? segments;
|
|
518
|
+
ctx.ui.setStatus(USAGE_WIDGET_KEY, sanitizeStatusText(barSegments.map((s) => s.text).join("")));
|
|
471
519
|
}
|
|
472
520
|
usageWidgetInstalled = true;
|
|
473
521
|
} catch {
|
|
@@ -479,6 +527,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
479
527
|
description: "Show, refresh, or toggle the OpenCode Go 5h / 7d / 30d usage widget",
|
|
480
528
|
handler: async (args, ctx) => {
|
|
481
529
|
const action = args.trim().toLowerCase();
|
|
530
|
+
if (action === "glyphs auto" || action === "glyphs unicode" || action === "glyphs ascii") {
|
|
531
|
+
const glyphs = action.slice("glyphs ".length) as UsageConfig["glyphs"];
|
|
532
|
+
usageConfig = { ...usageConfig, glyphs };
|
|
533
|
+
const persisted = writeUsageConfig({ glyphs });
|
|
534
|
+
updateUsageWidget(ctx);
|
|
535
|
+
const suffix = persisted ? "" : " for this session (could not write the config file)";
|
|
536
|
+
ctx.ui.notify(`OpenCode Go footer glyphs: ${glyphs}${suffix}.`, "info");
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
482
539
|
if (action === "on" || action === "off") {
|
|
483
540
|
const enabled = action === "on";
|
|
484
541
|
usageConfig = { ...usageConfig, enabled };
|
|
@@ -510,6 +567,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
510
567
|
});
|
|
511
568
|
|
|
512
569
|
pi.on("session_start", async (_event, ctx) => {
|
|
570
|
+
usageContext = ctx;
|
|
513
571
|
usageController.start(ctx);
|
|
514
572
|
revalidateAbort?.abort();
|
|
515
573
|
revalidateAbort = new AbortController();
|
|
@@ -551,6 +609,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
551
609
|
|
|
552
610
|
pi.on("session_shutdown", () => {
|
|
553
611
|
revalidateAbort?.abort();
|
|
612
|
+
usageContext = undefined;
|
|
613
|
+
unsubscribeMultiprovider?.();
|
|
614
|
+
unsubscribeMultiprovider = undefined;
|
|
615
|
+
multiproviderService = undefined;
|
|
616
|
+
setActiveMultiproviderService(undefined);
|
|
554
617
|
usageController.shutdown();
|
|
555
618
|
});
|
|
556
619
|
}
|
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,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Glyph policy: legacy terminals (mintty/Cygwin) get an ASCII footer so an
|
|
3
|
+
* over-wide glyph cannot shift the widget line and desync pi's renderer.
|
|
4
|
+
*/
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { DEFAULT_USAGE_CONFIG } from "../config.ts";
|
|
8
|
+
import { ASCII_GLYPHS, UNICODE_GLYPHS, detectLegacyTerminal, resolveGlyphSet, resolveWidgetGlyphSet } from "../glyphs.ts";
|
|
9
|
+
import { formatBar, parseUsageSnapshot, usageSegments } from "../usage.ts";
|
|
10
|
+
import { truncateToWidth } from "../format.ts";
|
|
11
|
+
|
|
12
|
+
const mintty = { TERM_PROGRAM: "mintty", TERM: "xterm" } as NodeJS.ProcessEnv;
|
|
13
|
+
const cygwin = { TERM: "cygwin" } as NodeJS.ProcessEnv;
|
|
14
|
+
const wt = { TERM_PROGRAM: "Windows_Terminal", TERM: "xterm-256color" } as NodeJS.ProcessEnv;
|
|
15
|
+
const isAscii = (value: string) => [...value].every((char) => char.charCodeAt(0) < 128);
|
|
16
|
+
|
|
17
|
+
test("detects legacy terminals", () => {
|
|
18
|
+
assert.equal(detectLegacyTerminal(mintty), true);
|
|
19
|
+
assert.equal(detectLegacyTerminal(cygwin), true);
|
|
20
|
+
assert.equal(detectLegacyTerminal(wt), false);
|
|
21
|
+
assert.equal(detectLegacyTerminal({} as NodeJS.ProcessEnv), false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("auto degrades on legacy terminals; explicit modes win for the status line", () => {
|
|
25
|
+
assert.equal(resolveGlyphSet("auto", mintty), ASCII_GLYPHS);
|
|
26
|
+
assert.equal(resolveGlyphSet("auto", wt), UNICODE_GLYPHS);
|
|
27
|
+
assert.equal(resolveGlyphSet("unicode", mintty), UNICODE_GLYPHS);
|
|
28
|
+
assert.equal(resolveGlyphSet("ascii", wt), ASCII_GLYPHS);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("widget content clamps an explicit unicode choice on legacy terminals", () => {
|
|
32
|
+
assert.equal(resolveWidgetGlyphSet("unicode", mintty), ASCII_GLYPHS);
|
|
33
|
+
assert.equal(resolveWidgetGlyphSet("unicode", wt), UNICODE_GLYPHS);
|
|
34
|
+
assert.equal(resolveWidgetGlyphSet("auto", mintty), ASCII_GLYPHS);
|
|
35
|
+
assert.equal(resolveWidgetGlyphSet("ascii", wt), ASCII_GLYPHS);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("the default config is auto", () => {
|
|
39
|
+
assert.equal(DEFAULT_USAGE_CONFIG.glyphs, "auto");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("the ASCII set is pure ASCII and renders an ASCII usage line", () => {
|
|
43
|
+
assert.equal(isAscii(Object.values(ASCII_GLYPHS).join("")), true);
|
|
44
|
+
assert.equal(isAscii(Object.values(UNICODE_GLYPHS).join("")), false);
|
|
45
|
+
|
|
46
|
+
// Built through the real parser so the fixture cannot drift from the wire shape.
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
const snapshot = parseUsageSnapshot(
|
|
49
|
+
{
|
|
50
|
+
usage: {
|
|
51
|
+
rolling: { status: "ok", percent: 37, resetsAt: new Date(now + 3_600_000).toISOString() },
|
|
52
|
+
weekly: { status: "ok", percent: 59, resetsAt: new Date(now + 86_400_000).toISOString() },
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
now,
|
|
56
|
+
);
|
|
57
|
+
assert.ok(snapshot);
|
|
58
|
+
const asciiLine = usageSegments(snapshot, { showResetTimes: true, glyphs: ASCII_GLYPHS }, now)
|
|
59
|
+
.map((segment) => segment.text)
|
|
60
|
+
.join("");
|
|
61
|
+
const unicodeLine = usageSegments(snapshot, { showResetTimes: true }, now)
|
|
62
|
+
.map((segment) => segment.text)
|
|
63
|
+
.join("");
|
|
64
|
+
assert.equal(isAscii(asciiLine), true);
|
|
65
|
+
assert.equal(asciiLine.includes("5h: 63% - 7d: 41%"), true);
|
|
66
|
+
assert.equal(unicodeLine.includes("\u00b7"), true);
|
|
67
|
+
assert.equal(unicodeLine.includes("\u21ba"), true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("bars and truncation take the ASCII glyphs too", () => {
|
|
71
|
+
assert.equal(formatBar(50, 4, ASCII_GLYPHS), "##--");
|
|
72
|
+
assert.equal(formatBar(50, 4), "\u2588\u2588\u2591\u2591");
|
|
73
|
+
const cut = truncateToWidth("abcdefghij", 5, ASCII_GLYPHS.ellipsis);
|
|
74
|
+
assert.equal(cut, "ab...");
|
|
75
|
+
assert.equal(isAscii(cut), true);
|
|
76
|
+
});
|
|
@@ -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);
|
package/usage.ts
CHANGED
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
* 30d ↺ 20d0h
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
+
import { UNICODE_GLYPHS, type GlyphSet } from "./glyphs.ts";
|
|
28
|
+
|
|
27
29
|
export const USAGE_URL = "https://opencode.ai/zen/go/v1/usage";
|
|
28
30
|
|
|
29
31
|
/** Published Go budget per window; informational only, not sent by the API. */
|
|
@@ -66,6 +68,8 @@ export interface UsageSegment {
|
|
|
66
68
|
export interface UsageFormatOptions {
|
|
67
69
|
showResetTimes: boolean;
|
|
68
70
|
showBankedResets?: boolean;
|
|
71
|
+
/** Glyph set for separators and reset markers; defaults to unicode. */
|
|
72
|
+
glyphs?: GlyphSet;
|
|
69
73
|
}
|
|
70
74
|
|
|
71
75
|
export const USAGE_WINDOW_LABELS: Record<UsageWindowKey, string> = {
|
|
@@ -256,9 +260,10 @@ function formatCompactReset(
|
|
|
256
260
|
label: string | undefined,
|
|
257
261
|
resetAt: number | null,
|
|
258
262
|
now: number,
|
|
263
|
+
glyphs: GlyphSet = UNICODE_GLYPHS,
|
|
259
264
|
): string | null {
|
|
260
265
|
if (resetAt === null) return null;
|
|
261
|
-
return `${label ? `${label} ` : ""}
|
|
266
|
+
return `${label ? `${label} ` : ""}${glyphs.reset} ${formatCountdown(resetAt - now)}`;
|
|
262
267
|
}
|
|
263
268
|
|
|
264
269
|
/** "3 banked resets", or null when the count is absent or zero. */
|
|
@@ -285,11 +290,12 @@ export function usageSegments(
|
|
|
285
290
|
options: UsageFormatOptions,
|
|
286
291
|
now = Date.now(),
|
|
287
292
|
): UsageSegment[] {
|
|
293
|
+
const glyphs = options.glyphs ?? UNICODE_GLYPHS;
|
|
288
294
|
const windows = snapshot.windows;
|
|
289
295
|
const labelled = windows.length > 1;
|
|
290
296
|
const segments: UsageSegment[] = [{ text: "Usage: ", severity: "muted" }];
|
|
291
297
|
windows.forEach((window, index) => {
|
|
292
|
-
if (index > 0) segments.push({ text:
|
|
298
|
+
if (index > 0) segments.push({ text: ` ${glyphs.sep} `, severity: "muted" });
|
|
293
299
|
segments.push({ text: `${window.label}: `, severity: "muted" });
|
|
294
300
|
segments.push({
|
|
295
301
|
text: formatPercent(window.remainingPercent),
|
|
@@ -298,13 +304,13 @@ export function usageSegments(
|
|
|
298
304
|
});
|
|
299
305
|
if (options.showResetTimes) {
|
|
300
306
|
for (const window of windows) {
|
|
301
|
-
const reset = formatCompactReset(labelled ? window.label : undefined, window.resetsAt, now);
|
|
302
|
-
if (reset) segments.push({ text: `
|
|
307
|
+
const reset = formatCompactReset(labelled ? window.label : undefined, window.resetsAt, now, glyphs);
|
|
308
|
+
if (reset) segments.push({ text: ` ${glyphs.sep} ${reset}`, severity: "muted" });
|
|
303
309
|
}
|
|
304
310
|
}
|
|
305
311
|
if (options.showBankedResets !== false) {
|
|
306
312
|
const banked = formatBankedResetsSuffix(snapshot.bankedResets);
|
|
307
|
-
if (banked) segments.push({ text: `
|
|
313
|
+
if (banked) segments.push({ text: ` ${glyphs.sep} ${banked}`, severity: "muted" });
|
|
308
314
|
}
|
|
309
315
|
return segments;
|
|
310
316
|
}
|
|
@@ -320,13 +326,13 @@ export function formatUsageLine(
|
|
|
320
326
|
}
|
|
321
327
|
|
|
322
328
|
/** Progress bar: 20 cells, filled by the remaining percentage. */
|
|
323
|
-
export function formatBar(percent: number, width = 20): string {
|
|
329
|
+
export function formatBar(percent: number, width = 20, glyphs: GlyphSet = UNICODE_GLYPHS): string {
|
|
324
330
|
const filled = Math.round((clampPercent(percent) / 100) * width);
|
|
325
|
-
return `${
|
|
331
|
+
return `${glyphs.barFilled.repeat(filled)}${glyphs.barHollow.repeat(width - filled)}`;
|
|
326
332
|
}
|
|
327
333
|
|
|
328
334
|
/** Multi-line breakdown with bars, used by the command output. */
|
|
329
|
-
export function formatUsageDetail(snapshot: UsageSnapshot, now = Date.now()): string[] {
|
|
335
|
+
export function formatUsageDetail(snapshot: UsageSnapshot, now = Date.now(), glyphs: GlyphSet = UNICODE_GLYPHS): string[] {
|
|
330
336
|
return snapshot.windows.map((window) => {
|
|
331
337
|
const clock = window.resetsAt === null
|
|
332
338
|
? null
|
|
@@ -334,8 +340,8 @@ export function formatUsageDetail(snapshot: UsageSnapshot, now = Date.now()): st
|
|
|
334
340
|
const reset =
|
|
335
341
|
window.resetsAt === null || clock === null
|
|
336
342
|
? ""
|
|
337
|
-
: `
|
|
343
|
+
: ` ${glyphs.reset} ${formatCountdown(window.resetsAt - now)} - ${clock}`;
|
|
338
344
|
const limited = window.status === "rate-limited" ? " RATE LIMITED" : "";
|
|
339
|
-
return `${window.label.padEnd(3)} ${formatBar(window.remainingPercent)} ${formatPercent(window.remainingPercent).padStart(4)} left${reset}${limited}`;
|
|
345
|
+
return `${window.label.padEnd(3)} ${formatBar(window.remainingPercent, 20, glyphs)} ${formatPercent(window.remainingPercent).padStart(4)} left${reset}${limited}`;
|
|
340
346
|
});
|
|
341
347
|
}
|