aigetwey 1.2.0 → 1.3.2

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 (72) hide show
  1. package/CHANGELOG.md +41 -1
  2. package/README.md +30 -7
  3. package/assets/screenshot.png +0 -0
  4. package/config.example.yaml +0 -1
  5. package/dashboard/src/app/(console)/quota/page.tsx +2 -2
  6. package/dashboard/src/app/layout.tsx +3 -2
  7. package/dashboard/src/components/BudgetForm.tsx +15 -17
  8. package/dashboard/src/components/{QuotaView.tsx → BudgetTracker.tsx} +71 -56
  9. package/dashboard/src/components/CooldownTimer.tsx +1 -1
  10. package/dashboard/src/components/EndpointView.tsx +255 -47
  11. package/dashboard/src/components/LogTable.tsx +36 -26
  12. package/dashboard/src/components/ProviderManager.tsx +3 -28
  13. package/dashboard/src/components/Rail.tsx +1 -1
  14. package/dashboard/src/components/RoutingView.tsx +6 -2
  15. package/dashboard/src/components/TopBar.tsx +1 -1
  16. package/dashboard/src/components/ui.tsx +6 -1
  17. package/dashboard/src/lib/client.ts +6 -5
  18. package/dashboard/src/lib/gateway.ts +24 -16
  19. package/dist/adapters/gemini.js +1 -0
  20. package/dist/adapters/gemini.js.map +1 -1
  21. package/dist/adapters/openai.js +13 -1
  22. package/dist/adapters/openai.js.map +1 -1
  23. package/dist/config.js +86 -23
  24. package/dist/config.js.map +1 -1
  25. package/dist/core/budget.js +1 -1
  26. package/dist/core/budget.js.map +1 -1
  27. package/dist/core/fallback.js +0 -6
  28. package/dist/core/fallback.js.map +1 -1
  29. package/dist/core/handler.js +13 -7
  30. package/dist/core/handler.js.map +1 -1
  31. package/dist/core/keysUsage.js +15 -0
  32. package/dist/core/keysUsage.js.map +1 -0
  33. package/dist/core/ratelimit.js +15 -0
  34. package/dist/core/ratelimit.js.map +1 -0
  35. package/dist/core/state.js +5 -13
  36. package/dist/core/state.js.map +1 -1
  37. package/dist/core/window.js +35 -0
  38. package/dist/core/window.js.map +1 -0
  39. package/dist/db.js +34 -29
  40. package/dist/db.js.map +1 -1
  41. package/dist/routes/admin.js +55 -10
  42. package/dist/routes/admin.js.map +1 -1
  43. package/dist/routes/v1.js +14 -1
  44. package/dist/routes/v1.js.map +1 -1
  45. package/dist/server.js +1 -7
  46. package/dist/server.js.map +1 -1
  47. package/dist/stream/anthropic-stream.js +7 -0
  48. package/dist/stream/anthropic-stream.js.map +1 -1
  49. package/dist/stream/gemini-stream.js +2 -1
  50. package/dist/stream/gemini-stream.js.map +1 -1
  51. package/dist/stream/openai-stream.js +10 -0
  52. package/dist/stream/openai-stream.js.map +1 -1
  53. package/package.json +1 -1
  54. package/src/adapters/gemini.ts +2 -0
  55. package/src/adapters/openai.ts +18 -1
  56. package/src/config.ts +89 -23
  57. package/src/core/budget.ts +1 -1
  58. package/src/core/fallback.ts +0 -9
  59. package/src/core/handler.ts +16 -9
  60. package/src/core/keysUsage.ts +49 -0
  61. package/src/core/ratelimit.ts +25 -0
  62. package/src/core/state.ts +4 -14
  63. package/src/core/window.ts +45 -0
  64. package/src/db.ts +35 -31
  65. package/src/routes/admin.ts +61 -9
  66. package/src/routes/v1.ts +18 -1
  67. package/src/server.ts +1 -8
  68. package/src/stream/anthropic-stream.ts +10 -1
  69. package/src/stream/chunk.ts +2 -0
  70. package/src/stream/gemini-stream.ts +3 -2
  71. package/src/stream/openai-stream.ts +12 -1
  72. package/src/core/quota.ts +0 -253
package/CHANGELOG.md CHANGED
@@ -5,7 +5,47 @@ 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.2] — 2026-06-26
9
+
10
+ ### Fixed
11
+ - **Cache & reasoning token accounting** — OpenAI cached tokens
12
+ (`prompt_tokens_details.cached_tokens`) on both the streaming and non-streaming
13
+ paths, plus reasoning tokens on the non-streaming path (OpenAI
14
+ `completion_tokens_details.reasoning_tokens`, Gemini `thoughtsTokenCount`), were
15
+ dropped from the usage log. They are now flattened into the canonical usage so
16
+ cache and reasoning are recorded consistently across streaming and non-streaming
17
+ for every provider. The `tokens_in`/`tokens_out` reading is unchanged.
18
+
19
+ ## [1.3.1] — 2026-06-26
20
+
21
+ ### Added
22
+ - **Granular cost calculation** — cost now uses separate per-1M rates for
23
+ non-cached input, cache-read, output, and reasoning tokens instead of a flat
24
+ input/output split. Models with extended thinking (Claude Sonnet 4, o1, Gemini
25
+ thinking) are now tracked accurately.
26
+ - **Reasoning token extraction** — extracts `reasoning_tokens` from Anthropic
27
+ (`thinking_tokens`), OpenAI (`completion_tokens_details.reasoning_tokens`), and
28
+ Gemini (`thoughtsTokenCount`); stored in the usage log for future display.
29
+
30
+ ## [1.3.0] — 2026-06-26
31
+
32
+ ### Added
33
+ - **Per-key expiry** — set an expiry date on a gateway key; `/v1/*` calls with an
34
+ expired key return `403 key expired`. Editable on the Endpoint page next to the
35
+ per-key model allowlist and rate limit. Keys with no expiry never expire.
36
+
37
+ ### Changed
38
+ - **Budgets page → Overall + Keys** — the Budgets page now separates Overall caps
39
+ (global/provider/model) from a Keys section that lists every gateway key with its
40
+ spend; capped keys show a bar, reset countdown, and expiry, uncapped keys show
41
+ spend + "no limit". A key's spend cap and expiry are now set in one place — the
42
+ key settings on the Endpoint page — alongside its model allowlist and rate limit.
43
+ - **Recurring budgets** — each budget window (`5h`/`24h`/`7day`/`30day`) now
44
+ resets on a cycle anchored to when the budget was created, not a shared epoch
45
+ grid. A per-key budget shared with another device becomes a self-resetting
46
+ allowance; the reset countdown reflects that key's own cycle. Budgets in an
47
+ existing `config.yaml` (no stored anchor) keep the previous epoch-grid reset
48
+ until next edited.
9
49
 
10
50
  ## [1.2.0] — 2026-06-25
11
51
 
package/README.md CHANGED
@@ -35,9 +35,16 @@ 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. Cost uses separate
45
+ rates for input, cached-read, output, and reasoning tokens. The Budgets page
46
+ splits **Overall** caps from a **Keys** view that shows every key's spend.
47
+ - **Dashboard** — providers, combos, usage, budgets, CLI tools, a live server
41
48
  console, and a settings page with a per-model pricing editor.
42
49
 
43
50
  ### Token savers
@@ -117,7 +124,6 @@ providers:
117
124
  format: anthropic
118
125
  base_url: https://api.anthropic.com/v1
119
126
  api_keys: [sk-ant-xxx]
120
- quota: { window: weekly, reset_at: monday, timezone: Asia/Jakarta }
121
127
  - id: opencode-free
122
128
  format: openai
123
129
  base_url: https://opencode.ai/zen/v1
@@ -130,6 +136,12 @@ models: # routing: client alias -> prioritized provider chai
130
136
  model: [claude-sonnet-4-6, claude-sonnet-4-5]
131
137
  price_in: 3 # USD per 1M tokens (for cost tracking)
132
138
  price_out: 15
139
+
140
+ budgets: # spend caps; window = rolling 5h | 24h | 7day | 30day
141
+ - scope: { type: global }
142
+ unit: usd # usd (cost) or tokens
143
+ limit: 50
144
+ window: 30day # rolling 30-day lookback (epoch-aligned bucket)
133
145
  ```
134
146
 
135
147
  A **combo** is one of these `models` entries: an alias your CLI tool calls,
@@ -192,9 +204,16 @@ npm run build # compile to dist/
192
204
  - **Penghemat token** — RTK memampatkan blok `tool_result` besar; caveman
193
205
  meringkas prosa output; ponytail mendorong kode minimal; headroom memampatkan
194
206
  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
207
+ - **Bagikan dengan aman** — kasih satu gateway key ke teman/rekan, lalu atur
208
+ allowlist model, rate limit, **batas spend**, dan **kedaluwarsa** di satu tempat.
209
+ Budget tiap key reset di siklus rolling-nya sendiri (jadi terasa seperti jatah
210
+ bulanan yang isi ulang otomatis); key yang kedaluwarsa ditolak `403`.
211
+ - **Budget + biaya** — batas spend berskop (global/provider/model/key) atas jendela
212
+ rolling `5h`/`24h`/`7day`/`30day` yang di-anchor ke saat budget dibuat, dengan
213
+ hitung mundur reset dan pelacakan pemakaian/biaya berbasis SQLite. Biaya dihitung
214
+ per jenis token (input, cached-read, output, reasoning). Halaman Budgets
215
+ memisah cap **Overall** dari tampilan **Keys** (pemakaian tiap key).
216
+ - **Dashboard** — providers, combos, usage, budgets, CLI tools, server console
198
217
  live, dan halaman settings dengan editor harga per-model.
199
218
 
200
219
  ### Penghemat token
@@ -293,6 +312,10 @@ npm run build # compile ke dist/
293
312
 
294
313
  ---
295
314
 
315
+ ## Acknowledgements
316
+
317
+ 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).
318
+
296
319
  ## License
297
320
 
298
321
  [MIT](./LICENSE) © xk1ko
Binary file
@@ -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
  }
@@ -1,4 +1,5 @@
1
1
  import type { Metadata } from "next";
2
+ import Script from "next/script";
2
3
  import { Inter, JetBrains_Mono } from "next/font/google";
3
4
  import { ThemeProvider } from "@/components/ThemeProvider";
4
5
  import { ToastProvider } from "@/components/ToastProvider";
@@ -14,13 +15,13 @@ export const metadata: Metadata = {
14
15
 
15
16
  export default function RootLayout({ children }: { children: React.ReactNode }) {
16
17
  return (
17
- <html lang="en" className={`dark ${inter.variable} ${jetbrainsMono.variable}`}>
18
+ <html lang="en" className={`dark ${inter.variable} ${jetbrainsMono.variable}`} suppressHydrationWarning>
18
19
  <head>
19
20
  <link
20
21
  rel="stylesheet"
21
22
  href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0&display=block"
22
23
  />
23
- <script dangerouslySetInnerHTML={{ __html: `try{var t=localStorage.getItem("theme");if(t==="light")document.documentElement.className=document.documentElement.className.replace("dark","light")}catch(e){}` }} />
24
+ <Script id="theme-init" strategy="beforeInteractive">{`try{var t=localStorage.getItem("theme");if(t==="light")document.documentElement.className=document.documentElement.className.replace("dark","light")}catch(e){}`}</Script>
24
25
  </head>
25
26
  <body><ThemeProvider><ToastProvider>{children}</ToastProvider></ThemeProvider></body>
26
27
  </html>
@@ -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({