@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 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, and GitHub Copilot — directly in your Pi session.
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 updated every 60 seconds and on each turn. Colours shift from green → amber → red as usage climbs.
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 if Pi can use the provider, pi-quotas can check its quotas.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@latentminds/pi-quotas",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "private": false,
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 `/github:quotas`",
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, and GitHub Copilot",
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
- usedStr = `$${window.usedValue.toFixed(2)} / $${window.limitValue.toFixed(2)}`;
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
- valueText = theme.fg(color, `$${w.usedValue.toFixed(0)}/$${w.limitValue.toFixed(0)}`);
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 formatStatus(ctx: ExtensionContext, windows: WindowStatus[]): string {
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", ` (↺${formatResetTime(w.resetsAt)})`) : "";
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((window) => ({
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
+ });
@@ -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
+ }
@@ -1,7 +1,9 @@
1
1
  export type SupportedQuotaProvider =
2
2
  | "anthropic"
3
3
  | "openai-codex"
4
- | "github-copilot";
4
+ | "github-copilot"
5
+ | "openrouter"
6
+ | "synthetic";
5
7
 
6
8
  export type QuotasErrorKind =
7
9
  | "cancelled"