@latentminds/pi-quotas 0.2.3 → 0.2.6
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 +27 -22
- package/package.json +1 -1
- package/src/config.ts +14 -3
- package/src/extensions/command-quotas/components/quotas-display.test.ts +125 -0
- package/src/extensions/command-quotas/components/quotas-display.ts +5 -8
- package/src/extensions/quota-warnings/index.ts +11 -5
- package/src/extensions/usage-status/format-status.ts +17 -1
- package/src/extensions/usage-status/index.ts +56 -8
- package/src/providers/fetch.test.ts +40 -0
- package/src/providers/fetch.ts +38 -0
- package/src/providers/parse.test.ts +131 -0
- package/src/providers/providers.ts +47 -50
- package/src/utils/quotas-severity.test.ts +22 -2
- package/src/utils/quotas-severity.ts +15 -5
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, and Synthetic — 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,17 @@ 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
|
+
| `/quotas:settings` | Toggle individual features on or off |
|
|
46
|
+
|
|
43
47
|
|
|
44
48
|
## Features
|
|
45
49
|
|
|
@@ -58,22 +62,26 @@ Automatic notifications when projected usage is on track to exceed limits before
|
|
|
58
62
|
### Per-feature toggles
|
|
59
63
|
|
|
60
64
|
Use `/quotas:settings` to enable or disable:
|
|
65
|
+
|
|
61
66
|
- Combined `/quotas` command
|
|
62
67
|
- Per-provider commands (`/anthropic:quotas`, `/codex:quotas`, `/github:quotas`, `/openrouter:quotas`, `/synthetic:quotas`)
|
|
63
68
|
- Footer status widget
|
|
64
69
|
- Quota warning notifications
|
|
70
|
+
- **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.
|
|
65
71
|
|
|
66
72
|
Settings can be saved globally (`~/.pi/agent/extensions/quotas.json`) or per-project (`.pi/quotas.json`). Run `/reload` after changing command visibility.
|
|
67
73
|
|
|
68
74
|
## Supported providers
|
|
69
75
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
|
73
|
-
|
|
|
74
|
-
|
|
|
75
|
-
|
|
|
76
|
-
|
|
|
76
|
+
|
|
77
|
+
| Provider | Windows | Details |
|
|
78
|
+
| -------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
|
79
|
+
| Anthropic | 5h, 7d, per-model 7d, extra usage | Utilization percentages; optional overage budget in local currency |
|
|
80
|
+
| OpenAI Codex | 5h, 7d, credits, spend cap | Rate-limit percentages; credit balance; spend-cap reached/OK |
|
|
81
|
+
| GitHub Copilot | Premium/chat/completions per month | Remaining/entitlement counts with overage indicators |
|
|
82
|
+
| OpenRouter | Monthly budget, daily/weekly/monthly usage | USD spending tracking with cents precision; optional per-key budget limits; UTC-based period resets |
|
|
83
|
+
| Synthetic | Subscription, search/hour, free tools, weekly tokens, 5h limit | Request counts and token budgets; rolling five-hour rate limit; weekly token regen |
|
|
84
|
+
|
|
77
85
|
|
|
78
86
|
## Credentials
|
|
79
87
|
|
|
@@ -103,6 +111,3 @@ See [CHANGELOG.md](CHANGELOG.md) for release notes and recent changes.
|
|
|
103
111
|
|
|
104
112
|
This project was inspired by [@aliou/pi-synthetic](https://www.npmjs.com/package/@aliou/pi-synthetic).
|
|
105
113
|
|
|
106
|
-
<p align="center">
|
|
107
|
-
<img src="docs/latent-minds@2x.png" alt="Latent Minds" width="320" />
|
|
108
|
-
</p>
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -9,7 +9,8 @@ export type QuotasFeatureId =
|
|
|
9
9
|
| "quotasCommand"
|
|
10
10
|
| "providerCommands"
|
|
11
11
|
| "usageStatus"
|
|
12
|
-
| "quotaWarnings"
|
|
12
|
+
| "quotaWarnings"
|
|
13
|
+
| "deferToSynthetic";
|
|
13
14
|
|
|
14
15
|
export const QUOTAS_EXTENSIONS_REQUEST_EVENT =
|
|
15
16
|
"quotas:extensions:request" as const;
|
|
@@ -28,6 +29,8 @@ export interface QuotasConfig {
|
|
|
28
29
|
providerCommands?: boolean;
|
|
29
30
|
usageStatus?: boolean;
|
|
30
31
|
quotaWarnings?: boolean;
|
|
32
|
+
/** When true and pi-synthetic's usage footer is active, hide pi-quotas' Synthetic footer. */
|
|
33
|
+
deferToSynthetic?: boolean;
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
export interface ResolvedQuotasConfig {
|
|
@@ -36,6 +39,7 @@ export interface ResolvedQuotasConfig {
|
|
|
36
39
|
providerCommands: boolean;
|
|
37
40
|
usageStatus: boolean;
|
|
38
41
|
quotaWarnings: boolean;
|
|
42
|
+
deferToSynthetic: boolean;
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
const DEFAULT_CONFIG: ResolvedQuotasConfig = {
|
|
@@ -44,6 +48,7 @@ const DEFAULT_CONFIG: ResolvedQuotasConfig = {
|
|
|
44
48
|
providerCommands: true,
|
|
45
49
|
usageStatus: true,
|
|
46
50
|
quotaWarnings: true,
|
|
51
|
+
deferToSynthetic: true,
|
|
47
52
|
};
|
|
48
53
|
|
|
49
54
|
let pendingMigrationNotice = false;
|
|
@@ -79,6 +84,7 @@ class QuotasConfigStore {
|
|
|
79
84
|
providerCommands: input?.providerCommands ?? DEFAULT_CONFIG.providerCommands,
|
|
80
85
|
usageStatus: input?.usageStatus ?? DEFAULT_CONFIG.usageStatus,
|
|
81
86
|
quotaWarnings: input?.quotaWarnings ?? DEFAULT_CONFIG.quotaWarnings,
|
|
87
|
+
deferToSynthetic: input?.deferToSynthetic ?? DEFAULT_CONFIG.deferToSynthetic,
|
|
82
88
|
};
|
|
83
89
|
}
|
|
84
90
|
|
|
@@ -164,6 +170,11 @@ const FEATURE_META: Array<{
|
|
|
164
170
|
label: "Quota warnings",
|
|
165
171
|
description: "Toggle projected-usage warning notifications",
|
|
166
172
|
},
|
|
173
|
+
{
|
|
174
|
+
id: "deferToSynthetic",
|
|
175
|
+
label: "Defer to Synthetic",
|
|
176
|
+
description: "When pi-synthetic is loaded, hide pi-quotas' Synthetic footer to avoid duplicates",
|
|
177
|
+
},
|
|
167
178
|
];
|
|
168
179
|
|
|
169
180
|
export function registerQuotasSettings(
|
|
@@ -205,11 +216,11 @@ export function registerQuotasSettings(
|
|
|
205
216
|
|
|
206
217
|
const feature = FEATURE_META.find((item) => selected.startsWith(item.label));
|
|
207
218
|
if (!feature) continue;
|
|
208
|
-
if (!getLoadedFeatures().has(feature.id)) {
|
|
219
|
+
if (feature.id !== "deferToSynthetic" && !getLoadedFeatures().has(feature.id)) {
|
|
209
220
|
ctx.ui.notify(`${feature.label} is not loaded by Pi in this session.`, "warning");
|
|
210
221
|
continue;
|
|
211
222
|
}
|
|
212
|
-
draft[feature.id] = !draft[feature.id];
|
|
223
|
+
(draft as unknown as Record<string, boolean>)[feature.id] = !(draft as unknown as Record<string, boolean>)[feature.id];
|
|
213
224
|
}
|
|
214
225
|
},
|
|
215
226
|
});
|
|
@@ -0,0 +1,125 @@
|
|
|
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
|
+
});
|
|
@@ -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 &&
|
|
@@ -44,11 +42,10 @@ function renderProgressBar(
|
|
|
44
42
|
const parts: string[] = [];
|
|
45
43
|
for (let idx = 0; idx < width; idx++) {
|
|
46
44
|
if (paceIndex !== null && idx === paceIndex) {
|
|
47
|
-
const markerColor = idx < filled ? "accent" : fillColor;
|
|
48
45
|
if (idx < filled) {
|
|
49
|
-
parts.push(
|
|
46
|
+
parts.push(theme.fg(fillColor, "█"));
|
|
50
47
|
} else {
|
|
51
|
-
parts.push(theme.fg(
|
|
48
|
+
parts.push(theme.fg(fillColor, "|"));
|
|
52
49
|
}
|
|
53
50
|
} else if (idx < filled) {
|
|
54
51
|
parts.push(theme.fg(fillColor, "█"));
|
|
@@ -123,7 +120,7 @@ export class QuotasComponent implements Component {
|
|
|
123
120
|
}
|
|
124
121
|
|
|
125
122
|
lines.push("");
|
|
126
|
-
lines.push(this.theme.fg("dim",
|
|
123
|
+
lines.push(this.theme.fg("dim", ` pi-quotas v${pkg.version} · r to refresh q/Esc to close`));
|
|
127
124
|
lines.push(...border.render(width));
|
|
128
125
|
return lines;
|
|
129
126
|
}
|
|
@@ -85,6 +85,12 @@ export default async function (pi: ExtensionAPI) {
|
|
|
85
85
|
ctx.ui.notify(`${providerName} quota warning:\n${lines.join("\n")}`, level);
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
function scheduleCheck(ctx: ExtensionContext, onlyNew: boolean): void {
|
|
89
|
+
void check(ctx, onlyNew).catch(() => {
|
|
90
|
+
// Quota warnings are opportunistic; never let a failed quota check block Pi events.
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
88
94
|
pi.events.on(QUOTAS_CONFIG_UPDATED_EVENT, (data: unknown) => {
|
|
89
95
|
enabled = (data as QuotasConfigUpdatedPayload).config.quotaWarnings;
|
|
90
96
|
if (!enabled) {
|
|
@@ -93,21 +99,21 @@ export default async function (pi: ExtensionAPI) {
|
|
|
93
99
|
}
|
|
94
100
|
if (currentContext) {
|
|
95
101
|
clearAlertState();
|
|
96
|
-
|
|
102
|
+
scheduleCheck(currentContext, false);
|
|
97
103
|
}
|
|
98
104
|
});
|
|
99
105
|
|
|
100
|
-
pi.on("session_start",
|
|
106
|
+
pi.on("session_start", (_event, ctx) => {
|
|
101
107
|
currentContext = ctx;
|
|
102
108
|
clearAlertState();
|
|
103
109
|
if (!enabled) return;
|
|
104
|
-
|
|
110
|
+
scheduleCheck(ctx, false);
|
|
105
111
|
});
|
|
106
112
|
|
|
107
|
-
pi.on("turn_end",
|
|
113
|
+
pi.on("turn_end", (_event, ctx) => {
|
|
108
114
|
currentContext = ctx;
|
|
109
115
|
if (!enabled) return;
|
|
110
|
-
|
|
116
|
+
scheduleCheck(ctx, true);
|
|
111
117
|
});
|
|
112
118
|
|
|
113
119
|
pi.on("model_select", async (_event, ctx) => {
|
|
@@ -37,6 +37,11 @@ const SHORT_LABELS: Record<string, string> = {
|
|
|
37
37
|
"Daily": "daily",
|
|
38
38
|
"Weekly": "weekly",
|
|
39
39
|
"Monthly": "monthly",
|
|
40
|
+
// Synthetic labels (match pi-synthetic extension)
|
|
41
|
+
"Credits / week": "week",
|
|
42
|
+
"Requests / 5h": "5h",
|
|
43
|
+
"Search / hour": "search",
|
|
44
|
+
"Free Tool Calls / day": "tools",
|
|
40
45
|
};
|
|
41
46
|
|
|
42
47
|
/**
|
|
@@ -68,8 +73,19 @@ export function formatWindowStatus(theme: ThemeLike, w: WindowStatus): string {
|
|
|
68
73
|
const labelColor = isAtRisk ? color : "dim";
|
|
69
74
|
const labelText = theme.fg(labelColor, `${short}:`);
|
|
70
75
|
|
|
76
|
+
// Synthetic windows always use compact "remaining%" format
|
|
77
|
+
// to match the pi-synthetic extension display
|
|
78
|
+
const SYNTHETIC_LABELS = new Set([
|
|
79
|
+
"Credits / week", "Requests / 5h", "Search / hour", "Free Tool Calls / day",
|
|
80
|
+
]);
|
|
81
|
+
const isSynthetic = SYNTHETIC_LABELS.has(w.label);
|
|
82
|
+
|
|
71
83
|
let valueText: string;
|
|
72
|
-
if (
|
|
84
|
+
if (isSynthetic) {
|
|
85
|
+
// Compact format matching pi-synthetic: just remaining%
|
|
86
|
+
const remaining = Math.max(0, Math.min(100, Math.round(100 - w.usedPercent)));
|
|
87
|
+
valueText = theme.fg(color, `${remaining}%`);
|
|
88
|
+
} else if (w.label === "Spend cap") {
|
|
73
89
|
valueText = theme.fg(color, w.limited ? "REACHED" : "OK");
|
|
74
90
|
} else if (w.isCurrency && w.usedValue != null && w.limitValue != null) {
|
|
75
91
|
// Tracking-only windows have limitValue=0, show just usage
|
|
@@ -9,6 +9,12 @@ import {
|
|
|
9
9
|
type QuotasConfigUpdatedPayload,
|
|
10
10
|
configLoader,
|
|
11
11
|
} from "../../config.js";
|
|
12
|
+
|
|
13
|
+
/** Event emitted by pi-synthetic when its usage-status extension registers. */
|
|
14
|
+
const SYNTHETIC_EXTENSIONS_REGISTER_EVENT = "synthetic:extensions:register";
|
|
15
|
+
interface SyntheticExtensionsRegisterPayload {
|
|
16
|
+
feature: string;
|
|
17
|
+
}
|
|
12
18
|
import {
|
|
13
19
|
fetchProviderQuotas,
|
|
14
20
|
isSupportedProvider,
|
|
@@ -152,44 +158,86 @@ export default async function (pi: ExtensionAPI) {
|
|
|
152
158
|
await configLoader.load();
|
|
153
159
|
const refresher = createStatusRefresher();
|
|
154
160
|
let enabled = configLoader.getConfig().usageStatus;
|
|
161
|
+
let deferToSynthetic = configLoader.getConfig().deferToSynthetic;
|
|
155
162
|
let currentContext: ExtensionContext | undefined;
|
|
156
163
|
|
|
164
|
+
/** Whether pi-synthetic's usage footer is active in this session. */
|
|
165
|
+
let syntheticUsageActive = false;
|
|
166
|
+
|
|
167
|
+
pi.events.on(SYNTHETIC_EXTENSIONS_REGISTER_EVENT, (data: unknown) => {
|
|
168
|
+
const { feature } = data as SyntheticExtensionsRegisterPayload;
|
|
169
|
+
if (feature === "usageStatus") {
|
|
170
|
+
syntheticUsageActive = true;
|
|
171
|
+
// If currently showing synthetic data, clear our footer
|
|
172
|
+
if (currentContext && enabled && deferToSynthetic && currentContext.model?.provider === "synthetic") {
|
|
173
|
+
currentContext.ui.setStatus(EXTENSION_ID, undefined);
|
|
174
|
+
refresher.stop();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
function scheduleRefresh(ctx: ExtensionContext): void {
|
|
180
|
+
void refresher.refreshFor(ctx).catch(() => {
|
|
181
|
+
if (ctx.hasUI) ctx.ui.setStatus(EXTENSION_ID, ctx.ui.theme.fg("warning", "usage unavailable"));
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
157
185
|
pi.events.on(QUOTAS_CONFIG_UPDATED_EVENT, (data: unknown) => {
|
|
158
|
-
|
|
186
|
+
const config = (data as QuotasConfigUpdatedPayload).config;
|
|
187
|
+
enabled = config.usageStatus;
|
|
188
|
+
deferToSynthetic = config.deferToSynthetic;
|
|
159
189
|
if (!enabled) {
|
|
160
190
|
refresher.stop(currentContext);
|
|
161
191
|
return;
|
|
162
192
|
}
|
|
163
193
|
if (currentContext) {
|
|
164
194
|
refresher.start();
|
|
165
|
-
|
|
195
|
+
scheduleRefresh(currentContext);
|
|
166
196
|
}
|
|
167
197
|
});
|
|
168
198
|
|
|
169
|
-
|
|
199
|
+
/**
|
|
200
|
+
* Whether to suppress our footer because pi-synthetic is showing
|
|
201
|
+
* the same data for the Synthetic provider.
|
|
202
|
+
*/
|
|
203
|
+
function shouldDeferToSynthetic(provider: string | undefined): boolean {
|
|
204
|
+
return deferToSynthetic && syntheticUsageActive && provider === "synthetic";
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
pi.on("session_start", (_event, ctx) => {
|
|
170
208
|
currentContext = ctx;
|
|
171
209
|
if (!enabled) return;
|
|
210
|
+
if (shouldDeferToSynthetic(ctx.model?.provider)) {
|
|
211
|
+
ctx.ui.setStatus(EXTENSION_ID, undefined);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
172
214
|
refresher.start();
|
|
173
|
-
|
|
215
|
+
scheduleRefresh(ctx);
|
|
174
216
|
});
|
|
175
217
|
|
|
176
|
-
pi.on("turn_end",
|
|
218
|
+
pi.on("turn_end", (_event, ctx) => {
|
|
177
219
|
currentContext = ctx;
|
|
178
220
|
if (!enabled) return;
|
|
179
|
-
|
|
221
|
+
if (shouldDeferToSynthetic(ctx.model?.provider)) return;
|
|
222
|
+
scheduleRefresh(ctx);
|
|
180
223
|
});
|
|
181
224
|
|
|
182
|
-
pi.on("model_select",
|
|
225
|
+
pi.on("model_select", (_event, ctx) => {
|
|
183
226
|
currentContext = ctx;
|
|
184
227
|
if (!enabled) {
|
|
185
228
|
refresher.stop(ctx);
|
|
186
229
|
return;
|
|
187
230
|
}
|
|
188
|
-
|
|
231
|
+
if (shouldDeferToSynthetic(ctx.model?.provider)) {
|
|
232
|
+
ctx.ui.setStatus(EXTENSION_ID, undefined);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
scheduleRefresh(ctx);
|
|
189
236
|
});
|
|
190
237
|
|
|
191
238
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
192
239
|
currentContext = undefined;
|
|
240
|
+
syntheticUsageActive = false;
|
|
193
241
|
refresher.stop(ctx);
|
|
194
242
|
});
|
|
195
243
|
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { AuthStorage } from "@mariozechner/pi-coding-agent";
|
|
1
2
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
3
|
import {
|
|
3
4
|
fetchAnthropicQuotasWithToken,
|
|
4
5
|
fetchCodexQuotasWithToken,
|
|
6
|
+
fetchGitHubCopilotQuotas,
|
|
5
7
|
fetchGitHubCopilotQuotasWithToken,
|
|
6
8
|
fetchOpenRouterQuotasWithToken,
|
|
7
9
|
} from "./fetch.js";
|
|
@@ -135,6 +137,44 @@ describe("fetchGitHubCopilotQuotasWithToken", () => {
|
|
|
135
137
|
expect(result.success).toBe(true);
|
|
136
138
|
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
|
|
137
139
|
});
|
|
140
|
+
|
|
141
|
+
it("uses the stored GitHub OAuth refresh token for Pi 0.74 Copilot quota checks", async () => {
|
|
142
|
+
const auth = AuthStorage.inMemory({
|
|
143
|
+
"github-copilot": {
|
|
144
|
+
type: "oauth",
|
|
145
|
+
refresh: "ghu-refresh-token",
|
|
146
|
+
access: "tid=abc;proxy-ep=proxy.individual.githubcopilot.com;exp=1778611280",
|
|
147
|
+
expires: Date.now() + 60_000,
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
globalThis.fetch = vi.fn(async (_url, init) => {
|
|
152
|
+
const authorization = new Headers(init?.headers).get("authorization");
|
|
153
|
+
if (authorization === "Bearer ghu-refresh-token") {
|
|
154
|
+
return new Response(
|
|
155
|
+
JSON.stringify({
|
|
156
|
+
quota_reset_date: "2026-05-01T00:00:00Z",
|
|
157
|
+
quota_snapshots: {
|
|
158
|
+
premium_interactions: { entitlement: 300, remaining: 210 },
|
|
159
|
+
},
|
|
160
|
+
}),
|
|
161
|
+
{ status: 200 },
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
return new Response(JSON.stringify({ message: "Bad credentials" }), { status: 401 });
|
|
165
|
+
}) as any;
|
|
166
|
+
|
|
167
|
+
const result = await fetchGitHubCopilotQuotas(auth);
|
|
168
|
+
|
|
169
|
+
expect(result.success).toBe(true);
|
|
170
|
+
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
|
171
|
+
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
172
|
+
"https://api.github.com/copilot_internal/user",
|
|
173
|
+
expect.objectContaining({
|
|
174
|
+
headers: expect.objectContaining({ Authorization: "Bearer ghu-refresh-token" }),
|
|
175
|
+
}),
|
|
176
|
+
);
|
|
177
|
+
});
|
|
138
178
|
});
|
|
139
179
|
|
|
140
180
|
describe("fetchOpenRouterQuotasWithToken", () => {
|
package/src/providers/fetch.ts
CHANGED
|
@@ -179,6 +179,32 @@ async function tryGitHubUserEndpoint(
|
|
|
179
179
|
);
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
function githubOAuthToken(authStorage: AuthStorage): string | undefined {
|
|
183
|
+
// Pi's GitHub Copilot OAuth credential stores the GitHub OAuth token in
|
|
184
|
+
// `refresh`; `access` is a Copilot proxy token (tid=...;proxy-ep=...) that
|
|
185
|
+
// is valid for model calls but rejected by api.github.com quota endpoints.
|
|
186
|
+
const credential = authStorage.get("github-copilot") as any;
|
|
187
|
+
if (credential?.type !== "oauth") return undefined;
|
|
188
|
+
return typeof credential.refresh === "string" && credential.refresh.length > 0
|
|
189
|
+
? credential.refresh
|
|
190
|
+
: undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function fetchGitHubCopilotQuotasWithGitHubToken(
|
|
194
|
+
githubToken: string | undefined,
|
|
195
|
+
signal?: AbortSignal,
|
|
196
|
+
): Promise<QuotasResult> {
|
|
197
|
+
if (!githubToken) return failure("No GitHub Copilot OAuth token found", "config");
|
|
198
|
+
|
|
199
|
+
const bearerUsage = await tryGitHubUserEndpoint(`Bearer ${githubToken}`, signal);
|
|
200
|
+
if (bearerUsage.ok) return success("github-copilot", parseGitHubCopilotUsage(bearerUsage.data));
|
|
201
|
+
|
|
202
|
+
const tokenUsage = await tryGitHubUserEndpoint(`token ${githubToken}`, signal);
|
|
203
|
+
if (tokenUsage.ok) return success("github-copilot", parseGitHubCopilotUsage(tokenUsage.data));
|
|
204
|
+
|
|
205
|
+
return failure(bearerUsage.message, bearerUsage.kind);
|
|
206
|
+
}
|
|
207
|
+
|
|
182
208
|
export async function fetchGitHubCopilotQuotasWithToken(
|
|
183
209
|
accessToken: string | undefined,
|
|
184
210
|
signal?: AbortSignal,
|
|
@@ -234,6 +260,18 @@ export async function fetchGitHubCopilotQuotas(
|
|
|
234
260
|
authStorage: AuthStorage,
|
|
235
261
|
signal?: AbortSignal,
|
|
236
262
|
): Promise<QuotasResult> {
|
|
263
|
+
const oauthResult = await fetchGitHubCopilotQuotasWithGitHubToken(
|
|
264
|
+
githubOAuthToken(authStorage),
|
|
265
|
+
signal,
|
|
266
|
+
);
|
|
267
|
+
if (
|
|
268
|
+
oauthResult.success ||
|
|
269
|
+
oauthResult.error.kind === "cancelled" ||
|
|
270
|
+
oauthResult.error.kind === "timeout"
|
|
271
|
+
) {
|
|
272
|
+
return oauthResult;
|
|
273
|
+
}
|
|
274
|
+
|
|
237
275
|
return fetchGitHubCopilotQuotasWithToken(
|
|
238
276
|
await providerAccessToken(authStorage, "github-copilot"),
|
|
239
277
|
signal,
|
|
@@ -3,6 +3,7 @@ import { parseAnthropicUsage } from "./providers.js";
|
|
|
3
3
|
import { parseCodexUsage } from "./providers.js";
|
|
4
4
|
import { parseGitHubCopilotUsage } from "./providers.js";
|
|
5
5
|
import { parseOpenRouterUsage } from "./providers.js";
|
|
6
|
+
import { parseSyntheticUsage } from "./providers.js";
|
|
6
7
|
|
|
7
8
|
describe("parseAnthropicUsage", () => {
|
|
8
9
|
it("maps oauth usage response into quota windows", () => {
|
|
@@ -410,3 +411,133 @@ describe("parseOpenRouterUsage", () => {
|
|
|
410
411
|
expect(windows.find((w) => w.label === "Monthly")).toBeDefined();
|
|
411
412
|
});
|
|
412
413
|
});
|
|
414
|
+
|
|
415
|
+
describe("parseSyntheticUsage", () => {
|
|
416
|
+
it("parses a full API response with all windows", () => {
|
|
417
|
+
const windows = parseSyntheticUsage({
|
|
418
|
+
subscription: {
|
|
419
|
+
limit: 500,
|
|
420
|
+
requests: 0,
|
|
421
|
+
renewsAt: "2026-05-06T12:27:17.097Z",
|
|
422
|
+
},
|
|
423
|
+
search: {
|
|
424
|
+
hourly: {
|
|
425
|
+
limit: 250,
|
|
426
|
+
requests: 30,
|
|
427
|
+
renewsAt: "2026-05-06T08:27:17.097Z",
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
freeToolCalls: {
|
|
431
|
+
limit: 0,
|
|
432
|
+
requests: 0,
|
|
433
|
+
renewsAt: "2026-05-07T07:27:17.102Z",
|
|
434
|
+
},
|
|
435
|
+
weeklyTokenLimit: {
|
|
436
|
+
nextRegenAt: "2026-05-06T09:44:14.000Z",
|
|
437
|
+
percentRemaining: 96.39,
|
|
438
|
+
maxCredits: "$24.00",
|
|
439
|
+
remainingCredits: "$23.13",
|
|
440
|
+
nextRegenCredits: "$0.48",
|
|
441
|
+
},
|
|
442
|
+
rollingFiveHourLimit: {
|
|
443
|
+
nextTickAt: "2026-05-06T07:27:51.000Z",
|
|
444
|
+
tickPercent: 0.05,
|
|
445
|
+
remaining: 420,
|
|
446
|
+
max: 500,
|
|
447
|
+
limited: false,
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// subscription is NOT a window (matching pi-synthetic extension)
|
|
452
|
+
expect(windows.find((w) => w.label === "Subscription")).toBeUndefined();
|
|
453
|
+
|
|
454
|
+
// weeklyTokenLimit: 100 - 96.39 = ~3.61%
|
|
455
|
+
const credits = windows.find((w) => w.label === "Credits / week");
|
|
456
|
+
expect(credits).toBeDefined();
|
|
457
|
+
expect(credits!.usedPercent).toBeCloseTo(3.61, 1);
|
|
458
|
+
expect(credits!.isCurrency).toBe(true);
|
|
459
|
+
expect(credits!.limitValue).toBe(24);
|
|
460
|
+
expect(credits!.usedValue).toBeCloseTo(0.87, 1);
|
|
461
|
+
expect(credits!.paceScale).toBe(1 / 7);
|
|
462
|
+
expect(credits!.nextAmount).toBe("+$0.48");
|
|
463
|
+
|
|
464
|
+
// rollingFiveHourLimit: (500-420)/500 = 16%
|
|
465
|
+
const fiveHour = windows.find((w) => w.label === "Requests / 5h");
|
|
466
|
+
expect(fiveHour).toBeDefined();
|
|
467
|
+
expect(fiveHour!.usedPercent).toBe(16);
|
|
468
|
+
expect(fiveHour!.usedValue).toBe(80);
|
|
469
|
+
expect(fiveHour!.limitValue).toBe(500);
|
|
470
|
+
expect(fiveHour!.limited).toBe(false);
|
|
471
|
+
|
|
472
|
+
// search hourly
|
|
473
|
+
const search = windows.find((w) => w.label === "Search / hour");
|
|
474
|
+
expect(search).toBeDefined();
|
|
475
|
+
expect(search!.usedPercent).toBeCloseTo(12, 0);
|
|
476
|
+
expect(search!.usedValue).toBe(30);
|
|
477
|
+
expect(search!.limitValue).toBe(250);
|
|
478
|
+
|
|
479
|
+
// freeToolCalls with limit=0 is NOT shown
|
|
480
|
+
expect(windows.find((w) => w.label === "Free Tool Calls / day")).toBeUndefined();
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it("shows freeToolCalls when limit > 0", () => {
|
|
484
|
+
const windows = parseSyntheticUsage({
|
|
485
|
+
weeklyTokenLimit: {
|
|
486
|
+
nextRegenAt: "2026-05-06T09:44:14.000Z",
|
|
487
|
+
percentRemaining: 50,
|
|
488
|
+
maxCredits: "$10.00",
|
|
489
|
+
remainingCredits: "$5.00",
|
|
490
|
+
nextRegenCredits: "$0.50",
|
|
491
|
+
},
|
|
492
|
+
freeToolCalls: {
|
|
493
|
+
limit: 100,
|
|
494
|
+
requests: 25,
|
|
495
|
+
renewsAt: "2026-05-07T07:27:17.102Z",
|
|
496
|
+
},
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
const tools = windows.find((w) => w.label === "Free Tool Calls / day");
|
|
500
|
+
expect(tools).toBeDefined();
|
|
501
|
+
expect(tools!.usedPercent).toBe(25);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it("parses currency strings like $24.00 correctly", () => {
|
|
505
|
+
const windows = parseSyntheticUsage({
|
|
506
|
+
weeklyTokenLimit: {
|
|
507
|
+
nextRegenAt: "2026-05-06T09:44:14.000Z",
|
|
508
|
+
percentRemaining: 75,
|
|
509
|
+
maxCredits: "$1,234.56",
|
|
510
|
+
remainingCredits: "$925.92",
|
|
511
|
+
nextRegenCredits: "$12.34",
|
|
512
|
+
},
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
const credits = windows.find((w) => w.label === "Credits / week");
|
|
516
|
+
expect(credits).toBeDefined();
|
|
517
|
+
expect(credits!.limitValue).toBe(1234.56);
|
|
518
|
+
expect(credits!.usedValue).toBeCloseTo(308.64, 1);
|
|
519
|
+
expect(credits!.usedPercent).toBe(25); // 100 - 75
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("handles limited state", () => {
|
|
523
|
+
const windows = parseSyntheticUsage({
|
|
524
|
+
rollingFiveHourLimit: {
|
|
525
|
+
nextTickAt: "2026-05-06T07:27:51.000Z",
|
|
526
|
+
tickPercent: 100,
|
|
527
|
+
remaining: 0,
|
|
528
|
+
max: 500,
|
|
529
|
+
limited: true,
|
|
530
|
+
},
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
const fiveHour = windows.find((w) => w.label === "Requests / 5h");
|
|
534
|
+
expect(fiveHour).toBeDefined();
|
|
535
|
+
expect(fiveHour!.usedPercent).toBe(100);
|
|
536
|
+
expect(fiveHour!.limited).toBe(true);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it("returns empty array when no data", () => {
|
|
540
|
+
const windows = parseSyntheticUsage({});
|
|
541
|
+
expect(windows).toHaveLength(0);
|
|
542
|
+
});
|
|
543
|
+
});
|
|
@@ -353,26 +353,55 @@ export function parseOpenRouterUsage(data: any): QuotaWindow[] {
|
|
|
353
353
|
return windows;
|
|
354
354
|
}
|
|
355
355
|
|
|
356
|
+
/** Parse currency strings like "$24.00" to a number */
|
|
357
|
+
function parseCurrency(value: string): number {
|
|
358
|
+
const n = Number(value.replace(/[^0-9.-]/g, ""));
|
|
359
|
+
return Number.isFinite(n) ? n : 0;
|
|
360
|
+
}
|
|
361
|
+
|
|
356
362
|
export function parseSyntheticUsage(data: any): QuotaWindow[] {
|
|
357
363
|
const windows: QuotaWindow[] = [];
|
|
358
364
|
|
|
359
|
-
//
|
|
360
|
-
if (data?.
|
|
365
|
+
// Weekly token/credit limit (primary window for paid plans)
|
|
366
|
+
if (data?.weeklyTokenLimit) {
|
|
367
|
+
const { weeklyTokenLimit } = data.weeklyTokenLimit;
|
|
368
|
+
const limitValue = parseCurrency(data.weeklyTokenLimit.maxCredits);
|
|
369
|
+
const remainingValue = parseCurrency(data.weeklyTokenLimit.remainingCredits);
|
|
361
370
|
windows.push({
|
|
362
371
|
provider: "synthetic",
|
|
363
|
-
label: "
|
|
364
|
-
usedPercent:
|
|
365
|
-
resetsAt: parseDateish(data.
|
|
366
|
-
windowSeconds:
|
|
367
|
-
usedValue:
|
|
368
|
-
limitValue
|
|
372
|
+
label: "Credits / week",
|
|
373
|
+
usedPercent: Math.max(0, Math.min(100, 100 - data.weeklyTokenLimit.percentRemaining)),
|
|
374
|
+
resetsAt: parseDateish(data.weeklyTokenLimit.nextRegenAt),
|
|
375
|
+
windowSeconds: 24 * 60 * 60,
|
|
376
|
+
usedValue: limitValue - remainingValue,
|
|
377
|
+
limitValue,
|
|
378
|
+
isCurrency: true,
|
|
369
379
|
showPace: true,
|
|
370
|
-
|
|
380
|
+
paceScale: 1 / 7,
|
|
381
|
+
nextAmount: `+${data.weeklyTokenLimit.nextRegenCredits}`,
|
|
382
|
+
nextLabel: "Next regen",
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Rolling 5-hour request limit
|
|
387
|
+
if (data?.rollingFiveHourLimit && data.rollingFiveHourLimit.max > 0) {
|
|
388
|
+
const used = data.rollingFiveHourLimit.max - data.rollingFiveHourLimit.remaining;
|
|
389
|
+
windows.push({
|
|
390
|
+
provider: "synthetic",
|
|
391
|
+
label: "Requests / 5h",
|
|
392
|
+
usedPercent: safePercent(used, data.rollingFiveHourLimit.max),
|
|
393
|
+
resetsAt: parseDateish(data.rollingFiveHourLimit.nextTickAt),
|
|
394
|
+
windowSeconds: 5 * 60 * 60,
|
|
395
|
+
usedValue: Math.round(used),
|
|
396
|
+
limitValue: data.rollingFiveHourLimit.max,
|
|
397
|
+
showPace: false,
|
|
398
|
+
limited: data.rollingFiveHourLimit.limited,
|
|
399
|
+
nextLabel: data.rollingFiveHourLimit.limited ? "Limited" : "Resets",
|
|
371
400
|
});
|
|
372
401
|
}
|
|
373
402
|
|
|
374
|
-
// Search hourly
|
|
375
|
-
if (data?.search?.hourly) {
|
|
403
|
+
// Search hourly (only if limit > 0)
|
|
404
|
+
if (data?.search?.hourly?.limit && data.search.hourly.limit > 0) {
|
|
376
405
|
windows.push({
|
|
377
406
|
provider: "synthetic",
|
|
378
407
|
label: "Search / hour",
|
|
@@ -381,59 +410,27 @@ export function parseSyntheticUsage(data: any): QuotaWindow[] {
|
|
|
381
410
|
windowSeconds: 60 * 60,
|
|
382
411
|
usedValue: data.search.hourly.requests,
|
|
383
412
|
limitValue: data.search.hourly.limit,
|
|
384
|
-
showPace:
|
|
413
|
+
showPace: true,
|
|
414
|
+
paceScale: 1,
|
|
385
415
|
nextLabel: "Resets",
|
|
386
416
|
});
|
|
387
417
|
}
|
|
388
418
|
|
|
389
|
-
// Free tool calls
|
|
390
|
-
if (data?.freeToolCalls) {
|
|
419
|
+
// Free tool calls (only if limit > 0)
|
|
420
|
+
if (data?.freeToolCalls?.limit && data.freeToolCalls.limit > 0) {
|
|
391
421
|
windows.push({
|
|
392
422
|
provider: "synthetic",
|
|
393
|
-
label: "Free
|
|
423
|
+
label: "Free Tool Calls / day",
|
|
394
424
|
usedPercent: safePercent(data.freeToolCalls.requests, data.freeToolCalls.limit),
|
|
395
425
|
resetsAt: parseDateish(data.freeToolCalls.renewsAt),
|
|
396
|
-
windowSeconds:
|
|
426
|
+
windowSeconds: 24 * 60 * 60,
|
|
397
427
|
usedValue: data.freeToolCalls.requests,
|
|
398
428
|
limitValue: data.freeToolCalls.limit,
|
|
399
429
|
showPace: true,
|
|
430
|
+
paceScale: 1,
|
|
400
431
|
nextLabel: "Resets",
|
|
401
432
|
});
|
|
402
433
|
}
|
|
403
434
|
|
|
404
|
-
// Weekly token limit
|
|
405
|
-
if (data?.weeklyTokenLimit) {
|
|
406
|
-
const maxCredits = parseFloat(data.weeklyTokenLimit.maxCredits) || 0;
|
|
407
|
-
const remainingCredits = parseFloat(data.weeklyTokenLimit.remainingCredits) || 0;
|
|
408
|
-
windows.push({
|
|
409
|
-
provider: "synthetic",
|
|
410
|
-
label: "Weekly Tokens",
|
|
411
|
-
usedPercent: data.weeklyTokenLimit.percentRemaining ?? safePercent(remainingCredits, maxCredits),
|
|
412
|
-
resetsAt: parseDateish(data.weeklyTokenLimit.nextRegenAt),
|
|
413
|
-
windowSeconds: 7 * 24 * 60 * 60,
|
|
414
|
-
usedValue: maxCredits - remainingCredits,
|
|
415
|
-
limitValue: maxCredits,
|
|
416
|
-
isCurrency: true,
|
|
417
|
-
showPace: true,
|
|
418
|
-
nextLabel: "Regen",
|
|
419
|
-
});
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
// Rolling 5-hour limit
|
|
423
|
-
if (data?.rollingFiveHourLimit) {
|
|
424
|
-
windows.push({
|
|
425
|
-
provider: "synthetic",
|
|
426
|
-
label: "5h Limit",
|
|
427
|
-
usedPercent: data.rollingFiveHourLimit.tickPercent,
|
|
428
|
-
resetsAt: parseDateish(data.rollingFiveHourLimit.nextTickAt),
|
|
429
|
-
windowSeconds: 5 * 60 * 60,
|
|
430
|
-
usedValue: data.rollingFiveHourLimit.max - data.rollingFiveHourLimit.remaining,
|
|
431
|
-
limitValue: data.rollingFiveHourLimit.max,
|
|
432
|
-
limited: data.rollingFiveHourLimit.limited,
|
|
433
|
-
showPace: false,
|
|
434
|
-
nextLabel: data.rollingFiveHourLimit.limited ? "Limited" : "Resets",
|
|
435
|
-
});
|
|
436
|
-
}
|
|
437
|
-
|
|
438
435
|
return windows;
|
|
439
436
|
}
|
|
@@ -74,13 +74,33 @@ describe("assessWindow", () => {
|
|
|
74
74
|
expect(assessWindow(w).severity).toBe("critical");
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
+
it("warns for pace-aware windows once absolute usage reaches 80%", () => {
|
|
78
|
+
const now = Date.now();
|
|
79
|
+
const monthSeconds = 31 * 24 * 3600;
|
|
80
|
+
const w = makeWindow({
|
|
81
|
+
provider: "github-copilot",
|
|
82
|
+
label: "Premium / month",
|
|
83
|
+
usedPercent: 83,
|
|
84
|
+
usedValue: 249,
|
|
85
|
+
limitValue: 300,
|
|
86
|
+
showPace: true,
|
|
87
|
+
windowSeconds: monthSeconds,
|
|
88
|
+
// Screenshot repro: 51/300 left with reset in about 428h35m in a 31-day
|
|
89
|
+
// monthly window. Dynamic pace alone projects just under warning, but
|
|
90
|
+
// only 17% remains, so low-quota color should still be amber.
|
|
91
|
+
resetsAt: new Date(now + ((428 * 60 + 35) * 60 * 1000)),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(assessWindow(w).severity).toBe("warning");
|
|
95
|
+
});
|
|
96
|
+
|
|
77
97
|
it("returns warning when projected exceeds dynamic warn threshold", () => {
|
|
78
98
|
const w = makeWindow({
|
|
79
|
-
usedPercent:
|
|
99
|
+
usedPercent: 79,
|
|
80
100
|
showPace: true,
|
|
81
101
|
paceScale: 1,
|
|
82
102
|
windowSeconds: 3600,
|
|
83
|
-
resetsAt: new Date(Date.now() +
|
|
103
|
+
resetsAt: new Date(Date.now() + 2520 * 1000),
|
|
84
104
|
});
|
|
85
105
|
expect(assessWindow(w).severity).toBe("warning");
|
|
86
106
|
});
|
|
@@ -53,11 +53,24 @@ export function getProjectedPercent(
|
|
|
53
53
|
return Math.max(0, (usedPercent / effectivePace) * 100);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
function absoluteUsageSeverity(window: QuotaWindow, percent: number): RiskSeverity {
|
|
57
|
+
if (window.limited || percent >= 100) return "critical";
|
|
58
|
+
if (percent >= 90) return "high";
|
|
59
|
+
if (percent >= 80) return "warning";
|
|
60
|
+
return "none";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function maxSeverity(a: RiskSeverity, b: RiskSeverity): RiskSeverity {
|
|
64
|
+
const order: RiskSeverity[] = ["none", "warning", "high", "critical"];
|
|
65
|
+
return order[Math.max(order.indexOf(a), order.indexOf(b))] ?? "none";
|
|
66
|
+
}
|
|
67
|
+
|
|
56
68
|
export function assessWindow(window: QuotaWindow): RiskAssessment {
|
|
57
69
|
const rawPace = window.showPace ? getPacePercent(window) : null;
|
|
58
70
|
const pacePercent =
|
|
59
71
|
rawPace !== null ? rawPace * (window.paceScale ?? 1) : null;
|
|
60
72
|
const projectedPercent = getProjectedPercent(window.usedPercent, pacePercent);
|
|
73
|
+
const absoluteSeverity = absoluteUsageSeverity(window, window.usedPercent);
|
|
61
74
|
|
|
62
75
|
let progress: number | null = null;
|
|
63
76
|
if (pacePercent !== null) progress = pacePercent / 100;
|
|
@@ -70,10 +83,7 @@ export function assessWindow(window: QuotaWindow): RiskAssessment {
|
|
|
70
83
|
};
|
|
71
84
|
|
|
72
85
|
if (progress === null) {
|
|
73
|
-
|
|
74
|
-
if (window.limited || projectedPercent >= 100) severity = "critical";
|
|
75
|
-
else if (projectedPercent >= 90) severity = "high";
|
|
76
|
-
else if (projectedPercent >= 80) severity = "warning";
|
|
86
|
+
const severity = absoluteUsageSeverity(window, projectedPercent);
|
|
77
87
|
|
|
78
88
|
return {
|
|
79
89
|
...base,
|
|
@@ -121,7 +131,7 @@ export function assessWindow(window: QuotaWindow): RiskAssessment {
|
|
|
121
131
|
warnProjectedPercent,
|
|
122
132
|
highProjectedPercent,
|
|
123
133
|
criticalProjectedPercent,
|
|
124
|
-
severity,
|
|
134
|
+
severity: maxSeverity(severity, absoluteSeverity),
|
|
125
135
|
};
|
|
126
136
|
}
|
|
127
137
|
|