@latentminds/pi-quotas 0.1.2 → 0.2.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 +11 -4
- package/package.json +1 -1
- package/src/config.ts +1 -1
- package/src/extensions/command-quotas/command.ts +1 -1
- package/src/extensions/command-quotas/components/quotas-display.ts +6 -1
- package/src/extensions/command-quotas/provider-commands.test.ts +9 -0
- package/src/extensions/command-quotas/provider-commands.ts +12 -0
- package/src/extensions/usage-status/format-status.test.ts +106 -2
- package/src/extensions/usage-status/format-status.ts +12 -1
- package/src/extensions/usage-status/index.ts +23 -13
- package/src/lib/quotas.ts +6 -0
- package/src/providers/fetch.test.ts +60 -0
- package/src/providers/fetch.ts +51 -0
- package/src/providers/parse.test.ts +154 -0
- package/src/providers/providers.ts +194 -0
- package/src/types/quotas.ts +3 -1
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,
|
|
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 OpenRouter — directly in your Pi session.
|
|
4
4
|
|
|
5
5
|
## Screenshots
|
|
6
6
|
|
|
@@ -37,6 +37,7 @@ pi -e npm:@latentminds/pi-quotas
|
|
|
37
37
|
| `/anthropic:quotas` | Anthropic quotas only |
|
|
38
38
|
| `/codex:quotas` | OpenAI Codex quotas only |
|
|
39
39
|
| `/github:quotas` | GitHub Copilot quotas only |
|
|
40
|
+
| `/openrouter:quotas` | OpenRouter quotas only |
|
|
40
41
|
| `/quotas:settings` | Toggle individual features on or off |
|
|
41
42
|
|
|
42
43
|
## Features
|
|
@@ -47,7 +48,7 @@ Run `/quotas` to open a bordered TUI view showing all providers side by side, wi
|
|
|
47
48
|
|
|
48
49
|
### Footer status widget
|
|
49
50
|
|
|
50
|
-
When your active model is from a supported provider, the Pi footer shows real-time quota headroom
|
|
51
|
+
When your active model is from a supported provider, the Pi footer shows real-time quota headroom - updated every 60 seconds and on each turn. Colours shift from green → amber → red as usage climbs.
|
|
51
52
|
|
|
52
53
|
### Quota warnings
|
|
53
54
|
|
|
@@ -57,7 +58,7 @@ Automatic notifications when projected usage is on track to exceed limits before
|
|
|
57
58
|
|
|
58
59
|
Use `/quotas:settings` to enable or disable:
|
|
59
60
|
- Combined `/quotas` command
|
|
60
|
-
- Per-provider commands (`/anthropic:quotas`, `/codex:quotas`, `/github:quotas`)
|
|
61
|
+
- Per-provider commands (`/anthropic:quotas`, `/codex:quotas`, `/github:quotas`, `/openrouter:quotas`)
|
|
61
62
|
- Footer status widget
|
|
62
63
|
- Quota warning notifications
|
|
63
64
|
|
|
@@ -70,6 +71,7 @@ Settings can be saved globally (`~/.pi/agent/extensions/quotas.json`) or per-pro
|
|
|
70
71
|
| Anthropic | 5h, 7d, per-model 7d, extra usage | Utilization percentages; optional overage budget in local currency |
|
|
71
72
|
| OpenAI Codex | 5h, 7d, credits, spend cap | Rate-limit percentages; credit balance; spend-cap reached/OK |
|
|
72
73
|
| GitHub Copilot | Premium/chat/completions per month | Remaining/entitlement counts with overage indicators |
|
|
74
|
+
| OpenRouter | Monthly budget, daily/weekly/monthly usage | USD spending tracking with cents precision; optional per-key budget limits; UTC-based period resets |
|
|
73
75
|
|
|
74
76
|
## Credentials
|
|
75
77
|
|
|
@@ -78,13 +80,18 @@ pi-quotas reads existing Pi auth entries from `~/.pi/agent/auth.json`:
|
|
|
78
80
|
- `anthropic` — Anthropic OAuth token
|
|
79
81
|
- `openai-codex` — Codex access token (also reads `~/.codex/auth.json` for the account ID)
|
|
80
82
|
- `github-copilot` — GitHub Copilot OAuth token (falls back to `gh auth token` if needed)
|
|
83
|
+
- `openrouter` — OpenRouter API key (Bearer token)
|
|
81
84
|
|
|
82
|
-
No additional setup is required
|
|
85
|
+
No additional setup is required - if Pi can use the provider, pi-quotas can check its quotas.
|
|
83
86
|
|
|
84
87
|
## Requirements
|
|
85
88
|
|
|
86
89
|
- [Pi](https://github.com/mariozechner/pi) >= 0.61.0
|
|
87
90
|
|
|
91
|
+
## Changelog
|
|
92
|
+
|
|
93
|
+
See [CHANGELOG.md](CHANGELOG.md) for release notes and recent changes.
|
|
94
|
+
|
|
88
95
|
## License
|
|
89
96
|
|
|
90
97
|
[MIT](LICENSE) © Latent Minds Pty Ltd
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -152,7 +152,7 @@ const FEATURE_META: Array<{
|
|
|
152
152
|
{
|
|
153
153
|
id: "providerCommands",
|
|
154
154
|
label: "Provider quota commands",
|
|
155
|
-
description: "Toggle `/anthropic:quotas`, `/codex:quotas`, and `/
|
|
155
|
+
description: "Toggle `/anthropic:quotas`, `/codex:quotas`, `/github:quotas`, `/openrouter:quotas`, and `/synthetic:quotas`",
|
|
156
156
|
},
|
|
157
157
|
{
|
|
158
158
|
id: "usageStatus",
|
|
@@ -65,7 +65,7 @@ async function openQuotaView(
|
|
|
65
65
|
|
|
66
66
|
export function registerQuotasCommands(pi: ExtensionAPI): void {
|
|
67
67
|
pi.registerCommand("quotas", {
|
|
68
|
-
description: "Display remaining quotas for Anthropic, Codex,
|
|
68
|
+
description: "Display remaining quotas for Anthropic, Codex, GitHub Copilot, and OpenRouter",
|
|
69
69
|
handler: async (_args, ctx) => {
|
|
70
70
|
if (!configLoader.getConfig().quotasCommand) {
|
|
71
71
|
ctx.ui.notify("/quotas is disabled. Re-enable it in /quotas:settings.", "warning");
|
|
@@ -162,7 +162,12 @@ export class QuotasComponent implements Component {
|
|
|
162
162
|
// Format the usage string depending on window type
|
|
163
163
|
let usedStr: string;
|
|
164
164
|
if (window.isCurrency) {
|
|
165
|
-
|
|
165
|
+
// Tracking-only windows have limitValue=0, show just usage
|
|
166
|
+
if (window.limitValue === 0) {
|
|
167
|
+
usedStr = `$${window.usedValue.toFixed(2)} used`;
|
|
168
|
+
} else {
|
|
169
|
+
usedStr = `$${window.usedValue.toFixed(2)} / $${window.limitValue.toFixed(2)}`;
|
|
170
|
+
}
|
|
166
171
|
} else if (window.limitValue <= 1 && window.label === "Spend cap") {
|
|
167
172
|
usedStr = window.limited ? "REACHED" : "OK";
|
|
168
173
|
} else if (window.limitValue > 0 && window.limitValue !== 100) {
|
|
@@ -31,4 +31,13 @@ describe("getProviderCommandInfo", () => {
|
|
|
31
31
|
title: "GitHub Copilot Quotas",
|
|
32
32
|
});
|
|
33
33
|
});
|
|
34
|
+
|
|
35
|
+
it("maps openrouter to openrouter:quotas", () => {
|
|
36
|
+
const info = getProviderCommandInfo("openrouter");
|
|
37
|
+
expect(info).toMatchObject<Partial<ProviderCommandInfo>>({
|
|
38
|
+
provider: "openrouter",
|
|
39
|
+
commandName: "openrouter:quotas",
|
|
40
|
+
title: "OpenRouter Quotas",
|
|
41
|
+
});
|
|
42
|
+
});
|
|
34
43
|
});
|
|
@@ -28,5 +28,17 @@ export function getProviderCommandInfo(
|
|
|
28
28
|
commandName: "github:quotas",
|
|
29
29
|
title: "GitHub Copilot Quotas",
|
|
30
30
|
};
|
|
31
|
+
case "openrouter":
|
|
32
|
+
return {
|
|
33
|
+
provider,
|
|
34
|
+
commandName: "openrouter:quotas",
|
|
35
|
+
title: "OpenRouter Quotas",
|
|
36
|
+
};
|
|
37
|
+
case "synthetic":
|
|
38
|
+
return {
|
|
39
|
+
provider,
|
|
40
|
+
commandName: "synthetic:quotas",
|
|
41
|
+
title: "Synthetic Quotas",
|
|
42
|
+
};
|
|
31
43
|
}
|
|
32
44
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { formatWindowStatus, type WindowStatus } from "./format-status.js";
|
|
3
|
+
import type { SupportedQuotaProvider } from "../../types/quotas.js";
|
|
4
|
+
import { formatStatus, toWindowStatus } from "./index.js";
|
|
3
5
|
|
|
4
6
|
// Minimal fake theme that just returns text with markers for color assertions
|
|
5
7
|
function fakeTheme() {
|
|
@@ -11,6 +13,10 @@ function fakeTheme() {
|
|
|
11
13
|
describe("formatWindowStatus", () => {
|
|
12
14
|
const theme = fakeTheme() as any;
|
|
13
15
|
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
vi.useRealTimers();
|
|
18
|
+
});
|
|
19
|
+
|
|
14
20
|
it("shows remaining/limit for windows with known limits (GitHub premium)", () => {
|
|
15
21
|
const w: WindowStatus = {
|
|
16
22
|
label: "Premium / month",
|
|
@@ -53,7 +59,7 @@ describe("formatWindowStatus", () => {
|
|
|
53
59
|
limitValue: 300,
|
|
54
60
|
};
|
|
55
61
|
const result = formatWindowStatus(theme, w);
|
|
56
|
-
expect(result).toContain("$215/$300");
|
|
62
|
+
expect(result).toContain("$215.00/$300.00");
|
|
57
63
|
expect(result).toContain("[warning]");
|
|
58
64
|
});
|
|
59
65
|
|
|
@@ -101,4 +107,102 @@ describe("formatWindowStatus", () => {
|
|
|
101
107
|
const result = formatWindowStatus(theme, w);
|
|
102
108
|
expect(result).toContain("[dim]5h:");
|
|
103
109
|
});
|
|
110
|
+
|
|
111
|
+
it("renders footer reset times with minute precision for every provider", () => {
|
|
112
|
+
vi.useFakeTimers();
|
|
113
|
+
vi.setSystemTime(new Date("2026-05-06T05:28:37Z"));
|
|
114
|
+
|
|
115
|
+
const providers: Array<{ provider: SupportedQuotaProvider; label: string }> = [
|
|
116
|
+
{ provider: "anthropic", label: "5h" },
|
|
117
|
+
{ provider: "openai-codex", label: "7d" },
|
|
118
|
+
{ provider: "github-copilot", label: "Premium / month" },
|
|
119
|
+
{ provider: "openrouter", label: "Monthly Budget" },
|
|
120
|
+
{ provider: "synthetic", label: "Subscription" },
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
for (const { provider, label } of providers) {
|
|
124
|
+
const status = toWindowStatus({
|
|
125
|
+
provider,
|
|
126
|
+
label,
|
|
127
|
+
usedPercent: 50,
|
|
128
|
+
resetsAt: new Date("2026-05-06T07:47:37Z"),
|
|
129
|
+
windowSeconds: 5 * 60 * 60,
|
|
130
|
+
usedValue: 50,
|
|
131
|
+
limitValue: 100,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const result = formatStatus({ ui: { theme } } as any, [status]);
|
|
135
|
+
|
|
136
|
+
expect(result).toContain("(↺in 2h19m)");
|
|
137
|
+
expect(result).not.toContain("(↺in 3h)");
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("omits footer reset tags for windows without a real reset time", () => {
|
|
142
|
+
const result = formatStatus(
|
|
143
|
+
{ ui: { theme } } as any,
|
|
144
|
+
[
|
|
145
|
+
{
|
|
146
|
+
label: "Spend cap",
|
|
147
|
+
usedPercent: 0,
|
|
148
|
+
severity: "none",
|
|
149
|
+
resetsAt: null,
|
|
150
|
+
limited: false,
|
|
151
|
+
usedValue: 0,
|
|
152
|
+
limitValue: 1,
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
expect(result).toContain("cap:");
|
|
158
|
+
expect(result).not.toContain("↺");
|
|
159
|
+
expect(result).not.toContain("soon");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("maps sentinel reset dates to null before rendering status for non-reset provider windows", () => {
|
|
163
|
+
const windows: Array<{ provider: SupportedQuotaProvider; label: string; isCurrency?: boolean }> = [
|
|
164
|
+
{ provider: "openai-codex", label: "Spend cap" },
|
|
165
|
+
{ provider: "openai-codex", label: "Credits", isCurrency: true },
|
|
166
|
+
{ provider: "openrouter", label: "Credits Remaining", isCurrency: true },
|
|
167
|
+
];
|
|
168
|
+
|
|
169
|
+
for (const { provider, label, isCurrency } of windows) {
|
|
170
|
+
const status = toWindowStatus({
|
|
171
|
+
provider,
|
|
172
|
+
label,
|
|
173
|
+
usedPercent: 0,
|
|
174
|
+
resetsAt: new Date(0),
|
|
175
|
+
windowSeconds: 0,
|
|
176
|
+
usedValue: 0,
|
|
177
|
+
limitValue: 1,
|
|
178
|
+
limited: false,
|
|
179
|
+
isCurrency,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
expect(status.resetsAt).toBeNull();
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("does not prefix elapsed reset times with in", () => {
|
|
187
|
+
vi.useFakeTimers();
|
|
188
|
+
vi.setSystemTime(new Date("2026-05-06T05:28:37Z"));
|
|
189
|
+
|
|
190
|
+
const result = formatStatus(
|
|
191
|
+
{ ui: { theme } } as any,
|
|
192
|
+
[
|
|
193
|
+
{
|
|
194
|
+
label: "5h",
|
|
195
|
+
usedPercent: 100,
|
|
196
|
+
severity: "critical",
|
|
197
|
+
resetsAt: "2026-05-06T05:28:37Z",
|
|
198
|
+
limited: false,
|
|
199
|
+
usedValue: 100,
|
|
200
|
+
limitValue: 100,
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
expect(result).toContain("(↺now)");
|
|
206
|
+
expect(result).not.toContain("(↺in now)");
|
|
207
|
+
});
|
|
104
208
|
});
|
|
@@ -31,6 +31,12 @@ const SHORT_LABELS: Record<string, string> = {
|
|
|
31
31
|
"Extra (USD)": "extra",
|
|
32
32
|
"Extra (EUR)": "extra",
|
|
33
33
|
"Extra (GBP)": "extra",
|
|
34
|
+
// OpenRouter labels
|
|
35
|
+
"Monthly Budget": "budget",
|
|
36
|
+
"Credits Remaining": "credits",
|
|
37
|
+
"Daily": "daily",
|
|
38
|
+
"Weekly": "weekly",
|
|
39
|
+
"Monthly": "monthly",
|
|
34
40
|
};
|
|
35
41
|
|
|
36
42
|
/**
|
|
@@ -66,7 +72,12 @@ export function formatWindowStatus(theme: ThemeLike, w: WindowStatus): string {
|
|
|
66
72
|
if (w.label === "Spend cap") {
|
|
67
73
|
valueText = theme.fg(color, w.limited ? "REACHED" : "OK");
|
|
68
74
|
} else if (w.isCurrency && w.usedValue != null && w.limitValue != null) {
|
|
69
|
-
|
|
75
|
+
// Tracking-only windows have limitValue=0, show just usage
|
|
76
|
+
if (w.limitValue === 0) {
|
|
77
|
+
valueText = theme.fg(color, `$${w.usedValue.toFixed(2)} used`);
|
|
78
|
+
} else {
|
|
79
|
+
valueText = theme.fg(color, `$${w.usedValue.toFixed(2)}/$${w.limitValue.toFixed(2)}`);
|
|
80
|
+
}
|
|
70
81
|
} else if (hasRealCounts(w)) {
|
|
71
82
|
const remaining = Math.max(0, Math.round(w.limitValue! - w.usedValue!));
|
|
72
83
|
valueText = theme.fg(color, `${remaining}/${w.limitValue}`);
|
|
@@ -11,28 +11,47 @@ import {
|
|
|
11
11
|
} from "../../config.js";
|
|
12
12
|
import {
|
|
13
13
|
fetchProviderQuotas,
|
|
14
|
-
formatResetTime,
|
|
15
14
|
isSupportedProvider,
|
|
16
15
|
} from "../../lib/quotas.js";
|
|
17
16
|
import {
|
|
18
17
|
assessWindow,
|
|
18
|
+
formatTimeRemaining,
|
|
19
19
|
} from "../../utils/quotas-severity.js";
|
|
20
|
+
import type { QuotaWindow } from "../../types/quotas.js";
|
|
20
21
|
import { formatWindowStatus, type WindowStatus } from "./format-status.js";
|
|
21
22
|
|
|
22
23
|
const EXTENSION_ID = "pi-quotas-usage";
|
|
23
24
|
const REFRESH_INTERVAL_MS = 60_000;
|
|
24
25
|
|
|
25
|
-
function
|
|
26
|
+
function formatFooterResetTime(resetsAt: string): string {
|
|
27
|
+
const remaining = formatTimeRemaining(new Date(resetsAt));
|
|
28
|
+
return remaining === "now" ? "now" : `in ${remaining}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function formatStatus(ctx: Pick<ExtensionContext, "ui">, windows: WindowStatus[]): string {
|
|
26
32
|
const theme = ctx.ui.theme;
|
|
27
33
|
return windows
|
|
28
34
|
.map((w) => {
|
|
29
35
|
const core = formatWindowStatus(theme, w);
|
|
30
|
-
const reset = w.resetsAt ? theme.fg("dim", ` (↺${
|
|
36
|
+
const reset = w.resetsAt ? theme.fg("dim", ` (↺${formatFooterResetTime(w.resetsAt)})`) : "";
|
|
31
37
|
return `${core}${reset}`;
|
|
32
38
|
})
|
|
33
39
|
.join(" ");
|
|
34
40
|
}
|
|
35
41
|
|
|
42
|
+
export function toWindowStatus(window: QuotaWindow): WindowStatus {
|
|
43
|
+
return {
|
|
44
|
+
label: window.label,
|
|
45
|
+
usedPercent: window.usedPercent,
|
|
46
|
+
severity: assessWindow(window).severity,
|
|
47
|
+
resetsAt: window.resetsAt.getTime() > 0 ? window.resetsAt.toISOString() : null,
|
|
48
|
+
limited: window.limited ?? false,
|
|
49
|
+
isCurrency: window.isCurrency,
|
|
50
|
+
usedValue: window.usedValue,
|
|
51
|
+
limitValue: window.limitValue,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
36
55
|
function createStatusRefresher() {
|
|
37
56
|
let refreshTimer: ReturnType<typeof setInterval> | undefined;
|
|
38
57
|
let activeContext: ExtensionContext | undefined;
|
|
@@ -54,16 +73,7 @@ function createStatusRefresher() {
|
|
|
54
73
|
ctx.ui.setStatus(EXTENSION_ID, ctx.ui.theme.fg("warning", "usage unavailable"));
|
|
55
74
|
return;
|
|
56
75
|
}
|
|
57
|
-
const windows: WindowStatus[] = result.data.windows.map(
|
|
58
|
-
label: window.label,
|
|
59
|
-
usedPercent: window.usedPercent,
|
|
60
|
-
severity: assessWindow(window).severity,
|
|
61
|
-
resetsAt: window.resetsAt.toISOString(),
|
|
62
|
-
limited: window.limited ?? false,
|
|
63
|
-
isCurrency: window.isCurrency,
|
|
64
|
-
usedValue: window.usedValue,
|
|
65
|
-
limitValue: window.limitValue,
|
|
66
|
-
}));
|
|
76
|
+
const windows: WindowStatus[] = result.data.windows.map(toWindowStatus);
|
|
67
77
|
lastStatus = windows;
|
|
68
78
|
ctx.ui.setStatus(EXTENSION_ID, formatStatus(ctx, windows));
|
|
69
79
|
} catch {
|
package/src/lib/quotas.ts
CHANGED
|
@@ -6,18 +6,24 @@ export const SUPPORTED_PROVIDERS: SupportedQuotaProvider[] = [
|
|
|
6
6
|
"anthropic",
|
|
7
7
|
"openai-codex",
|
|
8
8
|
"github-copilot",
|
|
9
|
+
"openrouter",
|
|
10
|
+
"synthetic",
|
|
9
11
|
];
|
|
10
12
|
|
|
11
13
|
export const PROVIDER_LABELS: Record<SupportedQuotaProvider, string> = {
|
|
12
14
|
anthropic: "Anthropic",
|
|
13
15
|
"openai-codex": "OpenAI Codex",
|
|
14
16
|
"github-copilot": "GitHub Copilot",
|
|
17
|
+
openrouter: "OpenRouter",
|
|
18
|
+
synthetic: "Synthetic",
|
|
15
19
|
};
|
|
16
20
|
|
|
17
21
|
const PROVIDER_TTLS_MS: Record<SupportedQuotaProvider, number> = {
|
|
18
22
|
anthropic: 5 * 60_000,
|
|
19
23
|
"openai-codex": 60_000,
|
|
20
24
|
"github-copilot": 5 * 60_000,
|
|
25
|
+
openrouter: 60_000,
|
|
26
|
+
synthetic: 60_000,
|
|
21
27
|
};
|
|
22
28
|
|
|
23
29
|
type CacheEntry = {
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
fetchAnthropicQuotasWithToken,
|
|
4
4
|
fetchCodexQuotasWithToken,
|
|
5
5
|
fetchGitHubCopilotQuotasWithToken,
|
|
6
|
+
fetchOpenRouterQuotasWithToken,
|
|
6
7
|
} from "./fetch.js";
|
|
7
8
|
|
|
8
9
|
const originalFetch = globalThis.fetch;
|
|
@@ -135,3 +136,62 @@ describe("fetchGitHubCopilotQuotasWithToken", () => {
|
|
|
135
136
|
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
|
|
136
137
|
});
|
|
137
138
|
});
|
|
139
|
+
|
|
140
|
+
describe("fetchOpenRouterQuotasWithToken", () => {
|
|
141
|
+
it("returns config error when token missing", async () => {
|
|
142
|
+
const result = await fetchOpenRouterQuotasWithToken(undefined);
|
|
143
|
+
expect(result).toMatchObject({
|
|
144
|
+
success: false,
|
|
145
|
+
error: { kind: "config" },
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("fetches and parses OpenRouter key info with budget", async () => {
|
|
150
|
+
globalThis.fetch = vi.fn().mockResolvedValue(
|
|
151
|
+
new Response(
|
|
152
|
+
JSON.stringify({
|
|
153
|
+
data: {
|
|
154
|
+
label: "Test Key",
|
|
155
|
+
limit: 50,
|
|
156
|
+
limit_remaining: 35,
|
|
157
|
+
limit_reset: "monthly",
|
|
158
|
+
usage: 15,
|
|
159
|
+
usage_daily: 2.5,
|
|
160
|
+
usage_weekly: 12,
|
|
161
|
+
usage_monthly: 15,
|
|
162
|
+
byok_usage: 0,
|
|
163
|
+
byok_usage_daily: 0,
|
|
164
|
+
byok_usage_weekly: 0,
|
|
165
|
+
byok_usage_monthly: 0,
|
|
166
|
+
is_free_tier: false,
|
|
167
|
+
},
|
|
168
|
+
}),
|
|
169
|
+
{ status: 200 },
|
|
170
|
+
),
|
|
171
|
+
) as any;
|
|
172
|
+
|
|
173
|
+
const result = await fetchOpenRouterQuotasWithToken("sk-or-test");
|
|
174
|
+
expect(result.success).toBe(true);
|
|
175
|
+
if (result.success) {
|
|
176
|
+
expect(result.data.provider).toBe("openrouter");
|
|
177
|
+
expect(result.data.windows).toHaveLength(4);
|
|
178
|
+
expect(result.data.windows[0]).toMatchObject({
|
|
179
|
+
label: "Monthly Budget",
|
|
180
|
+
usedValue: 15,
|
|
181
|
+
limitValue: 50,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("handles HTTP error", async () => {
|
|
187
|
+
globalThis.fetch = vi.fn().mockResolvedValue(
|
|
188
|
+
new Response("Unauthorized", { status: 401 }),
|
|
189
|
+
) as any;
|
|
190
|
+
|
|
191
|
+
const result = await fetchOpenRouterQuotasWithToken("bad-key");
|
|
192
|
+
expect(result).toMatchObject({
|
|
193
|
+
success: false,
|
|
194
|
+
error: { kind: "http" },
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
});
|
package/src/providers/fetch.ts
CHANGED
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
parseAnthropicUsage,
|
|
9
9
|
parseCodexUsage,
|
|
10
10
|
parseGitHubCopilotUsage,
|
|
11
|
+
parseOpenRouterUsage,
|
|
12
|
+
parseSyntheticUsage,
|
|
11
13
|
} from "./providers.js";
|
|
12
14
|
|
|
13
15
|
const FETCH_TIMEOUT_MS = 15_000;
|
|
@@ -238,8 +240,57 @@ export async function fetchGitHubCopilotQuotas(
|
|
|
238
240
|
);
|
|
239
241
|
}
|
|
240
242
|
|
|
243
|
+
export async function fetchOpenRouterQuotasWithToken(
|
|
244
|
+
accessToken: string | undefined,
|
|
245
|
+
signal?: AbortSignal,
|
|
246
|
+
): Promise<QuotasResult> {
|
|
247
|
+
if (!accessToken) return failure("No OpenRouter API key found", "config");
|
|
248
|
+
const result = await fetchJson(
|
|
249
|
+
"https://openrouter.ai/api/v1/key",
|
|
250
|
+
{
|
|
251
|
+
headers: {
|
|
252
|
+
Authorization: `Bearer ${accessToken}`,
|
|
253
|
+
Accept: "application/json",
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
signal,
|
|
257
|
+
);
|
|
258
|
+
if (!result.ok) return failure(result.message, result.kind);
|
|
259
|
+
return success("openrouter", parseOpenRouterUsage(result.data));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export async function fetchOpenRouterQuotas(
|
|
263
|
+
authStorage: AuthStorage,
|
|
264
|
+
signal?: AbortSignal,
|
|
265
|
+
): Promise<QuotasResult> {
|
|
266
|
+
return fetchOpenRouterQuotasWithToken(
|
|
267
|
+
await providerAccessToken(authStorage, "openrouter"),
|
|
268
|
+
signal,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export async function fetchSyntheticQuotas(
|
|
273
|
+
_authStorage: AuthStorage,
|
|
274
|
+
signal?: AbortSignal,
|
|
275
|
+
): Promise<QuotasResult> {
|
|
276
|
+
const apiKey = process.env.SYNTHETIC_API_KEY;
|
|
277
|
+
if (!apiKey) return failure("No Synthetic API key found (set SYNTHETIC_API_KEY)", "config");
|
|
278
|
+
|
|
279
|
+
const result = await fetchJson(
|
|
280
|
+
"https://api.synthetic.new/v2/quotas",
|
|
281
|
+
{
|
|
282
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
283
|
+
},
|
|
284
|
+
signal,
|
|
285
|
+
);
|
|
286
|
+
if (!result.ok) return failure(result.message, result.kind);
|
|
287
|
+
return success("synthetic", parseSyntheticUsage(result.data));
|
|
288
|
+
}
|
|
289
|
+
|
|
241
290
|
export const PROVIDER_FETCHERS = {
|
|
242
291
|
anthropic: fetchAnthropicQuotas,
|
|
243
292
|
"openai-codex": fetchCodexQuotas,
|
|
244
293
|
"github-copilot": fetchGitHubCopilotQuotas,
|
|
294
|
+
openrouter: fetchOpenRouterQuotas,
|
|
295
|
+
synthetic: fetchSyntheticQuotas,
|
|
245
296
|
} as const;
|
|
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
import { parseAnthropicUsage } from "./providers.js";
|
|
3
3
|
import { parseCodexUsage } from "./providers.js";
|
|
4
4
|
import { parseGitHubCopilotUsage } from "./providers.js";
|
|
5
|
+
import { parseOpenRouterUsage } from "./providers.js";
|
|
5
6
|
|
|
6
7
|
describe("parseAnthropicUsage", () => {
|
|
7
8
|
it("maps oauth usage response into quota windows", () => {
|
|
@@ -254,3 +255,156 @@ describe("parseGitHubCopilotUsage", () => {
|
|
|
254
255
|
expect(windows[1]).toMatchObject({ label: "Completions / month", usedPercent: 0 });
|
|
255
256
|
});
|
|
256
257
|
});
|
|
258
|
+
|
|
259
|
+
describe("parseOpenRouterUsage", () => {
|
|
260
|
+
it("maps API response with monthly budget limit", () => {
|
|
261
|
+
const windows = parseOpenRouterUsage({
|
|
262
|
+
data: {
|
|
263
|
+
label: "Test Key",
|
|
264
|
+
limit: 50,
|
|
265
|
+
limit_remaining: 35,
|
|
266
|
+
limit_reset: "monthly",
|
|
267
|
+
usage: 15,
|
|
268
|
+
usage_daily: 2.5,
|
|
269
|
+
usage_weekly: 12,
|
|
270
|
+
usage_monthly: 15,
|
|
271
|
+
byok_usage: 0,
|
|
272
|
+
byok_usage_daily: 0,
|
|
273
|
+
byok_usage_weekly: 0,
|
|
274
|
+
byok_usage_monthly: 0,
|
|
275
|
+
creator_user_id: "user123",
|
|
276
|
+
include_byok_in_limit: false,
|
|
277
|
+
is_free_tier: false,
|
|
278
|
+
is_management_key: false,
|
|
279
|
+
is_provisioning_key: false,
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// When limit is set, we get: Monthly Budget + Daily + Weekly + Monthly = 4 windows
|
|
284
|
+
expect(windows).toHaveLength(4);
|
|
285
|
+
|
|
286
|
+
// Monthly Budget window
|
|
287
|
+
const budget = windows.find((w) => w.label === "Monthly Budget");
|
|
288
|
+
expect(budget).toBeDefined();
|
|
289
|
+
expect(budget).toMatchObject({
|
|
290
|
+
provider: "openrouter",
|
|
291
|
+
label: "Monthly Budget",
|
|
292
|
+
usedPercent: 30, // 15/50 = 30%
|
|
293
|
+
isCurrency: true,
|
|
294
|
+
usedValue: 15,
|
|
295
|
+
limitValue: 50,
|
|
296
|
+
showPace: true,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// Daily, Weekly, Monthly usage windows
|
|
300
|
+
expect(windows.find((w) => w.label === "Daily")).toBeDefined();
|
|
301
|
+
expect(windows.find((w) => w.label === "Weekly")).toBeDefined();
|
|
302
|
+
expect(windows.find((w) => w.label === "Monthly")).toBeDefined();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("maps unlimited key with remaining credits", () => {
|
|
306
|
+
const windows = parseOpenRouterUsage({
|
|
307
|
+
data: {
|
|
308
|
+
label: "Unlimited Key",
|
|
309
|
+
limit: null,
|
|
310
|
+
limit_remaining: 100,
|
|
311
|
+
limit_reset: null,
|
|
312
|
+
usage: 50,
|
|
313
|
+
usage_daily: 5,
|
|
314
|
+
usage_weekly: 20,
|
|
315
|
+
usage_monthly: 50,
|
|
316
|
+
byok_usage: 0,
|
|
317
|
+
byok_usage_daily: 0,
|
|
318
|
+
byok_usage_weekly: 0,
|
|
319
|
+
byok_usage_monthly: 0,
|
|
320
|
+
creator_user_id: "user123",
|
|
321
|
+
include_byok_in_limit: false,
|
|
322
|
+
is_free_tier: false,
|
|
323
|
+
is_management_key: false,
|
|
324
|
+
is_provisioning_key: false,
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// When unlimited with limit_remaining: Credits Remaining + Daily + Weekly + Monthly = 4 windows
|
|
329
|
+
expect(windows).toHaveLength(4);
|
|
330
|
+
|
|
331
|
+
// Credits Remaining window
|
|
332
|
+
const remaining = windows.find((w) => w.label === "Credits Remaining");
|
|
333
|
+
expect(remaining).toBeDefined();
|
|
334
|
+
expect(remaining).toMatchObject({
|
|
335
|
+
provider: "openrouter",
|
|
336
|
+
label: "Credits Remaining",
|
|
337
|
+
usedPercent: 0,
|
|
338
|
+
isCurrency: true,
|
|
339
|
+
usedValue: 100,
|
|
340
|
+
limitValue: 100,
|
|
341
|
+
showPace: false,
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("handles zero usage", () => {
|
|
346
|
+
const windows = parseOpenRouterUsage({
|
|
347
|
+
data: {
|
|
348
|
+
label: "Test Key",
|
|
349
|
+
limit: 100,
|
|
350
|
+
limit_remaining: 100,
|
|
351
|
+
limit_reset: "monthly",
|
|
352
|
+
usage: 0,
|
|
353
|
+
usage_daily: 0,
|
|
354
|
+
usage_weekly: 0,
|
|
355
|
+
usage_monthly: 0,
|
|
356
|
+
byok_usage: 0,
|
|
357
|
+
byok_usage_daily: 0,
|
|
358
|
+
byok_usage_weekly: 0,
|
|
359
|
+
byok_usage_monthly: 0,
|
|
360
|
+
creator_user_id: "user123",
|
|
361
|
+
include_byok_in_limit: false,
|
|
362
|
+
is_free_tier: false,
|
|
363
|
+
is_management_key: false,
|
|
364
|
+
is_provisioning_key: false,
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
const budget = windows.find((w) => w.label === "Monthly Budget");
|
|
369
|
+
expect(budget).toBeDefined();
|
|
370
|
+
expect(budget!.usedPercent).toBe(0);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("returns empty array when no data", () => {
|
|
374
|
+
const windows = parseOpenRouterUsage({});
|
|
375
|
+
expect(windows).toHaveLength(0);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("handles missing limit_remaining for unlimited keys", () => {
|
|
379
|
+
const windows = parseOpenRouterUsage({
|
|
380
|
+
data: {
|
|
381
|
+
label: "Test Key",
|
|
382
|
+
limit: null,
|
|
383
|
+
limit_remaining: null,
|
|
384
|
+
limit_reset: null,
|
|
385
|
+
usage: 25,
|
|
386
|
+
usage_daily: 3,
|
|
387
|
+
usage_weekly: 10,
|
|
388
|
+
usage_monthly: 25,
|
|
389
|
+
byok_usage: 0,
|
|
390
|
+
byok_usage_daily: 0,
|
|
391
|
+
byok_usage_weekly: 0,
|
|
392
|
+
byok_usage_monthly: 0,
|
|
393
|
+
creator_user_id: "user123",
|
|
394
|
+
include_byok_in_limit: false,
|
|
395
|
+
is_free_tier: false,
|
|
396
|
+
is_management_key: false,
|
|
397
|
+
is_provisioning_key: false,
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
// Should not have "Credits Remaining" window if limit_remaining is null
|
|
402
|
+
const remaining = windows.find((w) => w.label === "Credits Remaining");
|
|
403
|
+
expect(remaining).toBeUndefined();
|
|
404
|
+
|
|
405
|
+
// But should still have usage tracking windows
|
|
406
|
+
expect(windows.find((w) => w.label === "Daily")).toBeDefined();
|
|
407
|
+
expect(windows.find((w) => w.label === "Weekly")).toBeDefined();
|
|
408
|
+
expect(windows.find((w) => w.label === "Monthly")).toBeDefined();
|
|
409
|
+
});
|
|
410
|
+
});
|
|
@@ -243,3 +243,197 @@ export function parseGitHubCopilotUsage(data: any): QuotaWindow[] {
|
|
|
243
243
|
|
|
244
244
|
return windows;
|
|
245
245
|
}
|
|
246
|
+
|
|
247
|
+
// Helper functions for OpenRouter date calculations (UTC-based)
|
|
248
|
+
function calculateNextMidnightUTC(): Date {
|
|
249
|
+
const now = new Date();
|
|
250
|
+
const midnight = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1, 0, 0, 0));
|
|
251
|
+
return midnight;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function calculateNextMondayUTC(): Date {
|
|
255
|
+
const now = new Date();
|
|
256
|
+
const day = now.getUTCDay(); // 0 = Sunday, 1 = Monday, etc.
|
|
257
|
+
const daysUntilMonday = day === 0 ? 1 : (8 - day); // Days until next Monday
|
|
258
|
+
const monday = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + daysUntilMonday, 0, 0, 0));
|
|
259
|
+
return monday;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function calculateNextMonthStartUTC(): Date {
|
|
263
|
+
const now = new Date();
|
|
264
|
+
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1, 0, 0, 0));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function parseOpenRouterUsage(data: any): QuotaWindow[] {
|
|
268
|
+
const windows: QuotaWindow[] = [];
|
|
269
|
+
const keyData = data?.data;
|
|
270
|
+
|
|
271
|
+
if (!keyData) return windows;
|
|
272
|
+
|
|
273
|
+
const limit = keyData.limit;
|
|
274
|
+
const limitRemaining = keyData.limit_remaining;
|
|
275
|
+
const usageDaily = keyData.usage_daily ?? 0;
|
|
276
|
+
const usageWeekly = keyData.usage_weekly ?? 0;
|
|
277
|
+
const usageMonthly = keyData.usage_monthly ?? 0;
|
|
278
|
+
|
|
279
|
+
// Monthly budget window (if limit is set)
|
|
280
|
+
if (limit != null && limit > 0) {
|
|
281
|
+
const usedPercent = safePercent(usageMonthly, limit);
|
|
282
|
+
windows.push({
|
|
283
|
+
provider: "openrouter",
|
|
284
|
+
label: "Monthly Budget",
|
|
285
|
+
usedPercent,
|
|
286
|
+
resetsAt: calculateNextMonthStartUTC(),
|
|
287
|
+
windowSeconds: 30 * 24 * 60 * 60,
|
|
288
|
+
usedValue: usageMonthly,
|
|
289
|
+
limitValue: limit,
|
|
290
|
+
isCurrency: true,
|
|
291
|
+
showPace: true,
|
|
292
|
+
paceScale: 1,
|
|
293
|
+
nextLabel: "Resets",
|
|
294
|
+
});
|
|
295
|
+
} else if (limitRemaining != null && limitRemaining >= 0) {
|
|
296
|
+
// Unlimited key with remaining tracked
|
|
297
|
+
windows.push({
|
|
298
|
+
provider: "openrouter",
|
|
299
|
+
label: "Credits Remaining",
|
|
300
|
+
usedPercent: 0,
|
|
301
|
+
resetsAt: new Date(0),
|
|
302
|
+
windowSeconds: 0,
|
|
303
|
+
usedValue: limitRemaining,
|
|
304
|
+
limitValue: limitRemaining,
|
|
305
|
+
isCurrency: true,
|
|
306
|
+
showPace: false,
|
|
307
|
+
nextLabel: undefined,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Daily usage window (tracking only)
|
|
312
|
+
windows.push({
|
|
313
|
+
provider: "openrouter",
|
|
314
|
+
label: "Daily",
|
|
315
|
+
usedPercent: 0,
|
|
316
|
+
resetsAt: calculateNextMidnightUTC(),
|
|
317
|
+
windowSeconds: 24 * 60 * 60,
|
|
318
|
+
usedValue: usageDaily,
|
|
319
|
+
limitValue: 0,
|
|
320
|
+
isCurrency: true,
|
|
321
|
+
showPace: false,
|
|
322
|
+
nextLabel: "UTC",
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// Weekly usage window (tracking only)
|
|
326
|
+
windows.push({
|
|
327
|
+
provider: "openrouter",
|
|
328
|
+
label: "Weekly",
|
|
329
|
+
usedPercent: 0,
|
|
330
|
+
resetsAt: calculateNextMondayUTC(),
|
|
331
|
+
windowSeconds: 7 * 24 * 60 * 60,
|
|
332
|
+
usedValue: usageWeekly,
|
|
333
|
+
limitValue: 0,
|
|
334
|
+
isCurrency: true,
|
|
335
|
+
showPace: false,
|
|
336
|
+
nextLabel: "Week",
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// Monthly usage window (tracking only)
|
|
340
|
+
windows.push({
|
|
341
|
+
provider: "openrouter",
|
|
342
|
+
label: "Monthly",
|
|
343
|
+
usedPercent: 0,
|
|
344
|
+
resetsAt: calculateNextMonthStartUTC(),
|
|
345
|
+
windowSeconds: 30 * 24 * 60 * 60,
|
|
346
|
+
usedValue: usageMonthly,
|
|
347
|
+
limitValue: 0,
|
|
348
|
+
isCurrency: true,
|
|
349
|
+
showPace: false,
|
|
350
|
+
nextLabel: "Month",
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
return windows;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function parseSyntheticUsage(data: any): QuotaWindow[] {
|
|
357
|
+
const windows: QuotaWindow[] = [];
|
|
358
|
+
|
|
359
|
+
// Subscription (requests)
|
|
360
|
+
if (data?.subscription) {
|
|
361
|
+
windows.push({
|
|
362
|
+
provider: "synthetic",
|
|
363
|
+
label: "Subscription",
|
|
364
|
+
usedPercent: safePercent(data.subscription.requests, data.subscription.limit),
|
|
365
|
+
resetsAt: parseDateish(data.subscription.renewsAt),
|
|
366
|
+
windowSeconds: 30 * 24 * 60 * 60,
|
|
367
|
+
usedValue: data.subscription.requests,
|
|
368
|
+
limitValue: data.subscription.limit,
|
|
369
|
+
showPace: true,
|
|
370
|
+
nextLabel: "Resets",
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Search hourly
|
|
375
|
+
if (data?.search?.hourly) {
|
|
376
|
+
windows.push({
|
|
377
|
+
provider: "synthetic",
|
|
378
|
+
label: "Search / hour",
|
|
379
|
+
usedPercent: safePercent(data.search.hourly.requests, data.search.hourly.limit),
|
|
380
|
+
resetsAt: parseDateish(data.search.hourly.renewsAt),
|
|
381
|
+
windowSeconds: 60 * 60,
|
|
382
|
+
usedValue: data.search.hourly.requests,
|
|
383
|
+
limitValue: data.search.hourly.limit,
|
|
384
|
+
showPace: false,
|
|
385
|
+
nextLabel: "Resets",
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Free tool calls
|
|
390
|
+
if (data?.freeToolCalls) {
|
|
391
|
+
windows.push({
|
|
392
|
+
provider: "synthetic",
|
|
393
|
+
label: "Free Tools",
|
|
394
|
+
usedPercent: safePercent(data.freeToolCalls.requests, data.freeToolCalls.limit),
|
|
395
|
+
resetsAt: parseDateish(data.freeToolCalls.renewsAt),
|
|
396
|
+
windowSeconds: 30 * 24 * 60 * 60,
|
|
397
|
+
usedValue: data.freeToolCalls.requests,
|
|
398
|
+
limitValue: data.freeToolCalls.limit,
|
|
399
|
+
showPace: true,
|
|
400
|
+
nextLabel: "Resets",
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
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
|
+
return windows;
|
|
439
|
+
}
|