@latentminds/pi-quotas 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,12 +1,14 @@
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, OpenRouter, and Synthetic — directly in your Pi session.
3
+ Quota monitoring for Pi. Shows remaining usage and rate limits for Anthropic, OpenAI Codex, GitHub Copilot, OpenRouter, and Synthetic — directly in your Pi session.
4
4
 
5
5
  ## Screenshots
6
6
 
7
+
7
8
  | `/quotas` dashboard | Footer status |
8
- |---|---|
9
- | ![Quotas dashboard](docs/quotas-dashboard.png) | ![Footer status](docs/footer-status.png) |
9
+ | ------------------- | ------------- |
10
+ | Quotas dashboard | Footer status |
11
+
10
12
 
11
13
  ## Install
12
14
 
@@ -31,15 +33,17 @@ pi -e npm:@latentminds/pi-quotas
31
33
 
32
34
  ## Commands
33
35
 
34
- | Command | Description |
35
- |---------|-------------|
36
- | `/quotas` | Combined quota dashboard for all providers |
37
- | `/anthropic:quotas` | Anthropic quotas only |
38
- | `/codex:quotas` | OpenAI Codex quotas only |
39
- | `/github:quotas` | GitHub Copilot quotas only |
40
- | `/openrouter:quotas` | OpenRouter quotas only |
41
- | `/synthetic:quotas` | Synthetic quotas only |
42
- | `/quotas:settings` | Toggle individual features on or off |
36
+
37
+ | Command | Description |
38
+ | -------------------- | ------------------------------------------ |
39
+ | `/quotas` | Combined quota dashboard for all providers |
40
+ | `/anthropic:quotas` | Anthropic quotas only |
41
+ | `/codex:quotas` | OpenAI Codex quotas only |
42
+ | `/github:quotas` | GitHub Copilot quotas only |
43
+ | `/openrouter:quotas` | OpenRouter quotas only |
44
+ | `/synthetic:quotas` | Synthetic quotas only |
45
+ | `/quotas:settings` | Toggle individual features on or off |
46
+
43
47
 
44
48
  ## Features
45
49
 
@@ -58,6 +62,7 @@ Automatic notifications when projected usage is on track to exceed limits before
58
62
  ### Per-feature toggles
59
63
 
60
64
  Use `/quotas:settings` to enable or disable:
65
+
61
66
  - Combined `/quotas` command
62
67
  - Per-provider commands (`/anthropic:quotas`, `/codex:quotas`, `/github:quotas`, `/openrouter:quotas`, `/synthetic:quotas`)
63
68
  - Footer status widget
@@ -68,13 +73,15 @@ Settings can be saved globally (`~/.pi/agent/extensions/quotas.json`) or per-pro
68
73
 
69
74
  ## Supported providers
70
75
 
71
- | Provider | Windows | Details |
72
- |----------|---------|---------|
73
- | Anthropic | 5h, 7d, per-model 7d, extra usage | Utilization percentages; optional overage budget in local currency |
74
- | OpenAI Codex | 5h, 7d, credits, spend cap | Rate-limit percentages; credit balance; spend-cap reached/OK |
75
- | GitHub Copilot | Premium/chat/completions per month | Remaining/entitlement counts with overage indicators |
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 |
76
+
77
+ | Provider | Windows | Details |
78
+ | -------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
79
+ | Anthropic | 5h, 7d, per-model 7d, extra usage | Utilization percentages; optional overage budget in local currency |
80
+ | OpenAI Codex | 5h, 7d, credits, spend cap | Rate-limit percentages; credit balance; spend-cap reached/OK |
81
+ | GitHub Copilot | Premium/chat/completions per month | Remaining/entitlement counts with overage indicators |
82
+ | OpenRouter | Monthly budget, daily/weekly/monthly usage | USD spending tracking with cents precision; optional per-key budget limits; UTC-based period resets |
83
+ | Synthetic | Subscription, search/hour, free tools, weekly tokens, 5h limit | Request counts and token budgets; rolling five-hour rate limit; weekly token regen |
84
+
78
85
 
79
86
  ## Credentials
80
87
 
@@ -104,6 +111,3 @@ See [CHANGELOG.md](CHANGELOG.md) for release notes and recent changes.
104
111
 
105
112
  This project was inspired by [@aliou/pi-synthetic](https://www.npmjs.com/package/@aliou/pi-synthetic).
106
113
 
107
- <p align="center">
108
- <img src="docs/latent-minds@2x.png" alt="Latent Minds" width="320" />
109
- </p>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@latentminds/pi-quotas",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "private": false,
@@ -0,0 +1,125 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import pkg from "../../../../package.json" with { type: "json" };
3
+ import { QuotasComponent } from "./quotas-display.js";
4
+
5
+ const ansi = {
6
+ accent: "\x1b[32m",
7
+ border: "\x1b[36m",
8
+ dim: "\x1b[2m",
9
+ error: "\x1b[31m",
10
+ muted: "\x1b[90m",
11
+ success: "\x1b[32m",
12
+ warning: "\x1b[33m",
13
+ } as const;
14
+
15
+ function fakeTheme() {
16
+ return {
17
+ bold: (text: string) => text,
18
+ fg: (color: keyof typeof ansi, text: string) => `${ansi[color] ?? ""}${text}\x1b[0m`,
19
+ getFgAnsi: (color: keyof typeof ansi) => ansi[color] ?? "",
20
+ };
21
+ }
22
+
23
+ function makeComponent(): QuotasComponent {
24
+ return new QuotasComponent(
25
+ fakeTheme() as any,
26
+ { requestRender: () => {} } as any,
27
+ "Quotas",
28
+ () => {},
29
+ () => {},
30
+ );
31
+ }
32
+
33
+ describe("QuotasComponent", () => {
34
+ it("renders the pi-quotas package version in the dashboard footer", () => {
35
+ const component = makeComponent();
36
+ component.setState({ type: "loaded", snapshots: [] });
37
+
38
+ expect(component.render(70).join("\n")).toContain(`pi-quotas v${pkg.version}`);
39
+ });
40
+
41
+ it("does not render an accent-colored pace marker inside filled warning bars", () => {
42
+ vi.useFakeTimers();
43
+ vi.setSystemTime(new Date("2026-05-14T13:42:11Z"));
44
+
45
+ const component = makeComponent();
46
+ component.setState({
47
+ type: "loaded",
48
+ snapshots: [
49
+ {
50
+ provider: "github-copilot",
51
+ result: {
52
+ success: true,
53
+ data: {
54
+ provider: "github-copilot",
55
+ windows: [
56
+ {
57
+ provider: "github-copilot",
58
+ label: "Premium / month",
59
+ usedPercent: 83,
60
+ resetsAt: new Date("2026-06-01T10:00:00Z"),
61
+ windowSeconds: 31 * 24 * 3600,
62
+ usedValue: 249,
63
+ limitValue: 300,
64
+ showPace: true,
65
+ nextLabel: "Resets",
66
+ nextAmount: "overage allowed",
67
+ },
68
+ ],
69
+ },
70
+ },
71
+ },
72
+ ],
73
+ });
74
+
75
+ const output = component.render(70).join("\n");
76
+
77
+ expect(output).toContain("51/300 left");
78
+ expect(output).not.toContain(`${ansi.accent}|`);
79
+
80
+ vi.useRealTimers();
81
+ });
82
+
83
+ it("does not render a pace marker for zero-usage windows", () => {
84
+ vi.useFakeTimers();
85
+ vi.setSystemTime(new Date("2026-05-14T13:49:29Z"));
86
+
87
+ const component = makeComponent();
88
+ component.setState({
89
+ type: "loaded",
90
+ snapshots: [
91
+ {
92
+ provider: "synthetic",
93
+ result: {
94
+ success: true,
95
+ data: {
96
+ provider: "synthetic",
97
+ windows: [
98
+ {
99
+ provider: "synthetic",
100
+ label: "Credits / week",
101
+ usedPercent: 0,
102
+ resetsAt: new Date("2026-05-14T16:40:29Z"),
103
+ windowSeconds: 7 * 24 * 3600,
104
+ usedValue: 0,
105
+ limitValue: 24,
106
+ isCurrency: true,
107
+ showPace: true,
108
+ nextLabel: "Next regen",
109
+ nextAmount: "+$0.48",
110
+ },
111
+ ],
112
+ },
113
+ },
114
+ },
115
+ ],
116
+ });
117
+
118
+ const output = component.render(70).join("\n");
119
+
120
+ expect(output).toContain("$0.00 / $24.00");
121
+ expect(output).not.toContain("|");
122
+
123
+ vi.useRealTimers();
124
+ });
125
+ });
@@ -2,6 +2,7 @@ import type { Theme } from "@mariozechner/pi-coding-agent";
2
2
  import { DynamicBorder } from "@mariozechner/pi-coding-agent";
3
3
  import type { Component } from "@mariozechner/pi-tui";
4
4
  import { Loader, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
5
+ import pkg from "../../../../package.json" with { type: "json" };
5
6
  import { PROVIDER_LABELS } from "../../../lib/quotas.js";
6
7
  import type { QuotasResult, SupportedQuotaProvider } from "../../../types/quotas.js";
7
8
  import {
@@ -19,10 +20,6 @@ type QuotasState =
19
20
  | { type: "loading" }
20
21
  | { type: "loaded"; snapshots: Snapshot[] };
21
22
 
22
- function fgAnsiToBg(fgAnsi: string): string {
23
- return fgAnsi.split("[38;").join("[48;").replace(/\[3([0-9])m/g, "[4$1m");
24
- }
25
-
26
23
  function renderProgressBar(
27
24
  percent: number,
28
25
  width: number,
@@ -33,6 +30,7 @@ function renderProgressBar(
33
30
  const clamped = Math.max(0, Math.min(100, Math.round(percent)));
34
31
  const filled = Math.round((clamped / 100) * width);
35
32
  const showPace =
33
+ percent > 0 &&
36
34
  pacePercent !== null &&
37
35
  pacePercent !== undefined &&
38
36
  pacePercent >= 5 &&
@@ -44,11 +42,10 @@ function renderProgressBar(
44
42
  const parts: string[] = [];
45
43
  for (let idx = 0; idx < width; idx++) {
46
44
  if (paceIndex !== null && idx === paceIndex) {
47
- const markerColor = idx < filled ? "accent" : fillColor;
48
45
  if (idx < filled) {
49
- parts.push(`${fgAnsiToBg(theme.getFgAnsi(fillColor))}${theme.getFgAnsi(markerColor)}|${reset}`);
46
+ parts.push(theme.fg(fillColor, "█"));
50
47
  } else {
51
- parts.push(theme.fg(markerColor, "|"));
48
+ parts.push(theme.fg(fillColor, "|"));
52
49
  }
53
50
  } else if (idx < filled) {
54
51
  parts.push(theme.fg(fillColor, "█"));
@@ -123,7 +120,7 @@ export class QuotasComponent implements Component {
123
120
  }
124
121
 
125
122
  lines.push("");
126
- lines.push(this.theme.fg("dim", " r to refresh q/Esc to close"));
123
+ lines.push(this.theme.fg("dim", ` pi-quotas v${pkg.version} · r to refresh q/Esc to close`));
127
124
  lines.push(...border.render(width));
128
125
  return lines;
129
126
  }
@@ -85,6 +85,12 @@ export default async function (pi: ExtensionAPI) {
85
85
  ctx.ui.notify(`${providerName} quota warning:\n${lines.join("\n")}`, level);
86
86
  }
87
87
 
88
+ function scheduleCheck(ctx: ExtensionContext, onlyNew: boolean): void {
89
+ void check(ctx, onlyNew).catch(() => {
90
+ // Quota warnings are opportunistic; never let a failed quota check block Pi events.
91
+ });
92
+ }
93
+
88
94
  pi.events.on(QUOTAS_CONFIG_UPDATED_EVENT, (data: unknown) => {
89
95
  enabled = (data as QuotasConfigUpdatedPayload).config.quotaWarnings;
90
96
  if (!enabled) {
@@ -93,21 +99,21 @@ export default async function (pi: ExtensionAPI) {
93
99
  }
94
100
  if (currentContext) {
95
101
  clearAlertState();
96
- void check(currentContext, false);
102
+ scheduleCheck(currentContext, false);
97
103
  }
98
104
  });
99
105
 
100
- pi.on("session_start", async (_event, ctx) => {
106
+ pi.on("session_start", (_event, ctx) => {
101
107
  currentContext = ctx;
102
108
  clearAlertState();
103
109
  if (!enabled) return;
104
- await check(ctx, false);
110
+ scheduleCheck(ctx, false);
105
111
  });
106
112
 
107
- pi.on("turn_end", async (_event, ctx) => {
113
+ pi.on("turn_end", (_event, ctx) => {
108
114
  currentContext = ctx;
109
115
  if (!enabled) return;
110
- await check(ctx, true);
116
+ scheduleCheck(ctx, true);
111
117
  });
112
118
 
113
119
  pi.on("model_select", async (_event, ctx) => {
@@ -176,6 +176,12 @@ export default async function (pi: ExtensionAPI) {
176
176
  }
177
177
  });
178
178
 
179
+ function scheduleRefresh(ctx: ExtensionContext): void {
180
+ void refresher.refreshFor(ctx).catch(() => {
181
+ if (ctx.hasUI) ctx.ui.setStatus(EXTENSION_ID, ctx.ui.theme.fg("warning", "usage unavailable"));
182
+ });
183
+ }
184
+
179
185
  pi.events.on(QUOTAS_CONFIG_UPDATED_EVENT, (data: unknown) => {
180
186
  const config = (data as QuotasConfigUpdatedPayload).config;
181
187
  enabled = config.usageStatus;
@@ -186,7 +192,7 @@ export default async function (pi: ExtensionAPI) {
186
192
  }
187
193
  if (currentContext) {
188
194
  refresher.start();
189
- void refresher.refreshFor(currentContext);
195
+ scheduleRefresh(currentContext);
190
196
  }
191
197
  });
192
198
 
@@ -198,7 +204,7 @@ export default async function (pi: ExtensionAPI) {
198
204
  return deferToSynthetic && syntheticUsageActive && provider === "synthetic";
199
205
  }
200
206
 
201
- pi.on("session_start", async (_event, ctx) => {
207
+ pi.on("session_start", (_event, ctx) => {
202
208
  currentContext = ctx;
203
209
  if (!enabled) return;
204
210
  if (shouldDeferToSynthetic(ctx.model?.provider)) {
@@ -206,17 +212,17 @@ export default async function (pi: ExtensionAPI) {
206
212
  return;
207
213
  }
208
214
  refresher.start();
209
- await refresher.refreshFor(ctx);
215
+ scheduleRefresh(ctx);
210
216
  });
211
217
 
212
- pi.on("turn_end", async (_event, ctx) => {
218
+ pi.on("turn_end", (_event, ctx) => {
213
219
  currentContext = ctx;
214
220
  if (!enabled) return;
215
221
  if (shouldDeferToSynthetic(ctx.model?.provider)) return;
216
- await refresher.refreshFor(ctx);
222
+ scheduleRefresh(ctx);
217
223
  });
218
224
 
219
- pi.on("model_select", async (_event, ctx) => {
225
+ pi.on("model_select", (_event, ctx) => {
220
226
  currentContext = ctx;
221
227
  if (!enabled) {
222
228
  refresher.stop(ctx);
@@ -226,7 +232,7 @@ export default async function (pi: ExtensionAPI) {
226
232
  ctx.ui.setStatus(EXTENSION_ID, undefined);
227
233
  return;
228
234
  }
229
- await refresher.refreshFor(ctx);
235
+ scheduleRefresh(ctx);
230
236
  });
231
237
 
232
238
  pi.on("session_shutdown", async (_event, ctx) => {
@@ -1,7 +1,9 @@
1
+ import { AuthStorage } from "@mariozechner/pi-coding-agent";
1
2
  import { afterEach, describe, expect, it, vi } from "vitest";
2
3
  import {
3
4
  fetchAnthropicQuotasWithToken,
4
5
  fetchCodexQuotasWithToken,
6
+ fetchGitHubCopilotQuotas,
5
7
  fetchGitHubCopilotQuotasWithToken,
6
8
  fetchOpenRouterQuotasWithToken,
7
9
  } from "./fetch.js";
@@ -135,6 +137,44 @@ describe("fetchGitHubCopilotQuotasWithToken", () => {
135
137
  expect(result.success).toBe(true);
136
138
  expect(globalThis.fetch).toHaveBeenCalledTimes(2);
137
139
  });
140
+
141
+ it("uses the stored GitHub OAuth refresh token for Pi 0.74 Copilot quota checks", async () => {
142
+ const auth = AuthStorage.inMemory({
143
+ "github-copilot": {
144
+ type: "oauth",
145
+ refresh: "ghu-refresh-token",
146
+ access: "tid=abc;proxy-ep=proxy.individual.githubcopilot.com;exp=1778611280",
147
+ expires: Date.now() + 60_000,
148
+ },
149
+ });
150
+
151
+ globalThis.fetch = vi.fn(async (_url, init) => {
152
+ const authorization = new Headers(init?.headers).get("authorization");
153
+ if (authorization === "Bearer ghu-refresh-token") {
154
+ return new Response(
155
+ JSON.stringify({
156
+ quota_reset_date: "2026-05-01T00:00:00Z",
157
+ quota_snapshots: {
158
+ premium_interactions: { entitlement: 300, remaining: 210 },
159
+ },
160
+ }),
161
+ { status: 200 },
162
+ );
163
+ }
164
+ return new Response(JSON.stringify({ message: "Bad credentials" }), { status: 401 });
165
+ }) as any;
166
+
167
+ const result = await fetchGitHubCopilotQuotas(auth);
168
+
169
+ expect(result.success).toBe(true);
170
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
171
+ expect(globalThis.fetch).toHaveBeenCalledWith(
172
+ "https://api.github.com/copilot_internal/user",
173
+ expect.objectContaining({
174
+ headers: expect.objectContaining({ Authorization: "Bearer ghu-refresh-token" }),
175
+ }),
176
+ );
177
+ });
138
178
  });
139
179
 
140
180
  describe("fetchOpenRouterQuotasWithToken", () => {
@@ -179,6 +179,32 @@ async function tryGitHubUserEndpoint(
179
179
  );
180
180
  }
181
181
 
182
+ function githubOAuthToken(authStorage: AuthStorage): string | undefined {
183
+ // Pi's GitHub Copilot OAuth credential stores the GitHub OAuth token in
184
+ // `refresh`; `access` is a Copilot proxy token (tid=...;proxy-ep=...) that
185
+ // is valid for model calls but rejected by api.github.com quota endpoints.
186
+ const credential = authStorage.get("github-copilot") as any;
187
+ if (credential?.type !== "oauth") return undefined;
188
+ return typeof credential.refresh === "string" && credential.refresh.length > 0
189
+ ? credential.refresh
190
+ : undefined;
191
+ }
192
+
193
+ async function fetchGitHubCopilotQuotasWithGitHubToken(
194
+ githubToken: string | undefined,
195
+ signal?: AbortSignal,
196
+ ): Promise<QuotasResult> {
197
+ if (!githubToken) return failure("No GitHub Copilot OAuth token found", "config");
198
+
199
+ const bearerUsage = await tryGitHubUserEndpoint(`Bearer ${githubToken}`, signal);
200
+ if (bearerUsage.ok) return success("github-copilot", parseGitHubCopilotUsage(bearerUsage.data));
201
+
202
+ const tokenUsage = await tryGitHubUserEndpoint(`token ${githubToken}`, signal);
203
+ if (tokenUsage.ok) return success("github-copilot", parseGitHubCopilotUsage(tokenUsage.data));
204
+
205
+ return failure(bearerUsage.message, bearerUsage.kind);
206
+ }
207
+
182
208
  export async function fetchGitHubCopilotQuotasWithToken(
183
209
  accessToken: string | undefined,
184
210
  signal?: AbortSignal,
@@ -234,6 +260,18 @@ export async function fetchGitHubCopilotQuotas(
234
260
  authStorage: AuthStorage,
235
261
  signal?: AbortSignal,
236
262
  ): Promise<QuotasResult> {
263
+ const oauthResult = await fetchGitHubCopilotQuotasWithGitHubToken(
264
+ githubOAuthToken(authStorage),
265
+ signal,
266
+ );
267
+ if (
268
+ oauthResult.success ||
269
+ oauthResult.error.kind === "cancelled" ||
270
+ oauthResult.error.kind === "timeout"
271
+ ) {
272
+ return oauthResult;
273
+ }
274
+
237
275
  return fetchGitHubCopilotQuotasWithToken(
238
276
  await providerAccessToken(authStorage, "github-copilot"),
239
277
  signal,
@@ -74,13 +74,33 @@ describe("assessWindow", () => {
74
74
  expect(assessWindow(w).severity).toBe("critical");
75
75
  });
76
76
 
77
+ it("warns for pace-aware windows once absolute usage reaches 80%", () => {
78
+ const now = Date.now();
79
+ const monthSeconds = 31 * 24 * 3600;
80
+ const w = makeWindow({
81
+ provider: "github-copilot",
82
+ label: "Premium / month",
83
+ usedPercent: 83,
84
+ usedValue: 249,
85
+ limitValue: 300,
86
+ showPace: true,
87
+ windowSeconds: monthSeconds,
88
+ // Screenshot repro: 51/300 left with reset in about 428h35m in a 31-day
89
+ // monthly window. Dynamic pace alone projects just under warning, but
90
+ // only 17% remains, so low-quota color should still be amber.
91
+ resetsAt: new Date(now + ((428 * 60 + 35) * 60 * 1000)),
92
+ });
93
+
94
+ expect(assessWindow(w).severity).toBe("warning");
95
+ });
96
+
77
97
  it("returns warning when projected exceeds dynamic warn threshold", () => {
78
98
  const w = makeWindow({
79
- usedPercent: 95,
99
+ usedPercent: 79,
80
100
  showPace: true,
81
101
  paceScale: 1,
82
102
  windowSeconds: 3600,
83
- resetsAt: new Date(Date.now() + 1800 * 1000),
103
+ resetsAt: new Date(Date.now() + 2520 * 1000),
84
104
  });
85
105
  expect(assessWindow(w).severity).toBe("warning");
86
106
  });
@@ -53,11 +53,24 @@ export function getProjectedPercent(
53
53
  return Math.max(0, (usedPercent / effectivePace) * 100);
54
54
  }
55
55
 
56
+ function absoluteUsageSeverity(window: QuotaWindow, percent: number): RiskSeverity {
57
+ if (window.limited || percent >= 100) return "critical";
58
+ if (percent >= 90) return "high";
59
+ if (percent >= 80) return "warning";
60
+ return "none";
61
+ }
62
+
63
+ function maxSeverity(a: RiskSeverity, b: RiskSeverity): RiskSeverity {
64
+ const order: RiskSeverity[] = ["none", "warning", "high", "critical"];
65
+ return order[Math.max(order.indexOf(a), order.indexOf(b))] ?? "none";
66
+ }
67
+
56
68
  export function assessWindow(window: QuotaWindow): RiskAssessment {
57
69
  const rawPace = window.showPace ? getPacePercent(window) : null;
58
70
  const pacePercent =
59
71
  rawPace !== null ? rawPace * (window.paceScale ?? 1) : null;
60
72
  const projectedPercent = getProjectedPercent(window.usedPercent, pacePercent);
73
+ const absoluteSeverity = absoluteUsageSeverity(window, window.usedPercent);
61
74
 
62
75
  let progress: number | null = null;
63
76
  if (pacePercent !== null) progress = pacePercent / 100;
@@ -70,10 +83,7 @@ export function assessWindow(window: QuotaWindow): RiskAssessment {
70
83
  };
71
84
 
72
85
  if (progress === null) {
73
- let severity: RiskSeverity = "none";
74
- if (window.limited || projectedPercent >= 100) severity = "critical";
75
- else if (projectedPercent >= 90) severity = "high";
76
- else if (projectedPercent >= 80) severity = "warning";
86
+ const severity = absoluteUsageSeverity(window, projectedPercent);
77
87
 
78
88
  return {
79
89
  ...base,
@@ -121,7 +131,7 @@ export function assessWindow(window: QuotaWindow): RiskAssessment {
121
131
  warnProjectedPercent,
122
132
  highProjectedPercent,
123
133
  criticalProjectedPercent,
124
- severity,
134
+ severity: maxSeverity(severity, absoluteSeverity),
125
135
  };
126
136
  }
127
137