aigetwey 1.2.0 → 1.3.0

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +19 -1
  2. package/README.md +28 -7
  3. package/config.example.yaml +0 -1
  4. package/dashboard/src/app/(console)/quota/page.tsx +2 -2
  5. package/dashboard/src/components/BudgetForm.tsx +15 -17
  6. package/dashboard/src/components/{QuotaView.tsx → BudgetTracker.tsx} +71 -56
  7. package/dashboard/src/components/CooldownTimer.tsx +1 -1
  8. package/dashboard/src/components/EndpointView.tsx +255 -47
  9. package/dashboard/src/components/LogTable.tsx +32 -25
  10. package/dashboard/src/components/ProviderManager.tsx +3 -28
  11. package/dashboard/src/components/Rail.tsx +1 -1
  12. package/dashboard/src/components/RoutingView.tsx +6 -2
  13. package/dashboard/src/components/TopBar.tsx +1 -1
  14. package/dashboard/src/components/ui.tsx +6 -1
  15. package/dashboard/src/lib/client.ts +6 -5
  16. package/dashboard/src/lib/gateway.ts +23 -16
  17. package/dist/config.js +86 -23
  18. package/dist/config.js.map +1 -1
  19. package/dist/core/budget.js +1 -1
  20. package/dist/core/budget.js.map +1 -1
  21. package/dist/core/fallback.js +0 -6
  22. package/dist/core/fallback.js.map +1 -1
  23. package/dist/core/handler.js +6 -5
  24. package/dist/core/handler.js.map +1 -1
  25. package/dist/core/keysUsage.js +15 -0
  26. package/dist/core/keysUsage.js.map +1 -0
  27. package/dist/core/ratelimit.js +15 -0
  28. package/dist/core/ratelimit.js.map +1 -0
  29. package/dist/core/state.js +5 -13
  30. package/dist/core/state.js.map +1 -1
  31. package/dist/core/window.js +35 -0
  32. package/dist/core/window.js.map +1 -0
  33. package/dist/db.js +0 -20
  34. package/dist/db.js.map +1 -1
  35. package/dist/routes/admin.js +55 -10
  36. package/dist/routes/admin.js.map +1 -1
  37. package/dist/routes/v1.js +14 -1
  38. package/dist/routes/v1.js.map +1 -1
  39. package/dist/server.js +1 -7
  40. package/dist/server.js.map +1 -1
  41. package/dist/stream/openai-stream.js +3 -0
  42. package/dist/stream/openai-stream.js.map +1 -1
  43. package/package.json +1 -1
  44. package/src/config.ts +89 -23
  45. package/src/core/budget.ts +1 -1
  46. package/src/core/fallback.ts +0 -9
  47. package/src/core/handler.ts +9 -7
  48. package/src/core/keysUsage.ts +49 -0
  49. package/src/core/ratelimit.ts +25 -0
  50. package/src/core/state.ts +4 -14
  51. package/src/core/window.ts +45 -0
  52. package/src/db.ts +0 -23
  53. package/src/routes/admin.ts +61 -9
  54. package/src/routes/v1.ts +18 -1
  55. package/src/server.ts +1 -8
  56. package/src/stream/openai-stream.ts +3 -1
  57. package/src/core/quota.ts +0 -253
package/CHANGELOG.md CHANGED
@@ -5,7 +5,25 @@ All notable changes to **aigetwey** are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [1.3.0] — 2026-06-26
9
+
10
+ ### Added
11
+ - **Per-key expiry** — set an expiry date on a gateway key; `/v1/*` calls with an
12
+ expired key return `403 key expired`. Editable on the Endpoint page next to the
13
+ per-key model allowlist and rate limit. Keys with no expiry never expire.
14
+
15
+ ### Changed
16
+ - **Budgets page → Overall + Keys** — the Budgets page now separates Overall caps
17
+ (global/provider/model) from a Keys section that lists every gateway key with its
18
+ spend; capped keys show a bar, reset countdown, and expiry, uncapped keys show
19
+ spend + "no limit". A key's spend cap and expiry are now set in one place — the
20
+ key settings on the Endpoint page — alongside its model allowlist and rate limit.
21
+ - **Recurring budgets** — each budget window (`5h`/`24h`/`7day`/`30day`) now
22
+ resets on a cycle anchored to when the budget was created, not a shared epoch
23
+ grid. A per-key budget shared with another device becomes a self-resetting
24
+ allowance; the reset countdown reflects that key's own cycle. Budgets in an
25
+ existing `config.yaml` (no stored anchor) keep the previous epoch-grid reset
26
+ until next edited.
9
27
 
10
28
  ## [1.2.0] — 2026-06-25
11
29
 
package/README.md CHANGED
@@ -35,9 +35,15 @@ See [CHANGELOG.md](./CHANGELOG.md) for release history.
35
35
  - **Token savers** — RTK compresses bulky `tool_result` blocks; caveman trims
36
36
  output prose; ponytail nudges minimal code; headroom compresses context via an
37
37
  external `/v1/compress`. All toggle per-endpoint.
38
- - **Quota + cost** — per-provider token budgets with scheduled-window resets, a
39
- reset countdown, and SQLite-backed usage/cost tracking.
40
- - **Dashboard** providers, combos, usage, quota, CLI tools, a live server
38
+ - **Share it safely** — hand a gateway key to a teammate or a friend and set its
39
+ model allowlist, rate limit, **spend cap**, and **expiry** in one place. Each
40
+ key's budget resets on its own rolling cycle, so a shared key behaves like a
41
+ self-renewing monthly allowance; an expired key is refused with `403`.
42
+ - **Budgets + cost** — scoped spend caps (global/provider/model/key) over rolling
43
+ `5h`/`24h`/`7day`/`30day` windows anchored to when each budget was created, with a
44
+ live reset countdown and SQLite-backed usage/cost tracking. The Budgets page
45
+ splits **Overall** caps from a **Keys** view that shows every key's spend.
46
+ - **Dashboard** — providers, combos, usage, budgets, CLI tools, a live server
41
47
  console, and a settings page with a per-model pricing editor.
42
48
 
43
49
  ### Token savers
@@ -117,7 +123,6 @@ providers:
117
123
  format: anthropic
118
124
  base_url: https://api.anthropic.com/v1
119
125
  api_keys: [sk-ant-xxx]
120
- quota: { window: weekly, reset_at: monday, timezone: Asia/Jakarta }
121
126
  - id: opencode-free
122
127
  format: openai
123
128
  base_url: https://opencode.ai/zen/v1
@@ -130,6 +135,12 @@ models: # routing: client alias -> prioritized provider chai
130
135
  model: [claude-sonnet-4-6, claude-sonnet-4-5]
131
136
  price_in: 3 # USD per 1M tokens (for cost tracking)
132
137
  price_out: 15
138
+
139
+ budgets: # spend caps; window = rolling 5h | 24h | 7day | 30day
140
+ - scope: { type: global }
141
+ unit: usd # usd (cost) or tokens
142
+ limit: 50
143
+ window: 30day # rolling 30-day lookback (epoch-aligned bucket)
133
144
  ```
134
145
 
135
146
  A **combo** is one of these `models` entries: an alias your CLI tool calls,
@@ -192,9 +203,15 @@ npm run build # compile to dist/
192
203
  - **Penghemat token** — RTK memampatkan blok `tool_result` besar; caveman
193
204
  meringkas prosa output; ponytail mendorong kode minimal; headroom memampatkan
194
205
  konteks lewat `/v1/compress` eksternal. Semua bisa di-toggle per-endpoint.
195
- - **Kuota + biaya** — budget token per-provider dengan reset berjadwal, hitung
196
- mundur reset, dan pelacakan pemakaian/biaya berbasis SQLite.
197
- - **Dashboard** providers, combos, usage, kuota, CLI tools, server console
206
+ - **Bagikan dengan aman** — kasih satu gateway key ke teman/rekan, lalu atur
207
+ allowlist model, rate limit, **batas spend**, dan **kedaluwarsa** di satu tempat.
208
+ Budget tiap key reset di siklus rolling-nya sendiri (jadi terasa seperti jatah
209
+ bulanan yang isi ulang otomatis); key yang kedaluwarsa ditolak `403`.
210
+ - **Budget + biaya** — batas spend berskop (global/provider/model/key) atas jendela
211
+ rolling `5h`/`24h`/`7day`/`30day` yang di-anchor ke saat budget dibuat, dengan
212
+ hitung mundur reset dan pelacakan pemakaian/biaya berbasis SQLite. Halaman Budgets
213
+ memisah cap **Overall** dari tampilan **Keys** (pemakaian tiap key).
214
+ - **Dashboard** — providers, combos, usage, budgets, CLI tools, server console
198
215
  live, dan halaman settings dengan editor harga per-model.
199
216
 
200
217
  ### Penghemat token
@@ -293,6 +310,10 @@ npm run build # compile ke dist/
293
310
 
294
311
  ---
295
312
 
313
+ ## Acknowledgements
314
+
315
+ Inspired by [9router](https://github.com/decolua/9router) — its feature set and dashboard shaped much of this project's direction. / Terinspirasi oleh [9router](https://github.com/decolua/9router).
316
+
296
317
  ## License
297
318
 
298
319
  [MIT](./LICENSE) © xk1ko
@@ -23,7 +23,6 @@ providers: []
23
23
  # format: anthropic
24
24
  # base_url: https://api.anthropic.com/v1
25
25
  # api_keys: [sk-ant-xxx]
26
- # quota: { window: weekly, reset_at: monday, timezone: Asia/Jakarta }
27
26
  #
28
27
  # - id: openai
29
28
  # format: openai
@@ -1,5 +1,5 @@
1
- import { QuotaView } from "@/components/QuotaView";
1
+ import { BudgetTracker } from "@/components/BudgetTracker";
2
2
 
3
3
  export default function QuotaPage() {
4
- return <QuotaView />;
4
+ return <BudgetTracker />;
5
5
  }
@@ -7,8 +7,8 @@ import { Icon } from "@/components/Icon";
7
7
  import { ModelPicker, type ModelGroup } from "@/components/ModelPicker";
8
8
  import type { BudgetStatus, ModelsPayload } from "@/lib/gateway";
9
9
 
10
- const WINDOWS = ["5h", "daily", "weekly", "monthly"] as const;
11
- type ScopeType = "global" | "provider" | "model" | "key";
10
+ const WINDOWS = ["5h", "24h", "7day", "30day"] as const;
11
+ type ScopeType = "global" | "provider" | "model";
12
12
 
13
13
  /** Segment-pill button style — selected = accent, matches the Unit toggle. */
14
14
  const pill = (active: boolean): string =>
@@ -34,7 +34,6 @@ const SCOPES: { id: ScopeType; icon: string; label: string; hint: string }[] = [
34
34
  { id: "global", icon: "public", label: "Global", hint: "Cap total spend across the whole gateway." },
35
35
  { id: "provider", icon: "dns", label: "Per provider", hint: "Cap one provider's spend." },
36
36
  { id: "model", icon: "neurology", label: "Per model", hint: "Cap one upstream model's spend." },
37
- { id: "key", icon: "key", label: "Per API key", hint: "Cap one gateway key's spend." },
38
37
  ];
39
38
 
40
39
  /**
@@ -53,14 +52,17 @@ export function BudgetForm({
53
52
  onCancel: () => void;
54
53
  }) {
55
54
  const editing = initial !== null;
56
- const [scopeType, setScopeType] = useState<ScopeType | null>(initial ? initial.scope.type : null);
57
- const [scopeId, setScopeId] = useState(initial && initial.scope.type !== "global" ? initial.scope.id : "");
55
+ const [scopeType, setScopeType] = useState<ScopeType | null>(
56
+ initial && initial.scope.type !== "key" ? initial.scope.type : null,
57
+ );
58
+ const [scopeId, setScopeId] = useState(
59
+ initial && initial.scope.type !== "global" && initial.scope.type !== "key" ? initial.scope.id : "",
60
+ );
58
61
  const [catalog, setCatalog] = useState<ModelsPayload | null>(null);
59
- const [keys, setKeys] = useState<{ fingerprint: string; name: string; masked: string }[]>([]);
60
62
  const [pickerOpen, setPickerOpen] = useState(false);
61
63
  const [unit, setUnit] = useState<"usd" | "tokens">(initial?.unit ?? "usd");
62
64
  const [limit, setLimit] = useState(String(initial?.limit ?? ""));
63
- const [window, setWindow] = useState<(typeof WINDOWS)[number]>(initial?.window ?? "monthly");
65
+ const [window, setWindow] = useState<(typeof WINDOWS)[number]>(initial?.window ?? "30day");
64
66
  const [alertAt, setAlertAt] = useState(initial ? String(Math.round(initial.alert_at * 100)) : "80");
65
67
  const [note, setNote] = useState(initial?.note ?? "");
66
68
  const [error, setError] = useState("");
@@ -68,7 +70,6 @@ export function BudgetForm({
68
70
 
69
71
  useEffect(() => {
70
72
  void adminApi.models().then((r) => { if (r.ok && r.data) setCatalog(r.data); });
71
- void adminApi.keys().then((r) => { if (r.ok && r.data) setKeys(r.data); });
72
73
  }, []);
73
74
 
74
75
  const providerGroups: ModelGroup[] = catalog?.providers.length
@@ -78,10 +79,7 @@ export function BudgetForm({
78
79
  const modelGroups: ModelGroup[] = (catalog?.providers ?? [])
79
80
  .filter((p) => p.models.length > 0)
80
81
  .map((p) => ({ label: p.id, items: p.models.map((m) => ({ value: m.id, label: m.id })) }));
81
- const keyGroups: ModelGroup[] = keys.length
82
- ? [{ label: "API keys", items: keys.map((k) => ({ value: k.fingerprint, label: k.name })) }]
83
- : [];
84
- const scopeIdLabel = scopeType === "key" ? (keys.find((k) => k.fingerprint === scopeId)?.name ?? scopeId) : scopeId;
82
+ const scopeIdLabel = scopeId;
85
83
 
86
84
  async function save() {
87
85
  const limitNum = Number(limit);
@@ -117,7 +115,7 @@ export function BudgetForm({
117
115
  <Icon name="close" size={18} />
118
116
  </button>
119
117
  </div>
120
- <div className="mt-4 grid gap-3 sm:grid-cols-4">
118
+ <div className="mt-4 grid gap-3 sm:grid-cols-3">
121
119
  {SCOPES.map((s) => (
122
120
  <button
123
121
  key={s.id}
@@ -243,10 +241,10 @@ export function BudgetForm({
243
241
 
244
242
  {pickerOpen && scopeType !== "global" && (
245
243
  <ModelPicker
246
- title={scopeType === "provider" ? "Select a provider" : scopeType === "model" ? "Select a model" : "Select an API key"}
247
- note={`Click ${scopeType === "key" ? "a key" : `a ${scopeType}`} to scope this budget to it.`}
248
- searchPlaceholder={scopeType === "provider" ? "Search providers…" : scopeType === "model" ? "Search models…" : "Search keys…"}
249
- groups={scopeType === "provider" ? providerGroups : scopeType === "model" ? modelGroups : keyGroups}
244
+ title={scopeType === "provider" ? "Select a provider" : "Select a model"}
245
+ note={`Click a ${scopeType} to scope this budget to it.`}
246
+ searchPlaceholder={scopeType === "provider" ? "Search providers…" : "Search models…"}
247
+ groups={scopeType === "provider" ? providerGroups : modelGroups}
250
248
  selected={scopeId ? [scopeId] : []}
251
249
  onToggle={(v) => { setScopeId(v); setPickerOpen(false); }}
252
250
  onClose={() => setPickerOpen(false)}
@@ -9,44 +9,47 @@ import { fmt, Empty } from "@/components/ui";
9
9
  import { BudgetForm } from "@/components/BudgetForm";
10
10
  import { Button } from "@/components/Button";
11
11
  import { Icon } from "@/components/Icon";
12
- import type { QuotaSnapshot, BudgetStatus } from "@/lib/gateway";
12
+ import type { BudgetStatus, KeyUsageRow } from "@/lib/gateway";
13
13
 
14
14
  /**
15
15
  * Budget Tracker — scoped spend budgets (global / per-provider / per-model /
16
- * per-key) with an Add / Edit / Remove flow, shown above the per-provider token
17
- * quota grid (the older hard token cap that drives each provider card's reset
18
- * countdown): consumption vs limit, a fill bar, and a live reset countdown.
16
+ * per-key) with an Add / Edit / Remove flow: consumption vs limit, a fill bar,
17
+ * and a live reset countdown.
19
18
  */
20
- export function QuotaView() {
21
- const [quota, setQuota] = useState<QuotaSnapshot[] | null>(null);
19
+ export function BudgetTracker() {
22
20
  const [budgets, setBudgets] = useState<BudgetStatus[]>([]);
21
+ const [keys, setKeys] = useState<KeyUsageRow[]>([]);
22
+ const [loaded, setLoaded] = useState(false);
23
23
  const [form, setForm] = useState<{ open: boolean; initial: BudgetStatus | null }>({ open: false, initial: null });
24
24
  const [error, setError] = useState("");
25
25
 
26
- const refresh = () =>
27
- void adminApi.quota().then((r) => {
26
+ const refresh = () => {
27
+ void adminApi.budgets().then((r) => {
28
28
  if (!r.ok) setError(r.error ?? "could not reach the gateway");
29
- else { setQuota(r.data?.quota ?? []); setBudgets(r.data?.budgets ?? []); }
29
+ else { setBudgets(r.data?.budgets ?? []); }
30
+ setLoaded(true);
30
31
  });
32
+ void adminApi.keysUsage().then((r) => { if (r.ok) setKeys(r.data?.keys ?? []); });
33
+ };
31
34
 
32
35
  useEffect(() => { refresh(); }, []);
33
36
 
34
37
  if (error) return <Empty>{error}</Empty>;
35
- if (!quota) return <Empty>Loading…</Empty>;
38
+ if (!loaded) return <Empty>Loading...</Empty>;
36
39
 
37
40
  return (
38
41
  <div>
39
42
  <div className="mb-6">
40
- <h1 className="text-[22px] font-semibold tracking-tight text-text">Budget Tracker</h1>
43
+ <h1 className="text-[22px] font-semibold tracking-tight text-text">Budgets</h1>
41
44
  <p className="mt-1 text-[13px] text-text-muted">
42
- Spend caps (USD or tokens) and per-provider token quotas, with live reset countdowns.
45
+ Spend caps (USD or tokens) with live reset countdowns.
43
46
  </p>
44
47
  </div>
45
48
 
46
- {/* ── Budgets ── */}
49
+ {/* -- Budgets -- */}
47
50
  <div className="mb-6">
48
51
  <div className="mb-3 flex items-center justify-between">
49
- <h2 className="text-[15px] font-semibold text-text">Budgets</h2>
52
+ <h2 className="text-[15px] font-semibold text-text">Overall</h2>
50
53
  {!form.open && (
51
54
  <Button onClick={() => setForm({ open: true, initial: null })}>
52
55
  <Icon name="add" size={16} /> Add budget
@@ -63,11 +66,11 @@ export function QuotaView() {
63
66
  />
64
67
  )}
65
68
 
66
- {budgets.length === 0 ? (
69
+ {budgets.filter((b) => b.scope.type !== "key").length === 0 ? (
67
70
  !form.open && <Empty>No budgets yet. Add one to cap spend globally, per provider, or per model.</Empty>
68
71
  ) : (
69
72
  <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
70
- {budgets.map((b) => (
73
+ {budgets.filter((b) => b.scope.type !== "key").map((b) => (
71
74
  <RichCard
72
75
  key={b.key}
73
76
  header={
@@ -117,50 +120,62 @@ export function QuotaView() {
117
120
  )}
118
121
  </div>
119
122
 
120
- {/* ── Per-provider quota grid — only shown once a provider actually has a
121
- `quota:` cap configured; superseded by per-provider token budgets, so we
122
- don't advertise it with an empty state. ── */}
123
- {quota.length > 0 && (
124
- <>
125
- <div className="mb-3 flex items-baseline justify-between gap-3">
126
- <h2 className="text-[15px] font-semibold text-text">Provider quotas</h2>
127
- <span className="text-[12px] text-text-subtle">hard token cap per provider, per window</span>
128
- </div>
123
+ {/* -- Keys -- */}
124
+ <div>
125
+ <h2 className="mb-3 text-[15px] font-semibold text-text">Keys</h2>
126
+ {keys.length === 0 ? (
127
+ <Empty>No gateway keys yet. Add one on the Endpoint page.</Empty>
128
+ ) : (
129
129
  <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
130
- {quota.map((q) => (
131
- <RichCard
132
- key={q.provider}
133
- header={
134
- <>
135
- <CardTitle title={q.provider} sub={`window · ${q.window}`} />
136
- <Badge tone={q.exhausted ? "down" : q.alert ? "warn" : "live"}>
137
- {q.exhausted ? "exhausted" : q.alert ? "alert" : "active"}
138
- </Badge>
139
- </>
140
- }
141
- >
142
- <div className="space-y-2.5">
143
- {q.limit_tokens ? (
144
- <div className="h-1.5 overflow-hidden rounded-full bg-surface-2">
145
- <div
146
- className={`h-full rounded-full transition-all ${q.exhausted ? "bg-danger" : q.alert ? "bg-warning" : "bg-accent"}`}
147
- style={{ width: `${Math.round((q.pct ?? 0) * 100)}%` }}
148
- />
130
+ {keys.map((k) => (
131
+ <RichCard
132
+ key={k.fingerprint}
133
+ header={
134
+ <>
135
+ <CardTitle title={k.name} sub={k.budget ? `key · ${k.budget.window}` : "key · no limit"} />
136
+ {k.expires && Date.now() > k.expires ? (
137
+ <Badge tone="down">expired</Badge>
138
+ ) : k.budget?.exhausted ? (
139
+ <Badge tone="down">exhausted</Badge>
140
+ ) : (
141
+ <Badge tone="live">active</Badge>
142
+ )}
143
+ </>
144
+ }
145
+ >
146
+ <div className="space-y-2.5">
147
+ {k.budget ? (
148
+ <>
149
+ <div className="h-1.5 overflow-hidden rounded-full bg-surface-2">
150
+ <div
151
+ className={`h-full rounded-full transition-all ${k.budget.exhausted ? "bg-danger" : k.budget.alert ? "bg-warning" : "bg-accent"}`}
152
+ style={{ width: `${Math.min(100, Math.round(k.budget.pct * 100))}%` }}
153
+ />
154
+ </div>
155
+ <div className="flex items-center justify-between text-[12px]">
156
+ <span className="tnum text-text-muted">
157
+ {k.budget.unit === "usd"
158
+ ? `$${k.budget.spent.toFixed(2)} / $${k.budget.limit.toFixed(2)}`
159
+ : `${fmt.compact(k.budget.spent)} / ${fmt.compact(k.budget.limit)} tokens`}
160
+ </span>
161
+ <CooldownTimer ms={k.budget.reset_in_ms} tone="muted" icon="restart_alt" keepZero />
162
+ </div>
163
+ </>
164
+ ) : (
165
+ <div className="flex items-center justify-between text-[12px]">
166
+ <span className="tnum text-text-muted">${k.spent.toFixed(2)} spent</span>
167
+ <span className="text-text-subtle">no limit</span>
168
+ </div>
169
+ )}
170
+ <div className="text-[11px] text-text-subtle">
171
+ {k.expires ? `expires ${fmt.date(k.expires)}` : "no expiry"}
149
172
  </div>
150
- ) : null}
151
- <div className="flex items-center justify-between text-[12px]">
152
- <span className="tnum text-text-muted">
153
- {fmt.compact(q.consumed)}
154
- {q.limit_tokens ? ` / ${fmt.compact(q.limit_tokens)}` : ""} tokens
155
- </span>
156
- <CooldownTimer ms={q.reset_in_ms} tone="muted" icon="restart_alt" keepZero />
157
173
  </div>
158
- </div>
159
- </RichCard>
160
- ))}
174
+ </RichCard>
175
+ ))}
161
176
  </div>
162
- </>
163
- )}
177
+ )}
178
+ </div>
164
179
  </div>
165
180
  );
166
181
  }
@@ -6,7 +6,7 @@ import { fmt } from "./ui";
6
6
 
7
7
  /**
8
8
  * Live countdown from a gateway-snapshot remaining-ms. Counts down locally each
9
- * second from render. Used for both key cooldowns (danger tone) and quota window
9
+ * second from render. Used for both key cooldowns (danger tone) and budget window
10
10
  * resets (muted tone). Renders nothing once it hits zero unless `keepZero`.
11
11
  */
12
12
  export function CooldownTimer({