@latentminds/pi-quotas 0.2.6 → 0.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @latentminds/pi-quotas
2
2
 
3
- Quota monitoring for Pi. Shows remaining usage and rate limits for Anthropic, OpenAI Codex, GitHub Copilot, OpenRouter, and Synthetic — directly in your Pi session.
3
+ Quota monitoring for Pi. Shows remaining usage and rate limits for Anthropic, OpenAI Codex, GitHub Copilot, OpenRouter, Synthetic, Z.ai, and OpenCode Go — directly in your Pi session.
4
4
 
5
5
  ## Screenshots
6
6
 
@@ -42,6 +42,9 @@ pi -e npm:@latentminds/pi-quotas
42
42
  | `/github:quotas` | GitHub Copilot quotas only |
43
43
  | `/openrouter:quotas` | OpenRouter quotas only |
44
44
  | `/synthetic:quotas` | Synthetic quotas only |
45
+ | `/zai:quotas` | Z.ai quotas only |
46
+ | `/opencode-go:quotas`| OpenCode Go quotas only |
47
+ | `/tokens` | Cross-session token/cost usage |
45
48
  | `/quotas:settings` | Toggle individual features on or off |
46
49
 
47
50
 
@@ -64,7 +67,7 @@ Automatic notifications when projected usage is on track to exceed limits before
64
67
  Use `/quotas:settings` to enable or disable:
65
68
 
66
69
  - Combined `/quotas` command
67
- - Per-provider commands (`/anthropic:quotas`, `/codex:quotas`, `/github:quotas`, `/openrouter:quotas`, `/synthetic:quotas`)
70
+ - Per-provider commands (`/anthropic:quotas`, `/codex:quotas`, `/github:quotas`, `/openrouter:quotas`, `/synthetic:quotas`, `/zai:quotas`, `/opencode-go:quotas`)
68
71
  - Footer status widget
69
72
  - Quota warning notifications
70
73
  - **Defer to Synthetic** — when both pi-quotas and [pi-synthetic](https://www.npmjs.com/package/@aliou/pi-synthetic) are loaded, pi-quotas hides its own Synthetic footer to avoid showing duplicate quota information. Enabled by default; disable if you prefer to see both footers.
@@ -81,6 +84,8 @@ Settings can be saved globally (`~/.pi/agent/extensions/quotas.json`) or per-pro
81
84
  | GitHub Copilot | Premium/chat/completions per month | Remaining/entitlement counts with overage indicators |
82
85
  | OpenRouter | Monthly budget, daily/weekly/monthly usage | USD spending tracking with cents precision; optional per-key budget limits; UTC-based period resets |
83
86
  | Synthetic | Subscription, search/hour, free tools, weekly tokens, 5h limit | Request counts and token budgets; rolling five-hour rate limit; weekly token regen |
87
+ | Z.ai | 5h, 7d, monthly web searches | Token utilisation percentages (rolling 5h/7d windows); monthly web-search count limit |
88
+ | OpenCode Go | Rolling 5h, weekly, monthly USD | USD spend tracking against tier limits; cross-session token/cost aggregation via the `/tokens` command |
84
89
 
85
90
 
86
91
  ## Credentials
@@ -92,6 +97,8 @@ pi-quotas reads existing Pi auth entries from `~/.pi/agent/auth.json`:
92
97
  - `github-copilot` — GitHub Copilot OAuth token (falls back to `gh auth token` if needed)
93
98
  - `openrouter` — OpenRouter API key (Bearer token)
94
99
  - `synthetic` — Synthetic API key (set the `SYNTHETIC_API_KEY` environment variable)
100
+ - `zai` — Z.ai (Zhipu AI / GLM Coding Plan) API key
101
+ - `opencode-go` — OpenCode Go workspace ID and auth cookie (set the `OPENCODE_GO_WORKSPACE_ID` and `OPENCODE_GO_AUTH_COOKIE` environment variables, or configure them in the OpenCode Go config file)
95
102
 
96
103
  No additional setup is required - if Pi can use the provider, pi-quotas can check its quotas. For Synthetic, export `SYNTHETIC_API_KEY` in your shell or Pi environment.
97
104
 
package/package.json CHANGED
@@ -1,11 +1,15 @@
1
1
  {
2
2
  "name": "@latentminds/pi-quotas",
3
- "version": "0.2.6",
3
+ "version": "0.3.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "private": false,
7
7
  "author": "Markus Strazds <markus@latentminds.ai>",
8
- "keywords": ["pi-package", "pi-extension", "pi"],
8
+ "keywords": [
9
+ "pi-package",
10
+ "pi-extension",
11
+ "pi"
12
+ ],
9
13
  "repository": {
10
14
  "type": "git",
11
15
  "url": "https://github.com/latentminds-ai/pi-quotas"
@@ -14,30 +18,46 @@
14
18
  "extensions": [
15
19
  "./src/extensions/core/index.ts",
16
20
  "./src/extensions/command-quotas/index.ts",
21
+ "./src/extensions/command-tokens/index.ts",
17
22
  "./src/extensions/usage-status/index.ts",
23
+ "./src/extensions/token-status/index.ts",
18
24
  "./src/extensions/quota-warnings/index.ts"
19
25
  ],
20
26
  "image": "https://raw.githubusercontent.com/latentminds-ai/pi-quotas/main/docs/quotas-dashboard.png"
21
27
  },
22
- "files": ["src", "README.md", "LICENSE"],
28
+ "files": [
29
+ "src",
30
+ "README.md",
31
+ "LICENSE"
32
+ ],
23
33
  "peerDependencies": {
24
34
  "@mariozechner/pi-coding-agent": "*",
25
35
  "@mariozechner/pi-tui": "*"
26
36
  },
27
37
  "peerDependenciesMeta": {
28
- "@mariozechner/pi-coding-agent": { "optional": true },
29
- "@mariozechner/pi-tui": { "optional": true }
38
+ "@mariozechner/pi-coding-agent": {
39
+ "optional": true
40
+ },
41
+ "@mariozechner/pi-tui": {
42
+ "optional": true
43
+ }
30
44
  },
31
45
  "devDependencies": {
46
+ "@eslint/js": "^10.0.1",
32
47
  "@mariozechner/pi-coding-agent": "0.61.0",
33
48
  "@mariozechner/pi-tui": "0.61.0",
34
49
  "@sinclair/typebox": "^0.34.48",
35
50
  "@types/node": "^25.0.10",
51
+ "eslint": "^10.6.0",
52
+ "globals": "^17.7.0",
36
53
  "typescript": "^5.9.3",
54
+ "typescript-eslint": "^8.63.0",
37
55
  "vitest": "^4.0.18"
38
56
  },
39
57
  "scripts": {
40
58
  "typecheck": "tsc --noEmit",
59
+ "lint": "eslint src",
60
+ "lint:fix": "eslint src --fix",
41
61
  "test": "vitest run",
42
62
  "test:watch": "vitest"
43
63
  }
package/src/config.ts CHANGED
@@ -9,6 +9,7 @@ export type QuotasFeatureId =
9
9
  | "quotasCommand"
10
10
  | "providerCommands"
11
11
  | "usageStatus"
12
+ | "tokenStatus"
12
13
  | "quotaWarnings"
13
14
  | "deferToSynthetic";
14
15
 
@@ -16,8 +17,7 @@ export const QUOTAS_EXTENSIONS_REQUEST_EVENT =
16
17
  "quotas:extensions:request" as const;
17
18
  export const QUOTAS_EXTENSIONS_REGISTER_EVENT =
18
19
  "quotas:extensions:register" as const;
19
- export const QUOTAS_CONFIG_UPDATED_EVENT =
20
- "quotas:config:updated" as const;
20
+ export const QUOTAS_CONFIG_UPDATED_EVENT = "quotas:config:updated" as const;
21
21
 
22
22
  export interface QuotasExtensionsRegisterPayload {
23
23
  feature: QuotasFeatureId;
@@ -28,6 +28,7 @@ export interface QuotasConfig {
28
28
  quotasCommand?: boolean;
29
29
  providerCommands?: boolean;
30
30
  usageStatus?: boolean;
31
+ tokenStatus?: boolean;
31
32
  quotaWarnings?: boolean;
32
33
  /** When true and pi-synthetic's usage footer is active, hide pi-quotas' Synthetic footer. */
33
34
  deferToSynthetic?: boolean;
@@ -38,6 +39,7 @@ export interface ResolvedQuotasConfig {
38
39
  quotasCommand: boolean;
39
40
  providerCommands: boolean;
40
41
  usageStatus: boolean;
42
+ tokenStatus: boolean;
41
43
  quotaWarnings: boolean;
42
44
  deferToSynthetic: boolean;
43
45
  }
@@ -47,6 +49,7 @@ const DEFAULT_CONFIG: ResolvedQuotasConfig = {
47
49
  quotasCommand: true,
48
50
  providerCommands: true,
49
51
  usageStatus: true,
52
+ tokenStatus: true,
50
53
  quotaWarnings: true,
51
54
  deferToSynthetic: true,
52
55
  };
@@ -81,10 +84,13 @@ class QuotasConfigStore {
81
84
  return {
82
85
  configVersion: input?.configVersion ?? DEFAULT_CONFIG.configVersion,
83
86
  quotasCommand: input?.quotasCommand ?? DEFAULT_CONFIG.quotasCommand,
84
- providerCommands: input?.providerCommands ?? DEFAULT_CONFIG.providerCommands,
87
+ providerCommands:
88
+ input?.providerCommands ?? DEFAULT_CONFIG.providerCommands,
85
89
  usageStatus: input?.usageStatus ?? DEFAULT_CONFIG.usageStatus,
90
+ tokenStatus: input?.tokenStatus ?? DEFAULT_CONFIG.tokenStatus,
86
91
  quotaWarnings: input?.quotaWarnings ?? DEFAULT_CONFIG.quotaWarnings,
87
- deferToSynthetic: input?.deferToSynthetic ?? DEFAULT_CONFIG.deferToSynthetic,
92
+ deferToSynthetic:
93
+ input?.deferToSynthetic ?? DEFAULT_CONFIG.deferToSynthetic,
88
94
  };
89
95
  }
90
96
 
@@ -118,7 +124,11 @@ class QuotasConfigStore {
118
124
  async save(scope: "global" | "local", config: QuotasConfig): Promise<void> {
119
125
  const path = scope === "global" ? this.globalPath() : this.localPath();
120
126
  await mkdir(dirname(path), { recursive: true });
121
- await writeFile(path, JSON.stringify(this.resolve(config), null, 2) + "\n", "utf8");
127
+ await writeFile(
128
+ path,
129
+ JSON.stringify(this.resolve(config), null, 2) + "\n",
130
+ "utf8",
131
+ );
122
132
  await this.load(this.cwd);
123
133
  }
124
134
  }
@@ -126,7 +136,8 @@ class QuotasConfigStore {
126
136
  export const configLoader = new QuotasConfigStore();
127
137
 
128
138
  export async function seedQuotasConfigIfMissing(): Promise<void> {
129
- if (configLoader.hasConfig("global") || configLoader.hasConfig("local")) return;
139
+ if (configLoader.hasConfig("global") || configLoader.hasConfig("local"))
140
+ return;
130
141
  markMigrationNoticePending();
131
142
  try {
132
143
  await configLoader.save("global", DEFAULT_CONFIG);
@@ -158,13 +169,19 @@ const FEATURE_META: Array<{
158
169
  {
159
170
  id: "providerCommands",
160
171
  label: "Provider quota commands",
161
- description: "Toggle `/anthropic:quotas`, `/codex:quotas`, `/github:quotas`, `/openrouter:quotas`, and `/synthetic:quotas`",
172
+ description:
173
+ "Toggle `/anthropic:quotas`, `/codex:quotas`, `/github:quotas`, `/openrouter:quotas`, and `/synthetic:quotas`",
162
174
  },
163
175
  {
164
176
  id: "usageStatus",
165
177
  label: "Usage status",
166
178
  description: "Toggle footer quota status for the active provider",
167
179
  },
180
+ {
181
+ id: "tokenStatus",
182
+ label: "Token usage status",
183
+ description: "Toggle the footer token-usage status and /tokens command",
184
+ },
168
185
  {
169
186
  id: "quotaWarnings",
170
187
  label: "Quota warnings",
@@ -173,7 +190,8 @@ const FEATURE_META: Array<{
173
190
  {
174
191
  id: "deferToSynthetic",
175
192
  label: "Defer to Synthetic",
176
- description: "When pi-synthetic is loaded, hide pi-quotas' Synthetic footer to avoid duplicates",
193
+ description:
194
+ "When pi-synthetic is loaded, hide pi-quotas' Synthetic footer to avoid duplicates",
177
195
  },
178
196
  ];
179
197
 
@@ -196,7 +214,9 @@ export function registerQuotasSettings(
196
214
 
197
215
  while (true) {
198
216
  const choices = FEATURE_META.map((feature) => {
199
- const loaded = getLoadedFeatures().has(feature.id) ? "" : " (not loaded)";
217
+ const loaded = getLoadedFeatures().has(feature.id)
218
+ ? ""
219
+ : " (not loaded)";
200
220
  const enabled = draft[feature.id] ? "enabled" : "disabled";
201
221
  return `${feature.label}: ${enabled}${loaded}`;
202
222
  });
@@ -214,13 +234,23 @@ export function registerQuotasSettings(
214
234
  return;
215
235
  }
216
236
 
217
- const feature = FEATURE_META.find((item) => selected.startsWith(item.label));
237
+ const feature = FEATURE_META.find((item) =>
238
+ selected.startsWith(item.label),
239
+ );
218
240
  if (!feature) continue;
219
- if (feature.id !== "deferToSynthetic" && !getLoadedFeatures().has(feature.id)) {
220
- ctx.ui.notify(`${feature.label} is not loaded by Pi in this session.`, "warning");
241
+ if (
242
+ feature.id !== "deferToSynthetic" &&
243
+ !getLoadedFeatures().has(feature.id)
244
+ ) {
245
+ ctx.ui.notify(
246
+ `${feature.label} is not loaded by Pi in this session.`,
247
+ "warning",
248
+ );
221
249
  continue;
222
250
  }
223
- (draft as unknown as Record<string, boolean>)[feature.id] = !(draft as unknown as Record<string, boolean>)[feature.id];
251
+ (draft as unknown as Record<string, boolean>)[feature.id] = !(
252
+ draft as unknown as Record<string, boolean>
253
+ )[feature.id];
224
254
  }
225
255
  },
226
256
  });
@@ -59,10 +59,32 @@ async function openQuotaView(
59
59
 
60
60
  if (result === undefined) {
61
61
  const snapshots = await loadSnapshots(true);
62
- ctx.ui.notify(JSON.stringify(snapshots, null, 2), "info");
62
+ ctx.ui.notify(formatSnapshotsForNotify(snapshots), "info");
63
63
  }
64
64
  }
65
65
 
66
+ /**
67
+ * Render quota snapshots as a readable multi-line summary for the
68
+ * non-interactive fallback (when `ctx.ui.custom` returns undefined). Avoids
69
+ * dumping raw JSON — which previously leaked raw HTTP error bodies — and
70
+ * skips "not_applicable" providers since they have nothing to report.
71
+ */
72
+ function formatSnapshotsForNotify(snapshots: Snapshot[]): string {
73
+ const lines: string[] = [];
74
+ for (const { provider, result } of snapshots) {
75
+ if (!result.success) {
76
+ if (result.error.kind === "not_applicable") continue;
77
+ lines.push(`${provider}: ${result.error.message}`);
78
+ continue;
79
+ }
80
+ const summary = result.data.windows
81
+ .map((w) => `${w.label} ${w.usedPercent}%`)
82
+ .join(", ");
83
+ lines.push(`${provider}: ${summary || "no windows"}`);
84
+ }
85
+ return lines.join("\n") || "No quota data available";
86
+ }
87
+
66
88
  export function registerQuotasCommands(pi: ExtensionAPI): void {
67
89
  pi.registerCommand("quotas", {
68
90
  description: "Display remaining quotas for Anthropic, Codex, GitHub Copilot, and OpenRouter",
@@ -122,4 +122,33 @@ describe("QuotasComponent", () => {
122
122
 
123
123
  vi.useRealTimers();
124
124
  });
125
+
126
+ it("renders a not_applicable provider silently with a dim note, not a warning", () => {
127
+ const component = makeComponent();
128
+ component.setState({
129
+ type: "loaded",
130
+ snapshots: [
131
+ {
132
+ provider: "anthropic",
133
+ result: {
134
+ success: false,
135
+ error: {
136
+ kind: "not_applicable",
137
+ message: "Direct API key — no subscription usage to report",
138
+ },
139
+ },
140
+ },
141
+ ],
142
+ });
143
+
144
+ const output = component.render(70).join("\n");
145
+
146
+ expect(output).toContain("Anthropic");
147
+ expect(output).toContain("Direct API key");
148
+ // Rendered as a dim informational note, not a warning.
149
+ expect(output).toContain("\x1b[2m");
150
+ expect(output).not.toContain("\x1b[33m");
151
+ expect(output).not.toContain("usage unavailable");
152
+ expect(output).not.toContain("{");
153
+ });
125
154
  });
@@ -38,7 +38,6 @@ function renderProgressBar(
38
38
  const paceIndex = showPace
39
39
  ? Math.min(width - 1, Math.round((Math.max(0, Math.min(100, pacePercent ?? 0)) / 100) * width))
40
40
  : null;
41
- const reset = "\x1b[0m";
42
41
  const parts: string[] = [];
43
42
  for (let idx = 0; idx < width; idx++) {
44
43
  if (paceIndex !== null && idx === paceIndex) {
@@ -141,7 +140,14 @@ export class QuotasComponent implements Component {
141
140
  lines.push(truncateToWidth(` ${this.theme.fg("accent", title)}`, maxWidth));
142
141
 
143
142
  if (!snapshot.result.success) {
144
- lines.push(truncateToWidth(` ${this.theme.fg("warning", snapshot.result.error.message)}`, maxWidth));
143
+ // "not applicable" is an expected, non-error state (e.g. a direct
144
+ // Anthropic API key with no subscription usage) — show a dim note,
145
+ // not a warning-coloured error.
146
+ const { error } = snapshot.result;
147
+ const tone = error.kind === "not_applicable" ? "dim" : "warning";
148
+ lines.push(
149
+ truncateToWidth(` ${this.theme.fg(tone, error.message)}`, maxWidth),
150
+ );
145
151
  return lines;
146
152
  }
147
153
 
@@ -40,4 +40,13 @@ describe("getProviderCommandInfo", () => {
40
40
  title: "OpenRouter Quotas",
41
41
  });
42
42
  });
43
+
44
+ it("maps zai to zai:quotas", () => {
45
+ const info = getProviderCommandInfo("zai");
46
+ expect(info).toMatchObject<Partial<ProviderCommandInfo>>({
47
+ provider: "zai",
48
+ commandName: "zai:quotas",
49
+ title: "Z.ai Quotas",
50
+ });
51
+ });
43
52
  });
@@ -40,5 +40,17 @@ export function getProviderCommandInfo(
40
40
  commandName: "synthetic:quotas",
41
41
  title: "Synthetic Quotas",
42
42
  };
43
+ case "zai":
44
+ return {
45
+ provider,
46
+ commandName: "zai:quotas",
47
+ title: "Z.ai Quotas",
48
+ };
49
+ case "opencode-go":
50
+ return {
51
+ provider,
52
+ commandName: "opencode-go:quotas",
53
+ title: "OpenCode Go Quotas",
54
+ };
43
55
  }
44
56
  }
@@ -0,0 +1,102 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionCommandContext,
4
+ } from "@mariozechner/pi-coding-agent";
5
+ import { aggregateAllSessions } from "../../lib/session-tokens.js";
6
+ import { TokensComponent } from "./tokens-display.js";
7
+
8
+ async function openTokensView(
9
+ ctx: ExtensionCommandContext,
10
+ cwd?: string,
11
+ ): Promise<void> {
12
+ const result = await ctx.ui.custom<null>((tui, theme, _kb, done) => {
13
+ const controller = new AbortController();
14
+ const component = new TokensComponent(
15
+ theme,
16
+ tui,
17
+ () => {
18
+ controller.abort();
19
+ done(null);
20
+ },
21
+ () => {
22
+ component.setState({ type: "loading" });
23
+ tui.requestRender();
24
+ void load();
25
+ },
26
+ cwd,
27
+ );
28
+
29
+ async function load(): Promise<void> {
30
+ try {
31
+ const aggregateResult = await aggregateAllSessions({ cwd });
32
+ if (controller.signal.aborted) return;
33
+ component.setState({ type: "loaded", result: aggregateResult });
34
+ tui.requestRender();
35
+ } catch {
36
+ if (controller.signal.aborted) return;
37
+ component.setState({
38
+ type: "loaded",
39
+ result: {
40
+ totals: {
41
+ input: 0,
42
+ output: 0,
43
+ cacheRead: 0,
44
+ cacheWrite: 0,
45
+ totalTokens: 0,
46
+ costTotal: 0,
47
+ },
48
+ byModel: [],
49
+ byProvider: [],
50
+ bySession: [],
51
+ sessionCount: 0,
52
+ messageCount: 0,
53
+ },
54
+ });
55
+ tui.requestRender();
56
+ }
57
+ }
58
+
59
+ void load();
60
+
61
+ return {
62
+ render: (width: number) => component.render(width),
63
+ invalidate: () => component.invalidate(),
64
+ handleInput: (data: string) => component.handleInput(data),
65
+ dispose: () => {
66
+ controller.abort();
67
+ component.destroy();
68
+ },
69
+ };
70
+ });
71
+
72
+ // Fallback for non-interactive mode
73
+ if (result === undefined) {
74
+ const aggregateResult = await aggregateAllSessions({ cwd });
75
+ const { totals } = aggregateResult;
76
+ const lines = [
77
+ `Token Usage: ${aggregateResult.sessionCount} sessions, ${aggregateResult.messageCount} messages`,
78
+ ` Input: ${totals.input.toLocaleString()} · Output: ${totals.output.toLocaleString()}`,
79
+ ` Cache Read: ${totals.cacheRead.toLocaleString()} · Cache Write: ${totals.cacheWrite.toLocaleString()}`,
80
+ ` Total: ${totals.totalTokens.toLocaleString()} tokens · $${totals.costTotal.toFixed(2)}`,
81
+ ];
82
+ for (const model of aggregateResult.byModel) {
83
+ lines.push(
84
+ ` ${model.provider}/${model.model}: $${model.tokens.costTotal.toFixed(2)} (${model.messageCount} msgs)`,
85
+ );
86
+ }
87
+ ctx.ui.notify(lines.join("\n"), "info");
88
+ }
89
+ }
90
+
91
+ export function registerTokensCommand(pi: ExtensionAPI): void {
92
+ pi.registerCommand("tokens", {
93
+ description: "Display token usage and cost across all sessions",
94
+ handler: async (_args, ctx) => {
95
+ await openTokensView(ctx, ctx.cwd);
96
+ },
97
+ });
98
+ }
99
+
100
+ export default async function (pi: ExtensionAPI) {
101
+ registerTokensCommand(pi);
102
+ }
@@ -0,0 +1 @@
1
+ export { default } from "./command.js";