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.
- package/CHANGELOG.md +19 -1
- package/README.md +28 -7
- package/config.example.yaml +0 -1
- package/dashboard/src/app/(console)/quota/page.tsx +2 -2
- package/dashboard/src/components/BudgetForm.tsx +15 -17
- package/dashboard/src/components/{QuotaView.tsx → BudgetTracker.tsx} +71 -56
- package/dashboard/src/components/CooldownTimer.tsx +1 -1
- package/dashboard/src/components/EndpointView.tsx +255 -47
- package/dashboard/src/components/LogTable.tsx +32 -25
- package/dashboard/src/components/ProviderManager.tsx +3 -28
- package/dashboard/src/components/Rail.tsx +1 -1
- package/dashboard/src/components/RoutingView.tsx +6 -2
- package/dashboard/src/components/TopBar.tsx +1 -1
- package/dashboard/src/components/ui.tsx +6 -1
- package/dashboard/src/lib/client.ts +6 -5
- package/dashboard/src/lib/gateway.ts +23 -16
- package/dist/config.js +86 -23
- package/dist/config.js.map +1 -1
- package/dist/core/budget.js +1 -1
- package/dist/core/budget.js.map +1 -1
- package/dist/core/fallback.js +0 -6
- package/dist/core/fallback.js.map +1 -1
- package/dist/core/handler.js +6 -5
- package/dist/core/handler.js.map +1 -1
- package/dist/core/keysUsage.js +15 -0
- package/dist/core/keysUsage.js.map +1 -0
- package/dist/core/ratelimit.js +15 -0
- package/dist/core/ratelimit.js.map +1 -0
- package/dist/core/state.js +5 -13
- package/dist/core/state.js.map +1 -1
- package/dist/core/window.js +35 -0
- package/dist/core/window.js.map +1 -0
- package/dist/db.js +0 -20
- package/dist/db.js.map +1 -1
- package/dist/routes/admin.js +55 -10
- package/dist/routes/admin.js.map +1 -1
- package/dist/routes/v1.js +14 -1
- package/dist/routes/v1.js.map +1 -1
- package/dist/server.js +1 -7
- package/dist/server.js.map +1 -1
- package/dist/stream/openai-stream.js +3 -0
- package/dist/stream/openai-stream.js.map +1 -1
- package/package.json +1 -1
- package/src/config.ts +89 -23
- package/src/core/budget.ts +1 -1
- package/src/core/fallback.ts +0 -9
- package/src/core/handler.ts +9 -7
- package/src/core/keysUsage.ts +49 -0
- package/src/core/ratelimit.ts +25 -0
- package/src/core/state.ts +4 -14
- package/src/core/window.ts +45 -0
- package/src/db.ts +0 -23
- package/src/routes/admin.ts +61 -9
- package/src/routes/v1.ts +18 -1
- package/src/server.ts +1 -8
- package/src/stream/openai-stream.ts +3 -1
- 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
|
-
## [
|
|
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
|
-
- **
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
- **
|
|
196
|
-
|
|
197
|
-
|
|
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
|
package/config.example.yaml
CHANGED
|
@@ -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", "
|
|
11
|
-
type ScopeType = "global" | "provider" | "model"
|
|
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>(
|
|
57
|
-
|
|
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 ?? "
|
|
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
|
|
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-
|
|
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" :
|
|
247
|
-
note={`Click
|
|
248
|
-
searchPlaceholder={scopeType === "provider" ? "Search providers…" :
|
|
249
|
-
groups={scopeType === "provider" ? providerGroups :
|
|
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 {
|
|
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
|
|
17
|
-
*
|
|
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
|
|
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.
|
|
26
|
+
const refresh = () => {
|
|
27
|
+
void adminApi.budgets().then((r) => {
|
|
28
28
|
if (!r.ok) setError(r.error ?? "could not reach the gateway");
|
|
29
|
-
else {
|
|
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 (!
|
|
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">
|
|
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)
|
|
45
|
+
Spend caps (USD or tokens) with live reset countdowns.
|
|
43
46
|
</p>
|
|
44
47
|
</div>
|
|
45
48
|
|
|
46
|
-
{/*
|
|
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">
|
|
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
|
-
{/*
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
</
|
|
159
|
-
|
|
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
|
|
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({
|