@mevsgame/pi-model-alias-profiles 0.1.0 → 0.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 -0
- package/index.ts +6 -0
- package/package.json +1 -1
- package/src/usage-query.ts +41 -0
- package/src/usage.ts +99 -0
package/README.md
CHANGED
|
@@ -110,6 +110,10 @@ Other processes do not change this process's active profile. After a process
|
|
|
110
110
|
crash, a stale `.lock` may require removal after confirming no writer is active.
|
|
111
111
|
Local atomic-rename/lock semantics are assumed; network filesystems may differ.
|
|
112
112
|
|
|
113
|
+
## Optional pi-usage integration
|
|
114
|
+
|
|
115
|
+
When `@narumitw/pi-usage` is installed, selecting a tier queries its mapped physical provider through `usage:query:v1`. The separate `model-profiles-usage` footer item is cleared when the tier changes or its report expires. Queries may perform network requests. Without pi-usage there is no usage item. The installed pi-usage settings have no global status enable switch: its Anthropic status is gated by `anthropicExtraUsage`, and xAI is menu-only. The bridge respects these gates and Codex percentage/countdown preferences in `pi-usage.json`. Its compact formatter currently covers percentage buckets and Anthropic remaining spend; it does not reproduce pi-usage's full provider-specific formatting or custom `statuslineDisplay` templates.
|
|
116
|
+
|
|
113
117
|
## Known limitations and troubleshooting
|
|
114
118
|
|
|
115
119
|
- Another extension registering `tier/large`, `tier/medium`, or `tier/small`
|
|
@@ -138,6 +142,19 @@ Installed Pi-Bolt smoke checks confirm extension loading, `--model tier/medium`,
|
|
|
138
142
|
and all three aliases in `--list-models tier` without sending model requests.
|
|
139
143
|
See `VERIFICATION.md` for exact results and remaining manual checks.
|
|
140
144
|
|
|
145
|
+
## Publishing
|
|
146
|
+
|
|
147
|
+
`.github/workflows/publish.yml` tests and publishes the package to npm when a
|
|
148
|
+
GitHub Release is published. Configure npm Trusted Publishing once for
|
|
149
|
+
`@mevsgame/pi-model-alias-profiles` with GitHub Actions, owner `mevsgame`,
|
|
150
|
+
repository `pi-model-alias-profiles`, and workflow `publish.yml` (no environment).
|
|
151
|
+
No npm token secret is needed. Stable releases publish under `latest`; GitHub
|
|
152
|
+
prereleases publish under `next`.
|
|
153
|
+
|
|
154
|
+
For each release, update `version` in `package.json` and `package-lock.json`,
|
|
155
|
+
push the change and a matching `vX.Y.Z` tag, then publish a GitHub Release for
|
|
156
|
+
that tag. The workflow checks the tag/version match before publishing.
|
|
157
|
+
|
|
141
158
|
## License and links
|
|
142
159
|
|
|
143
160
|
MIT. [Source and issues](https://github.com/mevsgame/pi-model-alias-profiles).
|
package/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { ALIASES, ConfigStore, type Config, type Alias } from "./src/config.ts";
|
|
|
4
4
|
import { route, validateProfile } from "./src/profiles.ts";
|
|
5
5
|
import { branchProfile, STATE_ENTRY } from "./src/state.ts";
|
|
6
6
|
import { manage, quickPicker, type Controller } from "./src/ui.ts";
|
|
7
|
+
import { createUsageBridge } from "./src/usage.ts";
|
|
7
8
|
|
|
8
9
|
export default function modelProfiles(pi: ExtensionAPI) {
|
|
9
10
|
const store = new ConfigStore(join(getAgentDir(), "model-profiles.json"));
|
|
@@ -15,12 +16,14 @@ export default function modelProfiles(pi: ExtensionAPI) {
|
|
|
15
16
|
if (ctx.hasUI) ctx.ui.notify(message, error ? "error" : "info");
|
|
16
17
|
else console.error(`[model-profiles] ${message}`);
|
|
17
18
|
};
|
|
19
|
+
const usage = createUsageBridge(pi, () => config, () => active);
|
|
18
20
|
const status = (ctx: ExtensionContext) => {
|
|
19
21
|
if (!ctx.hasUI) return;
|
|
20
22
|
const alias = ctx.model?.provider === "tier" ? ctx.model.id as Alias : undefined;
|
|
21
23
|
ctx.ui.setStatus("model-profiles", alias
|
|
22
24
|
? `${active ?? "no profile"} · ${alias} → ${active && config.profiles[active]?.[alias] || "(unconfigured)"}`
|
|
23
25
|
: undefined);
|
|
26
|
+
if (pi.events) usage.update(ctx);
|
|
24
27
|
};
|
|
25
28
|
const record = () => pi.appendEntry(STATE_ENTRY, { profile: active ?? null });
|
|
26
29
|
const controller: Controller = {
|
|
@@ -71,11 +74,14 @@ export default function modelProfiles(pi: ExtensionAPI) {
|
|
|
71
74
|
};
|
|
72
75
|
pi.on("session_start", (_event, ctx) => {
|
|
73
76
|
restore(ctx);
|
|
77
|
+
if (pi.events) usage.start(ctx);
|
|
74
78
|
if (loadError) report(ctx, String(loadError), true);
|
|
75
79
|
else if (!Object.keys(config.profiles).length) report(ctx, "No model profiles configured. Run /model-profiles to create your first profile.");
|
|
76
80
|
});
|
|
81
|
+
pi.on("session_shutdown", (_event, ctx) => { if (pi.events) usage.stop(ctx); });
|
|
77
82
|
pi.on("session_tree", (_event, ctx) => restore(ctx));
|
|
78
83
|
pi.on("model_select", (_event, ctx) => status(ctx));
|
|
84
|
+
pi.on("agent_end", (_event, ctx) => { if (pi.events) usage.update(ctx); });
|
|
79
85
|
for (const alias of ALIASES) pi.registerVirtualModel({
|
|
80
86
|
provider: "tier", id: alias, name: `Tier ${alias}`,
|
|
81
87
|
thinkingLevels: ["off", "minimal", "low", "medium", "high", "xhigh"],
|
package/package.json
CHANGED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export type QueryOutcome = { kind: "result"; value: unknown } | { kind: "no-service" | "cancelled" | "timeout" | "protocol-error" };
|
|
4
|
+
|
|
5
|
+
/** The response offer is synchronous even when the offered result is asynchronous. */
|
|
6
|
+
export function queryUsage(pi: ExtensionAPI, session: ExtensionContext["sessionManager"], sessionId: string,
|
|
7
|
+
providerId: string, controller: AbortController, timeoutMs = 15_000): Promise<QueryOutcome> {
|
|
8
|
+
return new Promise(resolve => {
|
|
9
|
+
const signal = controller.signal;
|
|
10
|
+
if (signal.aborted) { resolve({ kind: "cancelled" }); return; }
|
|
11
|
+
let open = true, accepted = false, settled = false;
|
|
12
|
+
let deadline: ReturnType<typeof setTimeout> | undefined;
|
|
13
|
+
const finish = (outcome: QueryOutcome) => {
|
|
14
|
+
if (settled) return;
|
|
15
|
+
settled = true;
|
|
16
|
+
if (deadline) clearTimeout(deadline);
|
|
17
|
+
signal.removeEventListener("abort", onAbort);
|
|
18
|
+
resolve(outcome);
|
|
19
|
+
};
|
|
20
|
+
const onAbort = () => finish({ kind: "cancelled" });
|
|
21
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
22
|
+
deadline = setTimeout(() => { finish({ kind: "timeout" }); controller.abort(); }, timeoutMs);
|
|
23
|
+
try {
|
|
24
|
+
pi.events.emit("usage:query:v1", {
|
|
25
|
+
session, sessionId, providerId, refresh: true, signal,
|
|
26
|
+
respond(offered: unknown) {
|
|
27
|
+
// Even a rejected duplicate or late promise must have its rejection observed.
|
|
28
|
+
const promise = Promise.resolve(offered);
|
|
29
|
+
if (!open || accepted || settled || signal.aborted) { void promise.catch(() => {}); return false; }
|
|
30
|
+
accepted = true;
|
|
31
|
+
void promise.then(value => finish({ kind: "result", value }), () => finish({ kind: "protocol-error" }));
|
|
32
|
+
return true;
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
} catch {
|
|
36
|
+
finish({ kind: "protocol-error" });
|
|
37
|
+
controller.abort();
|
|
38
|
+
} finally { open = false; }
|
|
39
|
+
if (!accepted && !settled) { finish({ kind: "no-service" }); controller.abort(); }
|
|
40
|
+
});
|
|
41
|
+
}
|
package/src/usage.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { resolveTarget } from "./profiles.ts";
|
|
6
|
+
import type { Alias, Config } from "./config.ts";
|
|
7
|
+
import { queryUsage } from "./usage-query.ts";
|
|
8
|
+
|
|
9
|
+
// pi-usage has no global status switch. Its Anthropic status is explicitly opt-in;
|
|
10
|
+
// xAI is deliberately menu-only. Do not start a query for either when disabled.
|
|
11
|
+
function settings() {
|
|
12
|
+
try {
|
|
13
|
+
const value = JSON.parse(readFileSync(join(getAgentDir(), "pi-usage.json"), "utf8"));
|
|
14
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
15
|
+
} catch { return {}; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function formatUsage(value: any, provider: string, opts: any): string | undefined {
|
|
19
|
+
if (value?.status !== "ready" || value.providerId !== provider || value.report?.providerId !== provider ||
|
|
20
|
+
!Number.isFinite(value.expiresAt) || value.expiresAt <= Date.now() || !Array.isArray(value.report.buckets)) return;
|
|
21
|
+
if (provider === "anthropic") {
|
|
22
|
+
const metric = Array.isArray(value.report.metrics) && value.report.metrics.find((m: any) => m?.id === "extra-usage-remaining");
|
|
23
|
+
return typeof metric?.value === "number" && Number.isFinite(metric.value) ? `anthropic $${metric.value.toFixed(2)} left` : undefined;
|
|
24
|
+
}
|
|
25
|
+
const buckets = value.report.buckets.filter((b: any) => b?.unit === "percent" && Number.isFinite(b.remaining));
|
|
26
|
+
if (!buckets.length) return;
|
|
27
|
+
const label = provider === "openai-codex" ? "codex" : provider.replace(/[^a-zA-Z0-9._-]/g, "");
|
|
28
|
+
const parts = buckets.slice(0, 4).map((b: any) => {
|
|
29
|
+
const amount = provider === "openai-codex" && opts.codexStatusPercentage === "used"
|
|
30
|
+
? (Number.isFinite(b.used) ? b.used : 100 - b.remaining) : b.remaining;
|
|
31
|
+
const percent = `${Math.max(0, Math.min(100, amount)).toFixed(0)}%`;
|
|
32
|
+
const mins = b.windowMinutes;
|
|
33
|
+
const window = Number.isFinite(mins) && mins > 0 ? mins === 10080 ? "wk" : mins % 1440 === 0 ? `${mins / 1440}d` : mins % 60 === 0 ? `${mins / 60}h` : `${mins}m` : "";
|
|
34
|
+
const reset = provider === "openai-codex" && opts.codexStatusResetCountdown !== false && Number.isFinite(b.resetsAt)
|
|
35
|
+
? Math.max(0, Math.ceil((b.resetsAt - Date.now()) / 60000)) : undefined;
|
|
36
|
+
return reset !== undefined ? `${percent} ↻ ${reset >= 60 ? `${Math.floor(reset / 60)}h${reset % 60 ? `${reset % 60}m` : ""}` : `${reset}m`}` : `${window ? `${window} ` : ""}${percent}`;
|
|
37
|
+
});
|
|
38
|
+
return `${label} ${parts.join(" · ")}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createUsageBridge(pi: ExtensionAPI, getConfig: () => Config, getActive: () => string | undefined,
|
|
42
|
+
timing: { timeoutMs?: number; retryBaseMs?: number; retryMaxMs?: number; minRefreshMs?: number } = {}) {
|
|
43
|
+
let generation = 0;
|
|
44
|
+
let controller: AbortController | undefined;
|
|
45
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
46
|
+
let running = false;
|
|
47
|
+
let failures = 0;
|
|
48
|
+
const clear = (ctx: ExtensionContext) => { if (ctx.hasUI) ctx.ui.setStatus("model-profiles-usage", undefined); };
|
|
49
|
+
const cancel = () => { generation++; controller?.abort(); controller = undefined; if (timer) clearTimeout(timer); timer = undefined; };
|
|
50
|
+
const update = (ctx: ExtensionContext) => {
|
|
51
|
+
cancel();
|
|
52
|
+
clear(ctx);
|
|
53
|
+
if (!running || !ctx.hasUI || !pi.events || ctx.model?.provider !== "tier") return;
|
|
54
|
+
let model;
|
|
55
|
+
try { model = resolveTarget(getActive(), ctx.model.id as Alias, getConfig(), ctx.modelRegistry); } catch { return; }
|
|
56
|
+
const opts = settings();
|
|
57
|
+
if (model.provider === "xai" || (model.provider === "anthropic" && opts.anthropicExtraUsage !== true)) return;
|
|
58
|
+
const session = ctx.sessionManager;
|
|
59
|
+
if (typeof session.getSessionId !== "function") return;
|
|
60
|
+
const sessionId = session.getSessionId();
|
|
61
|
+
const current = generation;
|
|
62
|
+
const abort = new AbortController(); controller = abort;
|
|
63
|
+
void queryUsage(pi, session, sessionId, model.provider, abort, timing.timeoutMs).then(outcome => {
|
|
64
|
+
if (!running || current !== generation || session.getSessionId() !== sessionId) return;
|
|
65
|
+
if (ctx.model?.provider !== "tier" || JSON.stringify(settings()) !== JSON.stringify(opts)) return;
|
|
66
|
+
try {
|
|
67
|
+
const target = resolveTarget(getActive(), ctx.model.id as Alias, getConfig(), ctx.modelRegistry);
|
|
68
|
+
if (target.provider !== model.provider || target.id !== model.id) return;
|
|
69
|
+
} catch { return; }
|
|
70
|
+
if (controller === abort) controller = undefined;
|
|
71
|
+
const result = outcome.kind === "result" ? outcome.value : undefined;
|
|
72
|
+
const value = formatUsage(result, model.provider, opts);
|
|
73
|
+
if (value) {
|
|
74
|
+
failures = 0;
|
|
75
|
+
ctx.ui.setStatus("model-profiles-usage", value);
|
|
76
|
+
const expiresAt = (result as any).expiresAt as number;
|
|
77
|
+
const remaining = Math.max(0, expiresAt - Date.now());
|
|
78
|
+
timer = setTimeout(() => {
|
|
79
|
+
if (current !== generation) return;
|
|
80
|
+
clear(ctx);
|
|
81
|
+
const delay = Math.max(0, (timing.minRefreshMs ?? 1000) - remaining);
|
|
82
|
+
if (delay) timer = setTimeout(() => { if (current === generation) update(ctx); }, delay);
|
|
83
|
+
else update(ctx);
|
|
84
|
+
}, Math.min(2147483647, remaining));
|
|
85
|
+
} else {
|
|
86
|
+
// No service and all unavailable/malformed results are retried with capped backoff.
|
|
87
|
+
// External lifecycle/model changes can trigger an earlier attempt.
|
|
88
|
+
const max = timing.retryMaxMs ?? 60_000;
|
|
89
|
+
const delay = outcome.kind === "no-service" ? max : Math.min(max, (timing.retryBaseMs ?? 1000) * 2 ** Math.min(failures++, 16));
|
|
90
|
+
timer = setTimeout(() => { if (current === generation) update(ctx); }, delay);
|
|
91
|
+
}
|
|
92
|
+
}).catch(() => {});
|
|
93
|
+
};
|
|
94
|
+
return {
|
|
95
|
+
start(ctx: ExtensionContext) { running = true; const started = ++generation; queueMicrotask(() => { if (running && started === generation) update(ctx); }); },
|
|
96
|
+
update,
|
|
97
|
+
stop(ctx: ExtensionContext) { running = false; cancel(); clear(ctx); },
|
|
98
|
+
};
|
|
99
|
+
}
|