@latentminds/pi-quotas 0.2.1 → 0.2.4
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 +7 -3
- package/package.json +1 -1
- package/src/config.ts +14 -3
- package/src/extensions/usage-status/format-status.test.ts +41 -1
- package/src/extensions/usage-status/format-status.ts +17 -1
- package/src/extensions/usage-status/index.ts +75 -5
- package/src/providers/parse.test.ts +133 -0
- package/src/providers/providers.ts +47 -50
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @latentminds/pi-quotas
|
|
2
2
|
|
|
3
|
-
Quota monitoring for the [Pi coding agent](https://github.com/mariozechner/pi). Shows remaining usage and rate limits for Anthropic, OpenAI Codex, GitHub Copilot, and
|
|
3
|
+
Quota monitoring for the [Pi coding agent](https://github.com/mariozechner/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
|
|
|
@@ -38,6 +38,7 @@ pi -e npm:@latentminds/pi-quotas
|
|
|
38
38
|
| `/codex:quotas` | OpenAI Codex quotas only |
|
|
39
39
|
| `/github:quotas` | GitHub Copilot quotas only |
|
|
40
40
|
| `/openrouter:quotas` | OpenRouter quotas only |
|
|
41
|
+
| `/synthetic:quotas` | Synthetic quotas only |
|
|
41
42
|
| `/quotas:settings` | Toggle individual features on or off |
|
|
42
43
|
|
|
43
44
|
## Features
|
|
@@ -58,9 +59,10 @@ Automatic notifications when projected usage is on track to exceed limits before
|
|
|
58
59
|
|
|
59
60
|
Use `/quotas:settings` to enable or disable:
|
|
60
61
|
- Combined `/quotas` command
|
|
61
|
-
- Per-provider commands (`/anthropic:quotas`, `/codex:quotas`, `/github:quotas`, `/openrouter:quotas`)
|
|
62
|
+
- Per-provider commands (`/anthropic:quotas`, `/codex:quotas`, `/github:quotas`, `/openrouter:quotas`, `/synthetic:quotas`)
|
|
62
63
|
- Footer status widget
|
|
63
64
|
- Quota warning notifications
|
|
65
|
+
- **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.
|
|
64
66
|
|
|
65
67
|
Settings can be saved globally (`~/.pi/agent/extensions/quotas.json`) or per-project (`.pi/quotas.json`). Run `/reload` after changing command visibility.
|
|
66
68
|
|
|
@@ -72,6 +74,7 @@ Settings can be saved globally (`~/.pi/agent/extensions/quotas.json`) or per-pro
|
|
|
72
74
|
| OpenAI Codex | 5h, 7d, credits, spend cap | Rate-limit percentages; credit balance; spend-cap reached/OK |
|
|
73
75
|
| GitHub Copilot | Premium/chat/completions per month | Remaining/entitlement counts with overage indicators |
|
|
74
76
|
| OpenRouter | Monthly budget, daily/weekly/monthly usage | USD spending tracking with cents precision; optional per-key budget limits; UTC-based period resets |
|
|
77
|
+
| Synthetic | Subscription, search/hour, free tools, weekly tokens, 5h limit | Request counts and token budgets; rolling five-hour rate limit; weekly token regen |
|
|
75
78
|
|
|
76
79
|
## Credentials
|
|
77
80
|
|
|
@@ -81,8 +84,9 @@ pi-quotas reads existing Pi auth entries from `~/.pi/agent/auth.json`:
|
|
|
81
84
|
- `openai-codex` — Codex access token (also reads `~/.codex/auth.json` for the account ID)
|
|
82
85
|
- `github-copilot` — GitHub Copilot OAuth token (falls back to `gh auth token` if needed)
|
|
83
86
|
- `openrouter` — OpenRouter API key (Bearer token)
|
|
87
|
+
- `synthetic` — Synthetic API key (set the `SYNTHETIC_API_KEY` environment variable)
|
|
84
88
|
|
|
85
|
-
No additional setup is required - if Pi can use the provider, pi-quotas can check its quotas.
|
|
89
|
+
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.
|
|
86
90
|
|
|
87
91
|
## Requirements
|
|
88
92
|
|
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
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { formatWindowStatus, type WindowStatus } from "./format-status.js";
|
|
3
3
|
import type { SupportedQuotaProvider } from "../../types/quotas.js";
|
|
4
|
-
import { formatStatus, toWindowStatus } from "./index.js";
|
|
4
|
+
import { formatStatus, formatStatusForFooter, toStatusWindows, toWindowStatus } from "./index.js";
|
|
5
5
|
|
|
6
6
|
// Minimal fake theme that just returns text with markers for color assertions
|
|
7
7
|
function fakeTheme() {
|
|
@@ -183,6 +183,46 @@ describe("formatWindowStatus", () => {
|
|
|
183
183
|
}
|
|
184
184
|
});
|
|
185
185
|
|
|
186
|
+
it("clears the footer status when filtering removes all windows", () => {
|
|
187
|
+
expect(formatStatusForFooter({ ui: { theme } } as any, [])).toBeUndefined();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("filters Anthropic subscription windows from footer status while keeping extra usage", () => {
|
|
191
|
+
const windows = toStatusWindows([
|
|
192
|
+
{
|
|
193
|
+
provider: "anthropic",
|
|
194
|
+
label: "5h",
|
|
195
|
+
usedPercent: 10,
|
|
196
|
+
resetsAt: new Date("2026-05-06T07:47:37Z"),
|
|
197
|
+
windowSeconds: 5 * 60 * 60,
|
|
198
|
+
usedValue: 10,
|
|
199
|
+
limitValue: 100,
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
provider: "anthropic",
|
|
203
|
+
label: "7d Sonnet",
|
|
204
|
+
usedPercent: 20,
|
|
205
|
+
resetsAt: new Date("2026-05-06T07:47:37Z"),
|
|
206
|
+
windowSeconds: 7 * 24 * 60 * 60,
|
|
207
|
+
usedValue: 20,
|
|
208
|
+
limitValue: 100,
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
provider: "anthropic",
|
|
212
|
+
label: "Extra (USD)",
|
|
213
|
+
usedPercent: 30,
|
|
214
|
+
resetsAt: new Date("2026-06-01T00:00:00Z"),
|
|
215
|
+
windowSeconds: 30 * 24 * 60 * 60,
|
|
216
|
+
usedValue: 30,
|
|
217
|
+
limitValue: 100,
|
|
218
|
+
isCurrency: true,
|
|
219
|
+
},
|
|
220
|
+
]);
|
|
221
|
+
|
|
222
|
+
expect(windows).toHaveLength(1);
|
|
223
|
+
expect(windows[0]).toMatchObject({ label: "Extra (USD)" });
|
|
224
|
+
});
|
|
225
|
+
|
|
186
226
|
it("does not prefix elapsed reset times with in", () => {
|
|
187
227
|
vi.useFakeTimers();
|
|
188
228
|
vi.setSystemTime(new Date("2026-05-06T05:28:37Z"));
|
|
@@ -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,
|
|
@@ -39,6 +45,21 @@ export function formatStatus(ctx: Pick<ExtensionContext, "ui">, windows: WindowS
|
|
|
39
45
|
.join(" ");
|
|
40
46
|
}
|
|
41
47
|
|
|
48
|
+
const ANTHROPIC_SUBSCRIPTION_WINDOW_LABELS = new Set([
|
|
49
|
+
"5h",
|
|
50
|
+
"7d",
|
|
51
|
+
"7d Sonnet",
|
|
52
|
+
"7d Opus",
|
|
53
|
+
"7d Opus (legacy)",
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
function shouldShowInStatus(window: QuotaWindow): boolean {
|
|
57
|
+
return !(
|
|
58
|
+
window.provider === "anthropic" &&
|
|
59
|
+
ANTHROPIC_SUBSCRIPTION_WINDOW_LABELS.has(window.label)
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
42
63
|
export function toWindowStatus(window: QuotaWindow): WindowStatus {
|
|
43
64
|
return {
|
|
44
65
|
label: window.label,
|
|
@@ -52,6 +73,18 @@ export function toWindowStatus(window: QuotaWindow): WindowStatus {
|
|
|
52
73
|
};
|
|
53
74
|
}
|
|
54
75
|
|
|
76
|
+
export function toStatusWindows(windows: QuotaWindow[]): WindowStatus[] {
|
|
77
|
+
return windows.filter(shouldShowInStatus).map(toWindowStatus);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function formatStatusForFooter(
|
|
81
|
+
ctx: Pick<ExtensionContext, "ui">,
|
|
82
|
+
windows: WindowStatus[],
|
|
83
|
+
): string | undefined {
|
|
84
|
+
if (windows.length === 0) return undefined;
|
|
85
|
+
return formatStatus(ctx, windows);
|
|
86
|
+
}
|
|
87
|
+
|
|
55
88
|
function createStatusRefresher() {
|
|
56
89
|
let refreshTimer: ReturnType<typeof setInterval> | undefined;
|
|
57
90
|
let activeContext: ExtensionContext | undefined;
|
|
@@ -73,9 +106,10 @@ function createStatusRefresher() {
|
|
|
73
106
|
ctx.ui.setStatus(EXTENSION_ID, ctx.ui.theme.fg("warning", "usage unavailable"));
|
|
74
107
|
return;
|
|
75
108
|
}
|
|
76
|
-
const windows: WindowStatus[] = result.data.windows
|
|
77
|
-
|
|
78
|
-
|
|
109
|
+
const windows: WindowStatus[] = toStatusWindows(result.data.windows);
|
|
110
|
+
const status = formatStatusForFooter(ctx, windows);
|
|
111
|
+
lastStatus = status === undefined ? undefined : windows;
|
|
112
|
+
ctx.ui.setStatus(EXTENSION_ID, status);
|
|
79
113
|
} catch {
|
|
80
114
|
ctx.ui.setStatus(EXTENSION_ID, ctx.ui.theme.fg("warning", "usage unavailable"));
|
|
81
115
|
} finally {
|
|
@@ -114,7 +148,7 @@ function createStatusRefresher() {
|
|
|
114
148
|
},
|
|
115
149
|
renderLast(ctx: ExtensionContext): boolean {
|
|
116
150
|
if (!lastStatus || !ctx.hasUI) return false;
|
|
117
|
-
ctx.ui.setStatus(EXTENSION_ID,
|
|
151
|
+
ctx.ui.setStatus(EXTENSION_ID, formatStatusForFooter(ctx, lastStatus));
|
|
118
152
|
return true;
|
|
119
153
|
},
|
|
120
154
|
};
|
|
@@ -124,10 +158,28 @@ export default async function (pi: ExtensionAPI) {
|
|
|
124
158
|
await configLoader.load();
|
|
125
159
|
const refresher = createStatusRefresher();
|
|
126
160
|
let enabled = configLoader.getConfig().usageStatus;
|
|
161
|
+
let deferToSynthetic = configLoader.getConfig().deferToSynthetic;
|
|
127
162
|
let currentContext: ExtensionContext | undefined;
|
|
128
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
|
+
|
|
129
179
|
pi.events.on(QUOTAS_CONFIG_UPDATED_EVENT, (data: unknown) => {
|
|
130
|
-
|
|
180
|
+
const config = (data as QuotasConfigUpdatedPayload).config;
|
|
181
|
+
enabled = config.usageStatus;
|
|
182
|
+
deferToSynthetic = config.deferToSynthetic;
|
|
131
183
|
if (!enabled) {
|
|
132
184
|
refresher.stop(currentContext);
|
|
133
185
|
return;
|
|
@@ -138,9 +190,21 @@ export default async function (pi: ExtensionAPI) {
|
|
|
138
190
|
}
|
|
139
191
|
});
|
|
140
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Whether to suppress our footer because pi-synthetic is showing
|
|
195
|
+
* the same data for the Synthetic provider.
|
|
196
|
+
*/
|
|
197
|
+
function shouldDeferToSynthetic(provider: string | undefined): boolean {
|
|
198
|
+
return deferToSynthetic && syntheticUsageActive && provider === "synthetic";
|
|
199
|
+
}
|
|
200
|
+
|
|
141
201
|
pi.on("session_start", async (_event, ctx) => {
|
|
142
202
|
currentContext = ctx;
|
|
143
203
|
if (!enabled) return;
|
|
204
|
+
if (shouldDeferToSynthetic(ctx.model?.provider)) {
|
|
205
|
+
ctx.ui.setStatus(EXTENSION_ID, undefined);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
144
208
|
refresher.start();
|
|
145
209
|
await refresher.refreshFor(ctx);
|
|
146
210
|
});
|
|
@@ -148,6 +212,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
148
212
|
pi.on("turn_end", async (_event, ctx) => {
|
|
149
213
|
currentContext = ctx;
|
|
150
214
|
if (!enabled) return;
|
|
215
|
+
if (shouldDeferToSynthetic(ctx.model?.provider)) return;
|
|
151
216
|
await refresher.refreshFor(ctx);
|
|
152
217
|
});
|
|
153
218
|
|
|
@@ -157,11 +222,16 @@ export default async function (pi: ExtensionAPI) {
|
|
|
157
222
|
refresher.stop(ctx);
|
|
158
223
|
return;
|
|
159
224
|
}
|
|
225
|
+
if (shouldDeferToSynthetic(ctx.model?.provider)) {
|
|
226
|
+
ctx.ui.setStatus(EXTENSION_ID, undefined);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
160
229
|
await refresher.refreshFor(ctx);
|
|
161
230
|
});
|
|
162
231
|
|
|
163
232
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
164
233
|
currentContext = undefined;
|
|
234
|
+
syntheticUsageActive = false;
|
|
165
235
|
refresher.stop(ctx);
|
|
166
236
|
});
|
|
167
237
|
|
|
@@ -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", () => {
|
|
@@ -48,6 +49,8 @@ describe("parseAnthropicUsage", () => {
|
|
|
48
49
|
const extra = windows.find((w) => w.label === "Extra (AUD)");
|
|
49
50
|
expect(extra).toBeDefined();
|
|
50
51
|
expect(extra).toMatchObject({
|
|
52
|
+
provider: "anthropic",
|
|
53
|
+
label: "Extra (AUD)",
|
|
51
54
|
isCurrency: true,
|
|
52
55
|
usedPercent: 71.83,
|
|
53
56
|
usedValue: 215.48,
|
|
@@ -408,3 +411,133 @@ describe("parseOpenRouterUsage", () => {
|
|
|
408
411
|
expect(windows.find((w) => w.label === "Monthly")).toBeDefined();
|
|
409
412
|
});
|
|
410
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
|
}
|