@latentminds/pi-quotas 0.2.4 → 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 +34 -23
- package/package.json +25 -5
- package/src/config.ts +43 -13
- package/src/extensions/command-quotas/command.ts +23 -1
- package/src/extensions/command-quotas/components/quotas-display.test.ts +154 -0
- package/src/extensions/command-quotas/components/quotas-display.ts +13 -10
- package/src/extensions/command-quotas/provider-commands.test.ts +9 -0
- package/src/extensions/command-quotas/provider-commands.ts +12 -0
- package/src/extensions/command-tokens/command.ts +102 -0
- package/src/extensions/command-tokens/index.ts +1 -0
- package/src/extensions/command-tokens/tokens-display.ts +357 -0
- package/src/extensions/quota-warnings/index.ts +47 -19
- package/src/extensions/token-status/index.ts +241 -0
- package/src/extensions/token-status/provider-detection.test.ts +30 -0
- package/src/extensions/usage-status/index.test.ts +182 -0
- package/src/extensions/usage-status/index.ts +114 -44
- package/src/lib/quotas.ts +12 -1
- package/src/lib/session-tokens.test.ts +137 -0
- package/src/lib/session-tokens.ts +399 -0
- package/src/providers/fetch.test.ts +71 -0
- package/src/providers/fetch.ts +200 -17
- package/src/providers/opencode-go-config.ts +127 -0
- package/src/providers/opencode-go.ts +183 -0
- package/src/providers/parse.test.ts +185 -7
- package/src/providers/providers.ts +229 -21
- package/src/types/quotas.ts +12 -3
- package/src/utils/quotas-severity.test.ts +22 -2
- package/src/utils/quotas-severity.ts +28 -6
package/README.md
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# @latentminds/pi-quotas
|
|
2
2
|
|
|
3
|
-
Quota monitoring for
|
|
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
|
|
|
7
|
+
|
|
7
8
|
| `/quotas` dashboard | Footer status |
|
|
8
|
-
|
|
9
|
-
|
|
|
9
|
+
| ------------------- | ------------- |
|
|
10
|
+
| Quotas dashboard | Footer status |
|
|
11
|
+
|
|
10
12
|
|
|
11
13
|
## Install
|
|
12
14
|
|
|
@@ -31,15 +33,20 @@ pi -e npm:@latentminds/pi-quotas
|
|
|
31
33
|
|
|
32
34
|
## Commands
|
|
33
35
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
|
37
|
-
| `/
|
|
38
|
-
| `/
|
|
39
|
-
| `/
|
|
40
|
-
| `/
|
|
41
|
-
| `/
|
|
42
|
-
| `/quotas
|
|
36
|
+
|
|
37
|
+
| Command | Description |
|
|
38
|
+
| -------------------- | ------------------------------------------ |
|
|
39
|
+
| `/quotas` | Combined quota dashboard for all providers |
|
|
40
|
+
| `/anthropic:quotas` | Anthropic quotas only |
|
|
41
|
+
| `/codex:quotas` | OpenAI Codex quotas only |
|
|
42
|
+
| `/github:quotas` | GitHub Copilot quotas only |
|
|
43
|
+
| `/openrouter:quotas` | OpenRouter quotas only |
|
|
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 |
|
|
48
|
+
| `/quotas:settings` | Toggle individual features on or off |
|
|
49
|
+
|
|
43
50
|
|
|
44
51
|
## Features
|
|
45
52
|
|
|
@@ -58,8 +65,9 @@ Automatic notifications when projected usage is on track to exceed limits before
|
|
|
58
65
|
### Per-feature toggles
|
|
59
66
|
|
|
60
67
|
Use `/quotas:settings` to enable or disable:
|
|
68
|
+
|
|
61
69
|
- Combined `/quotas` command
|
|
62
|
-
- 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`)
|
|
63
71
|
- Footer status widget
|
|
64
72
|
- Quota warning notifications
|
|
65
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.
|
|
@@ -68,13 +76,17 @@ Settings can be saved globally (`~/.pi/agent/extensions/quotas.json`) or per-pro
|
|
|
68
76
|
|
|
69
77
|
## Supported providers
|
|
70
78
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
|
74
|
-
|
|
|
75
|
-
|
|
|
76
|
-
|
|
|
77
|
-
|
|
|
79
|
+
|
|
80
|
+
| Provider | Windows | Details |
|
|
81
|
+
| -------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
|
82
|
+
| Anthropic | 5h, 7d, per-model 7d, extra usage | Utilization percentages; optional overage budget in local currency |
|
|
83
|
+
| OpenAI Codex | 5h, 7d, credits, spend cap | Rate-limit percentages; credit balance; spend-cap reached/OK |
|
|
84
|
+
| GitHub Copilot | Premium/chat/completions per month | Remaining/entitlement counts with overage indicators |
|
|
85
|
+
| OpenRouter | Monthly budget, daily/weekly/monthly usage | USD spending tracking with cents precision; optional per-key budget limits; UTC-based period resets |
|
|
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 |
|
|
89
|
+
|
|
78
90
|
|
|
79
91
|
## Credentials
|
|
80
92
|
|
|
@@ -85,6 +97,8 @@ pi-quotas reads existing Pi auth entries from `~/.pi/agent/auth.json`:
|
|
|
85
97
|
- `github-copilot` — GitHub Copilot OAuth token (falls back to `gh auth token` if needed)
|
|
86
98
|
- `openrouter` — OpenRouter API key (Bearer token)
|
|
87
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)
|
|
88
102
|
|
|
89
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.
|
|
90
104
|
|
|
@@ -104,6 +118,3 @@ See [CHANGELOG.md](CHANGELOG.md) for release notes and recent changes.
|
|
|
104
118
|
|
|
105
119
|
This project was inspired by [@aliou/pi-synthetic](https://www.npmjs.com/package/@aliou/pi-synthetic).
|
|
106
120
|
|
|
107
|
-
<p align="center">
|
|
108
|
-
<img src="docs/latent-minds@2x.png" alt="Latent Minds" width="320" />
|
|
109
|
-
</p>
|
package/package.json
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@latentminds/pi-quotas",
|
|
3
|
-
"version": "0.
|
|
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": [
|
|
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": [
|
|
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": {
|
|
29
|
-
|
|
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:
|
|
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:
|
|
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(
|
|
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"))
|
|
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:
|
|
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:
|
|
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)
|
|
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) =>
|
|
237
|
+
const feature = FEATURE_META.find((item) =>
|
|
238
|
+
selected.startsWith(item.label),
|
|
239
|
+
);
|
|
218
240
|
if (!feature) continue;
|
|
219
|
-
if (
|
|
220
|
-
|
|
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] = !(
|
|
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(
|
|
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",
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import pkg from "../../../../package.json" with { type: "json" };
|
|
3
|
+
import { QuotasComponent } from "./quotas-display.js";
|
|
4
|
+
|
|
5
|
+
const ansi = {
|
|
6
|
+
accent: "\x1b[32m",
|
|
7
|
+
border: "\x1b[36m",
|
|
8
|
+
dim: "\x1b[2m",
|
|
9
|
+
error: "\x1b[31m",
|
|
10
|
+
muted: "\x1b[90m",
|
|
11
|
+
success: "\x1b[32m",
|
|
12
|
+
warning: "\x1b[33m",
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
function fakeTheme() {
|
|
16
|
+
return {
|
|
17
|
+
bold: (text: string) => text,
|
|
18
|
+
fg: (color: keyof typeof ansi, text: string) => `${ansi[color] ?? ""}${text}\x1b[0m`,
|
|
19
|
+
getFgAnsi: (color: keyof typeof ansi) => ansi[color] ?? "",
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function makeComponent(): QuotasComponent {
|
|
24
|
+
return new QuotasComponent(
|
|
25
|
+
fakeTheme() as any,
|
|
26
|
+
{ requestRender: () => {} } as any,
|
|
27
|
+
"Quotas",
|
|
28
|
+
() => {},
|
|
29
|
+
() => {},
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("QuotasComponent", () => {
|
|
34
|
+
it("renders the pi-quotas package version in the dashboard footer", () => {
|
|
35
|
+
const component = makeComponent();
|
|
36
|
+
component.setState({ type: "loaded", snapshots: [] });
|
|
37
|
+
|
|
38
|
+
expect(component.render(70).join("\n")).toContain(`pi-quotas v${pkg.version}`);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("does not render an accent-colored pace marker inside filled warning bars", () => {
|
|
42
|
+
vi.useFakeTimers();
|
|
43
|
+
vi.setSystemTime(new Date("2026-05-14T13:42:11Z"));
|
|
44
|
+
|
|
45
|
+
const component = makeComponent();
|
|
46
|
+
component.setState({
|
|
47
|
+
type: "loaded",
|
|
48
|
+
snapshots: [
|
|
49
|
+
{
|
|
50
|
+
provider: "github-copilot",
|
|
51
|
+
result: {
|
|
52
|
+
success: true,
|
|
53
|
+
data: {
|
|
54
|
+
provider: "github-copilot",
|
|
55
|
+
windows: [
|
|
56
|
+
{
|
|
57
|
+
provider: "github-copilot",
|
|
58
|
+
label: "Premium / month",
|
|
59
|
+
usedPercent: 83,
|
|
60
|
+
resetsAt: new Date("2026-06-01T10:00:00Z"),
|
|
61
|
+
windowSeconds: 31 * 24 * 3600,
|
|
62
|
+
usedValue: 249,
|
|
63
|
+
limitValue: 300,
|
|
64
|
+
showPace: true,
|
|
65
|
+
nextLabel: "Resets",
|
|
66
|
+
nextAmount: "overage allowed",
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const output = component.render(70).join("\n");
|
|
76
|
+
|
|
77
|
+
expect(output).toContain("51/300 left");
|
|
78
|
+
expect(output).not.toContain(`${ansi.accent}|`);
|
|
79
|
+
|
|
80
|
+
vi.useRealTimers();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("does not render a pace marker for zero-usage windows", () => {
|
|
84
|
+
vi.useFakeTimers();
|
|
85
|
+
vi.setSystemTime(new Date("2026-05-14T13:49:29Z"));
|
|
86
|
+
|
|
87
|
+
const component = makeComponent();
|
|
88
|
+
component.setState({
|
|
89
|
+
type: "loaded",
|
|
90
|
+
snapshots: [
|
|
91
|
+
{
|
|
92
|
+
provider: "synthetic",
|
|
93
|
+
result: {
|
|
94
|
+
success: true,
|
|
95
|
+
data: {
|
|
96
|
+
provider: "synthetic",
|
|
97
|
+
windows: [
|
|
98
|
+
{
|
|
99
|
+
provider: "synthetic",
|
|
100
|
+
label: "Credits / week",
|
|
101
|
+
usedPercent: 0,
|
|
102
|
+
resetsAt: new Date("2026-05-14T16:40:29Z"),
|
|
103
|
+
windowSeconds: 7 * 24 * 3600,
|
|
104
|
+
usedValue: 0,
|
|
105
|
+
limitValue: 24,
|
|
106
|
+
isCurrency: true,
|
|
107
|
+
showPace: true,
|
|
108
|
+
nextLabel: "Next regen",
|
|
109
|
+
nextAmount: "+$0.48",
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const output = component.render(70).join("\n");
|
|
119
|
+
|
|
120
|
+
expect(output).toContain("$0.00 / $24.00");
|
|
121
|
+
expect(output).not.toContain("|");
|
|
122
|
+
|
|
123
|
+
vi.useRealTimers();
|
|
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
|
+
});
|
|
154
|
+
});
|
|
@@ -2,6 +2,7 @@ import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
|
2
2
|
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
|
|
3
3
|
import type { Component } from "@mariozechner/pi-tui";
|
|
4
4
|
import { Loader, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
|
|
5
|
+
import pkg from "../../../../package.json" with { type: "json" };
|
|
5
6
|
import { PROVIDER_LABELS } from "../../../lib/quotas.js";
|
|
6
7
|
import type { QuotasResult, SupportedQuotaProvider } from "../../../types/quotas.js";
|
|
7
8
|
import {
|
|
@@ -19,10 +20,6 @@ type QuotasState =
|
|
|
19
20
|
| { type: "loading" }
|
|
20
21
|
| { type: "loaded"; snapshots: Snapshot[] };
|
|
21
22
|
|
|
22
|
-
function fgAnsiToBg(fgAnsi: string): string {
|
|
23
|
-
return fgAnsi.split("[38;").join("[48;").replace(/\[3([0-9])m/g, "[4$1m");
|
|
24
|
-
}
|
|
25
|
-
|
|
26
23
|
function renderProgressBar(
|
|
27
24
|
percent: number,
|
|
28
25
|
width: number,
|
|
@@ -33,6 +30,7 @@ function renderProgressBar(
|
|
|
33
30
|
const clamped = Math.max(0, Math.min(100, Math.round(percent)));
|
|
34
31
|
const filled = Math.round((clamped / 100) * width);
|
|
35
32
|
const showPace =
|
|
33
|
+
percent > 0 &&
|
|
36
34
|
pacePercent !== null &&
|
|
37
35
|
pacePercent !== undefined &&
|
|
38
36
|
pacePercent >= 5 &&
|
|
@@ -40,15 +38,13 @@ function renderProgressBar(
|
|
|
40
38
|
const paceIndex = showPace
|
|
41
39
|
? Math.min(width - 1, Math.round((Math.max(0, Math.min(100, pacePercent ?? 0)) / 100) * width))
|
|
42
40
|
: null;
|
|
43
|
-
const reset = "\x1b[0m";
|
|
44
41
|
const parts: string[] = [];
|
|
45
42
|
for (let idx = 0; idx < width; idx++) {
|
|
46
43
|
if (paceIndex !== null && idx === paceIndex) {
|
|
47
|
-
const markerColor = idx < filled ? "accent" : fillColor;
|
|
48
44
|
if (idx < filled) {
|
|
49
|
-
parts.push(
|
|
45
|
+
parts.push(theme.fg(fillColor, "█"));
|
|
50
46
|
} else {
|
|
51
|
-
parts.push(theme.fg(
|
|
47
|
+
parts.push(theme.fg(fillColor, "|"));
|
|
52
48
|
}
|
|
53
49
|
} else if (idx < filled) {
|
|
54
50
|
parts.push(theme.fg(fillColor, "█"));
|
|
@@ -123,7 +119,7 @@ export class QuotasComponent implements Component {
|
|
|
123
119
|
}
|
|
124
120
|
|
|
125
121
|
lines.push("");
|
|
126
|
-
lines.push(this.theme.fg("dim",
|
|
122
|
+
lines.push(this.theme.fg("dim", ` pi-quotas v${pkg.version} · r to refresh q/Esc to close`));
|
|
127
123
|
lines.push(...border.render(width));
|
|
128
124
|
return lines;
|
|
129
125
|
}
|
|
@@ -144,7 +140,14 @@ export class QuotasComponent implements Component {
|
|
|
144
140
|
lines.push(truncateToWidth(` ${this.theme.fg("accent", title)}`, maxWidth));
|
|
145
141
|
|
|
146
142
|
if (!snapshot.result.success) {
|
|
147
|
-
|
|
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
|
+
);
|
|
148
151
|
return lines;
|
|
149
152
|
}
|
|
150
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
|
}
|