aigetwey 1.1.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 (76) hide show
  1. package/CHANGELOG.md +65 -4
  2. package/README.md +32 -11
  3. package/config.example.yaml +6 -6
  4. package/dashboard/next.config.ts +6 -0
  5. package/dashboard/src/app/(console)/quota/page.tsx +2 -2
  6. package/dashboard/src/app/globals.css +47 -0
  7. package/dashboard/src/components/BudgetForm.tsx +256 -0
  8. package/dashboard/src/components/BudgetTracker.tsx +181 -0
  9. package/dashboard/src/components/CooldownTimer.tsx +1 -1
  10. package/dashboard/src/components/EndpointView.tsx +285 -47
  11. package/dashboard/src/components/LogTable.tsx +97 -25
  12. package/dashboard/src/components/ModelPicker.tsx +15 -7
  13. package/dashboard/src/components/ProviderDetail.tsx +27 -29
  14. package/dashboard/src/components/ProviderManager.tsx +39 -31
  15. package/dashboard/src/components/Rail.tsx +1 -1
  16. package/dashboard/src/components/RoutingView.tsx +8 -4
  17. package/dashboard/src/components/ToolDetail.tsx +5 -3
  18. package/dashboard/src/components/TopBar.tsx +1 -1
  19. package/dashboard/src/components/UsageView.tsx +25 -6
  20. package/dashboard/src/components/ui.tsx +6 -1
  21. package/dashboard/src/lib/cliTools.ts +0 -43
  22. package/dashboard/src/lib/client.ts +14 -7
  23. package/dashboard/src/lib/gateway.ts +33 -15
  24. package/dashboard/src/{middleware.ts → proxy.ts} +8 -6
  25. package/dist/cli.js +43 -8
  26. package/dist/cli.js.map +1 -1
  27. package/dist/config.js +136 -27
  28. package/dist/config.js.map +1 -1
  29. package/dist/core/budget.js +62 -17
  30. package/dist/core/budget.js.map +1 -1
  31. package/dist/core/fallback.js +0 -6
  32. package/dist/core/fallback.js.map +1 -1
  33. package/dist/core/handler.js +24 -9
  34. package/dist/core/handler.js.map +1 -1
  35. package/dist/core/keysUsage.js +15 -0
  36. package/dist/core/keysUsage.js.map +1 -0
  37. package/dist/core/ratelimit.js +15 -0
  38. package/dist/core/ratelimit.js.map +1 -0
  39. package/dist/core/state.js +15 -15
  40. package/dist/core/state.js.map +1 -1
  41. package/dist/core/window.js +35 -0
  42. package/dist/core/window.js.map +1 -0
  43. package/dist/db.js +39 -25
  44. package/dist/db.js.map +1 -1
  45. package/dist/middleware/auth.js +15 -8
  46. package/dist/middleware/auth.js.map +1 -1
  47. package/dist/routes/admin.js +80 -17
  48. package/dist/routes/admin.js.map +1 -1
  49. package/dist/routes/v1.js +28 -11
  50. package/dist/routes/v1.js.map +1 -1
  51. package/dist/server.js +5 -7
  52. package/dist/server.js.map +1 -1
  53. package/dist/stream/openai-stream.js +3 -0
  54. package/dist/stream/openai-stream.js.map +1 -1
  55. package/dist/upstream/client.js +9 -0
  56. package/dist/upstream/client.js.map +1 -1
  57. package/package.json +3 -4
  58. package/src/cli.ts +44 -8
  59. package/src/config.ts +142 -29
  60. package/src/core/budget.ts +78 -25
  61. package/src/core/fallback.ts +0 -9
  62. package/src/core/handler.ts +31 -12
  63. package/src/core/keysUsage.ts +49 -0
  64. package/src/core/ratelimit.ts +25 -0
  65. package/src/core/state.ts +21 -16
  66. package/src/core/window.ts +45 -0
  67. package/src/db.ts +50 -28
  68. package/src/middleware/auth.ts +18 -8
  69. package/src/routes/admin.ts +93 -20
  70. package/src/routes/v1.ts +32 -11
  71. package/src/server.ts +5 -8
  72. package/src/stream/openai-stream.ts +3 -1
  73. package/src/upstream/client.ts +9 -0
  74. package/dashboard/src/components/BudgetEditor.tsx +0 -97
  75. package/dashboard/src/components/QuotaView.tsx +0 -152
  76. package/src/core/quota.ts +0 -253
package/CHANGELOG.md CHANGED
@@ -5,7 +5,68 @@ 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.
27
+
28
+ ## [1.2.0] — 2026-06-25
29
+
30
+ ### Added
31
+ - **Scoped budgets** — budgets are now multi-scope: cap spend **globally**, per
32
+ **provider**, per **model**, or per **API key**. Each carries its own unit
33
+ (USD or tokens), window, soft alert, and a hard `402 budget exceeded` stop;
34
+ spend is still derived from the usage table (restart-safe). The Budget Tracker
35
+ page shows them as a card grid with an inline Add/Edit panel and a searchable
36
+ scope picker. Configure via `budgets:` or `PUT /admin/budgets`. Replaces the
37
+ single gateway-wide budget.
38
+ - **Per-API-key budgets** — cap one gateway key's spend. The matched caller key
39
+ fingerprint is recorded on each usage row; `GET /admin/keys` lists keys for
40
+ the picker.
41
+ - **Budget note** — an optional label on a budget to say what it's for.
42
+ - **Headroom re-check** — a "Check" button to re-probe the Headroom proxy.
43
+ - **Usage timeframes** — the Usage window adds **Today** (since local midnight)
44
+ and **60D** alongside 24h / 7D / 30D.
45
+ - **Request log filters** — the request log is collapsible and gains Provider +
46
+ Start/End-date filters with a Clear button.
47
+
48
+ ### Changed
49
+ - **Budget Tracker** — the Quota page is renamed Budget Tracker; the budget
50
+ "Alert at" threshold is a slider with a typeable %, and the per-provider token
51
+ quota grid (superseded by per-provider budgets) only shows when one is set.
52
+ - **Providers** — enable/disable a provider directly from the list card; a
53
+ disabled provider fades, reads red, and its models drop out of the combo,
54
+ CLI-tool, and budget pickers.
55
+ - **CLI tools** — the setup list is trimmed to Claude Code + opencode.
56
+ - **Providers + OpenAI only** — the project is scoped to Anthropic- and
57
+ OpenAI-compatible providers; Gemini is no longer advertised.
58
+ - **Next 16** — adopt the `proxy` file convention (was `middleware`).
59
+
60
+ ### Fixed
61
+ - **Streaming usage** — openai-format streaming upstreams now report token
62
+ usage (`stream_options.include_usage`); previously every streamed call through
63
+ an openai-compatible provider logged 0 tokens in/out.
64
+ - **Session persistence** — the dashboard session secret is persisted to the
65
+ data dir, so a gateway restart no longer invalidates the cookie and forces a
66
+ re-login.
67
+ - Favicon (`icon.svg`) is served publicly past the auth gate.
68
+ - Editing a budget preserves its alert threshold.
69
+ - The launcher waits for the dashboard to be ready, not just the proxy port.
9
70
 
10
71
  ## [1.1.0] — 2026-06-24
11
72
 
@@ -30,15 +91,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
30
91
  ## [1.0.0] — 2026-06-24
31
92
 
32
93
  First public release. A personal AI gateway that routes, translates, and tracks
33
- requests across Anthropic / OpenAI / Gemini-compatible providers, with a built-in
94
+ requests across Anthropic and OpenAI-compatible providers, with a built-in
34
95
  dashboard.
35
96
 
36
97
  ### Added
37
98
 
38
99
  #### Gateway
39
100
  - **Multi-format translation** — one canonical (OpenAI Chat) request shape,
40
- translated to/from each provider's native wire format (`openai`, `anthropic`,
41
- `gemini`) on both ingress and egress.
101
+ translated to/from each provider's native wire format (`openai`, `anthropic`)
102
+ on both ingress and egress.
42
103
  - **Combos** — alias an ordered provider chain (`fallback` or `round-robin`)
43
104
  behind a single model name; the alias *is* the model you call.
44
105
  - **Key pool** — multiple keys per provider with health tracking, cooldown on
package/README.md CHANGED
@@ -28,16 +28,22 @@ See [CHANGELOG.md](./CHANGELOG.md) for release history.
28
28
  ### Highlights
29
29
 
30
30
  - **One endpoint, every format** — clients speak OpenAI (`/v1/chat/completions`)
31
- or Anthropic (`/v1/messages`); the gateway translates to/from OpenAI,
32
- Anthropic, or Gemini providers, streaming included.
31
+ or Anthropic (`/v1/messages`); the gateway translates to/from OpenAI- and
32
+ Anthropic-compatible providers, streaming included.
33
33
  - **Routing + fallback** — a client alias resolves to a prioritized provider
34
34
  chain; on 429/5xx/timeout it rotates keys and falls through to the next.
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,
@@ -184,17 +195,23 @@ npm run build # compile to dist/
184
195
  ### Sorotan
185
196
 
186
197
  - **Satu endpoint, semua format** — klien bicara OpenAI (`/v1/chat/completions`)
187
- atau Anthropic (`/v1/messages`); gateway menerjemahkan ke/dari provider OpenAI,
188
- Anthropic, atau Gemini, termasuk streaming.
198
+ atau Anthropic (`/v1/messages`); gateway menerjemahkan ke/dari provider yang
199
+ kompatibel OpenAI & Anthropic, termasuk streaming.
189
200
  - **Routing + fallback** — sebuah alias klien diarahkan ke rantai provider
190
201
  berprioritas; saat 429/5xx/timeout ia memutar key dan jatuh ke provider
191
202
  berikutnya.
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
@@ -38,11 +37,12 @@ providers: []
38
37
  # free: true
39
38
  # auto_models: true
40
39
  #
41
- # - id: vertex
42
- # format: gemini
43
- # base_url: https://us-central1-aiplatform.googleapis.com/v1
44
- # service_account: /path/to/sa.json
45
- # quota: { window: monthly, limit_tokens: 300000000 }
40
+ # - id: anthropic
41
+ # format: anthropic
42
+ # base_url: https://api.anthropic.com
43
+ # api_key: sk-ant-xxx
44
+ # models:
45
+ # - { id: claude-sonnet-4-6 }
46
46
 
47
47
  # Combos: client-facing alias -> ordered provider chain. Call the alias as the
48
48
  # model name from your CLI tool. strategy: fallback (try in order) | round-robin.
@@ -1,4 +1,6 @@
1
1
  import type { NextConfig } from "next";
2
+ import { fileURLToPath } from "node:url";
3
+ import { dirname } from "node:path";
2
4
 
3
5
  const nextConfig: NextConfig = {
4
6
  // dashboard talks to the gateway only server-side (route handlers proxy
@@ -7,6 +9,10 @@ const nextConfig: NextConfig = {
7
9
  // allow dev HMR/resource requests from loopback hosts so client hydration
8
10
  // works regardless of which host name opens the app (localhost vs 127.0.0.1).
9
11
  allowedDevOrigins: ["localhost", "127.0.0.1"],
12
+ // this dashboard is its own npm package (own lockfile) nested in the gateway
13
+ // repo (which also has one). Pin Turbopack's root so it stops warning about an
14
+ // ambiguous workspace root and picks the dashboard.
15
+ turbopack: { root: dirname(fileURLToPath(import.meta.url)) },
10
16
  };
11
17
 
12
18
  export default nextConfig;
@@ -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
  }
@@ -338,3 +338,50 @@ button:disabled {
338
338
  from { opacity: 0; transform: translateX(20px); }
339
339
  to { opacity: 1; transform: translateX(0); }
340
340
  }
341
+
342
+ /* Brand range slider — the consumed portion glows lime (filled via an inline
343
+ * gradient on the element, which the webkit track inherits; Firefox uses
344
+ * ::-moz-range-progress), with a lime thumb ringed to read on the dark surface. */
345
+ input[type="range"].range-accent {
346
+ -webkit-appearance: none;
347
+ appearance: none;
348
+ height: 6px;
349
+ border-radius: 9999px;
350
+ background: var(--color-surface-2);
351
+ outline: none;
352
+ }
353
+ input[type="range"].range-accent::-webkit-slider-thumb {
354
+ -webkit-appearance: none;
355
+ appearance: none;
356
+ width: 16px;
357
+ height: 16px;
358
+ border-radius: 9999px;
359
+ background: var(--color-accent);
360
+ border: 2px solid var(--color-surface);
361
+ box-shadow: 0 0 0 1px var(--color-accent), 0 0 8px color-mix(in srgb, var(--color-accent) 55%, transparent);
362
+ cursor: pointer;
363
+ transition: box-shadow 0.15s ease;
364
+ }
365
+ input[type="range"].range-accent:hover::-webkit-slider-thumb,
366
+ input[type="range"].range-accent:focus-visible::-webkit-slider-thumb {
367
+ box-shadow: 0 0 0 1px var(--color-accent), 0 0 12px color-mix(in srgb, var(--color-accent) 85%, transparent);
368
+ }
369
+ input[type="range"].range-accent::-moz-range-track {
370
+ height: 6px;
371
+ border-radius: 9999px;
372
+ background: var(--color-surface-2);
373
+ }
374
+ input[type="range"].range-accent::-moz-range-progress {
375
+ height: 6px;
376
+ border-radius: 9999px;
377
+ background: var(--color-accent);
378
+ }
379
+ input[type="range"].range-accent::-moz-range-thumb {
380
+ width: 16px;
381
+ height: 16px;
382
+ border: 2px solid var(--color-surface);
383
+ border-radius: 9999px;
384
+ background: var(--color-accent);
385
+ box-shadow: 0 0 0 1px var(--color-accent), 0 0 8px color-mix(in srgb, var(--color-accent) 55%, transparent);
386
+ cursor: pointer;
387
+ }
@@ -0,0 +1,256 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { adminApi } from "@/lib/client";
5
+ import { Button, Input, Field } from "@/components/Button";
6
+ import { Icon } from "@/components/Icon";
7
+ import { ModelPicker, type ModelGroup } from "@/components/ModelPicker";
8
+ import type { BudgetStatus, ModelsPayload } from "@/lib/gateway";
9
+
10
+ const WINDOWS = ["5h", "24h", "7day", "30day"] as const;
11
+ type ScopeType = "global" | "provider" | "model";
12
+
13
+ /** Segment-pill button style — selected = accent, matches the Unit toggle. */
14
+ const pill = (active: boolean): string =>
15
+ `rounded-brand px-3 py-1.5 text-[13px] font-medium transition-colors ${
16
+ active ? "bg-accent/12 text-accent" : "bg-surface-2 text-text-muted hover:text-text"
17
+ }`;
18
+
19
+ /**
20
+ * A labelled field group for BUTTON controls. Unlike `Field` (a <label>, which
21
+ * forwards a pointer click to its first control and would swallow pill clicks),
22
+ * this is a plain <div> so each pill button receives its own click.
23
+ */
24
+ function Group({ label, children }: { label: string; children: React.ReactNode }) {
25
+ return (
26
+ <div className="flex flex-col gap-1.5">
27
+ <span className="text-[11px] font-medium uppercase tracking-wider text-text-subtle">{label}</span>
28
+ {children}
29
+ </div>
30
+ );
31
+ }
32
+
33
+ const SCOPES: { id: ScopeType; icon: string; label: string; hint: string }[] = [
34
+ { id: "global", icon: "public", label: "Global", hint: "Cap total spend across the whole gateway." },
35
+ { id: "provider", icon: "dns", label: "Per provider", hint: "Cap one provider's spend." },
36
+ { id: "model", icon: "neurology", label: "Per model", hint: "Cap one upstream model's spend." },
37
+ ];
38
+
39
+ /**
40
+ * Inline Add / Edit panel for a scoped budget — same shape as the "Add a
41
+ * provider" flow: step 1 picks the scope (card grid, add only), step 2 is the
42
+ * field set. Editing jumps straight to step 2 with the scope locked (the scope
43
+ * is the budget's identity); every other field stays editable.
44
+ */
45
+ export function BudgetForm({
46
+ initial,
47
+ onSaved,
48
+ onCancel,
49
+ }: {
50
+ initial: BudgetStatus | null;
51
+ onSaved: () => void;
52
+ onCancel: () => void;
53
+ }) {
54
+ const editing = initial !== null;
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
+ );
61
+ const [catalog, setCatalog] = useState<ModelsPayload | null>(null);
62
+ const [pickerOpen, setPickerOpen] = useState(false);
63
+ const [unit, setUnit] = useState<"usd" | "tokens">(initial?.unit ?? "usd");
64
+ const [limit, setLimit] = useState(String(initial?.limit ?? ""));
65
+ const [window, setWindow] = useState<(typeof WINDOWS)[number]>(initial?.window ?? "30day");
66
+ const [alertAt, setAlertAt] = useState(initial ? String(Math.round(initial.alert_at * 100)) : "80");
67
+ const [note, setNote] = useState(initial?.note ?? "");
68
+ const [error, setError] = useState("");
69
+ const [saving, setSaving] = useState(false);
70
+
71
+ useEffect(() => {
72
+ void adminApi.models().then((r) => { if (r.ok && r.data) setCatalog(r.data); });
73
+ }, []);
74
+
75
+ const providerGroups: ModelGroup[] = catalog?.providers.length
76
+ ? [{ label: "Providers", items: catalog.providers.map((p) => ({ value: p.id, label: p.id })) }]
77
+ : [];
78
+ // one entry per distinct upstream model id (a budget keys on usage.model), grouped by provider.
79
+ const modelGroups: ModelGroup[] = (catalog?.providers ?? [])
80
+ .filter((p) => p.models.length > 0)
81
+ .map((p) => ({ label: p.id, items: p.models.map((m) => ({ value: m.id, label: m.id })) }));
82
+ const scopeIdLabel = scopeId;
83
+
84
+ async function save() {
85
+ const limitNum = Number(limit);
86
+ if (!Number.isFinite(limitNum) || limitNum <= 0) return setError("limit must be a positive number");
87
+ const alertPct = Number(alertAt);
88
+ if (!Number.isFinite(alertPct) || alertPct <= 0 || alertPct > 100) return setError("alert % must be 1–100");
89
+ if (scopeType !== "global" && !scopeId.trim()) return setError(`pick a ${scopeType}`);
90
+ const scope =
91
+ scopeType === "global" ? { type: "global" as const } : { type: scopeType!, id: scopeId.trim() };
92
+ setSaving(true);
93
+ setError("");
94
+ try {
95
+ const r = await adminApi.setBudget({ scope, unit, limit: limitNum, window, alert_at: alertPct / 100, note: note.trim() || undefined });
96
+ if (!r.ok) return setError(r.error ?? "could not save budget");
97
+ onSaved();
98
+ } finally {
99
+ setSaving(false);
100
+ }
101
+ }
102
+
103
+ const panel = "mb-5 rounded-brand-lg border border-border bg-surface p-5 shadow-soft";
104
+
105
+ // step 1 (add only): pick the scope.
106
+ if (scopeType === null) {
107
+ return (
108
+ <div className={panel}>
109
+ <div className="flex items-start justify-between gap-3">
110
+ <div>
111
+ <h2 className="text-[14px] font-semibold text-text">Add a budget</h2>
112
+ <p className="mt-0.5 text-[12.5px] text-text-muted">Pick what this budget caps — the rest is one short form.</p>
113
+ </div>
114
+ <button type="button" onClick={onCancel} className="flex-none text-text-subtle hover:text-text" aria-label="Cancel">
115
+ <Icon name="close" size={18} />
116
+ </button>
117
+ </div>
118
+ <div className="mt-4 grid gap-3 sm:grid-cols-3">
119
+ {SCOPES.map((s) => (
120
+ <button
121
+ key={s.id}
122
+ type="button"
123
+ onClick={() => { setScopeType(s.id); setScopeId(""); }}
124
+ className="group flex items-start gap-3 rounded-brand-lg border border-border bg-bg p-4 text-left transition-colors hover:border-accent hover:bg-accent-soft"
125
+ >
126
+ <span className="flex h-10 w-10 flex-none items-center justify-center rounded-brand bg-surface-2 text-text-muted group-hover:text-accent">
127
+ <Icon name={s.icon} size={20} />
128
+ </span>
129
+ <span className="min-w-0">
130
+ <span className="block text-[13.5px] font-semibold text-text">{s.label}</span>
131
+ <span className="mt-1 block text-[11.5px] text-text-muted">{s.hint}</span>
132
+ </span>
133
+ </button>
134
+ ))}
135
+ </div>
136
+ </div>
137
+ );
138
+ }
139
+
140
+ const scopeMeta = SCOPES.find((s) => s.id === scopeType)!;
141
+
142
+ // step 2: the field set.
143
+ return (
144
+ <div className={panel}>
145
+ <div className="mb-4 flex items-center gap-2.5 border-b border-border-subtle pb-4">
146
+ <span className="flex h-8 w-8 items-center justify-center rounded-brand bg-surface-2 text-text-muted">
147
+ <Icon name={scopeMeta.icon} size={17} />
148
+ </span>
149
+ <div>
150
+ <div className="text-[13.5px] font-semibold text-text">{editing ? "Edit budget" : scopeMeta.label}</div>
151
+ <div className="tnum text-[11px] text-text-subtle">
152
+ {scopeType === "global" ? "whole gateway" : `${scopeType} · ${editing ? initial!.label : scopeIdLabel || "—"}`}
153
+ </div>
154
+ </div>
155
+ {editing ? null : (
156
+ <button
157
+ type="button"
158
+ onClick={() => { setScopeType(null); setError(""); }}
159
+ className="ml-auto inline-flex items-center gap-1 text-[12px] text-text-subtle hover:text-text"
160
+ >
161
+ <Icon name="arrow_back" size={14} /> change scope
162
+ </button>
163
+ )}
164
+ </div>
165
+
166
+ <div className="space-y-3">
167
+ {/* scope target — only when adding; editing locks the scope (shown in the header) */}
168
+ {!editing && scopeType !== "global" && (
169
+ <Group label={scopeType === "provider" ? "Provider" : scopeType === "model" ? "Model" : "API key"}>
170
+ <button
171
+ type="button"
172
+ onClick={() => setPickerOpen(true)}
173
+ className="flex w-full items-center justify-between rounded-brand border border-border bg-bg px-3 py-2 text-left text-[13px] transition-colors hover:border-accent"
174
+ >
175
+ <span className={scopeId ? "text-text" : "text-text-subtle"}>
176
+ {scopeId ? scopeIdLabel : scopeType === "provider" ? "Choose a provider…" : scopeType === "model" ? "Choose a model…" : "Choose an API key…"}
177
+ </span>
178
+ <Icon name="search" size={15} className="text-text-subtle" />
179
+ </button>
180
+ </Group>
181
+ )}
182
+
183
+ <Group label="Unit">
184
+ <div className="flex flex-wrap gap-2">
185
+ <button type="button" onClick={() => setUnit("usd")} className={pill(unit === "usd")}>USD</button>
186
+ <button type="button" onClick={() => setUnit("tokens")} className={pill(unit === "tokens")}>Tokens</button>
187
+ </div>
188
+ </Group>
189
+ <Field label="Limit" hint={unit === "usd" ? "$" : "tokens"}>
190
+ <Input value={limit} onChange={(e) => setLimit(e.target.value)} inputMode="decimal" placeholder={unit === "usd" ? "50.00" : "1000000"} />
191
+ </Field>
192
+ <Group label="Window">
193
+ <div className="flex flex-wrap gap-2">
194
+ {WINDOWS.map((w) => (
195
+ <button key={w} type="button" onClick={() => setWindow(w)} className={pill(window === w)}>{w}</button>
196
+ ))}
197
+ </div>
198
+ </Group>
199
+ <Group label="Alert at">
200
+ <div className="flex items-center gap-3">
201
+ <input
202
+ type="range"
203
+ min={0}
204
+ max={100}
205
+ step={1}
206
+ value={Math.max(0, Math.min(100, Number(alertAt) || 0))}
207
+ onChange={(e) => setAlertAt(e.target.value)}
208
+ className="range-accent flex-1"
209
+ style={{
210
+ background: `linear-gradient(to right, var(--color-accent) ${Math.max(0, Math.min(100, Number(alertAt) || 0))}%, var(--color-surface-2) ${Math.max(0, Math.min(100, Number(alertAt) || 0))}%)`,
211
+ }}
212
+ aria-label="Alert threshold percent"
213
+ />
214
+ <div className="relative w-16 flex-none">
215
+ <Input
216
+ value={alertAt}
217
+ onChange={(e) => {
218
+ const v = e.target.value.replace(/[^\d]/g, "");
219
+ if (v === "") return setAlertAt("");
220
+ setAlertAt(String(Math.max(0, Math.min(100, Number(v)))));
221
+ }}
222
+ inputMode="numeric"
223
+ className="pr-5 text-center tnum"
224
+ aria-label="Alert threshold percent (type)"
225
+ />
226
+ <span className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-[12px] text-text-subtle">%</span>
227
+ </div>
228
+ </div>
229
+ <p className="text-[11px] text-text-subtle">Warn once spend crosses this share of the limit.</p>
230
+ </Group>
231
+ <Field label="Note" hint="optional">
232
+ <Input value={note} onChange={(e) => setNote(e.target.value)} maxLength={200} placeholder="e.g. client X cap" />
233
+ </Field>
234
+ </div>
235
+
236
+ {error && <div className="mt-2 text-[12px] text-danger">{error}</div>}
237
+ <div className="mt-4 flex justify-end gap-2">
238
+ <Button type="button" variant="ghost" onClick={onCancel}>Cancel</Button>
239
+ <Button type="button" disabled={saving} onClick={save}>{saving ? "Saving…" : editing ? "Save changes" : "Add budget"}</Button>
240
+ </div>
241
+
242
+ {pickerOpen && scopeType !== "global" && (
243
+ <ModelPicker
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}
248
+ selected={scopeId ? [scopeId] : []}
249
+ onToggle={(v) => { setScopeId(v); setPickerOpen(false); }}
250
+ onClose={() => setPickerOpen(false)}
251
+ showThinkingHint={scopeType === "model"}
252
+ />
253
+ )}
254
+ </div>
255
+ );
256
+ }