aigetwey 1.1.0 → 1.2.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 (56) hide show
  1. package/CHANGELOG.md +46 -3
  2. package/README.md +4 -4
  3. package/config.example.yaml +6 -5
  4. package/dashboard/next.config.ts +6 -0
  5. package/dashboard/src/app/globals.css +47 -0
  6. package/dashboard/src/components/BudgetForm.tsx +258 -0
  7. package/dashboard/src/components/EndpointView.tsx +30 -0
  8. package/dashboard/src/components/LogTable.tsx +90 -25
  9. package/dashboard/src/components/ModelPicker.tsx +15 -7
  10. package/dashboard/src/components/ProviderDetail.tsx +27 -29
  11. package/dashboard/src/components/ProviderManager.tsx +36 -3
  12. package/dashboard/src/components/QuotaView.tsx +95 -81
  13. package/dashboard/src/components/Rail.tsx +1 -1
  14. package/dashboard/src/components/RoutingView.tsx +2 -2
  15. package/dashboard/src/components/ToolDetail.tsx +5 -3
  16. package/dashboard/src/components/TopBar.tsx +1 -1
  17. package/dashboard/src/components/UsageView.tsx +25 -6
  18. package/dashboard/src/lib/cliTools.ts +0 -43
  19. package/dashboard/src/lib/client.ts +9 -3
  20. package/dashboard/src/lib/gateway.ts +12 -1
  21. package/dashboard/src/{middleware.ts → proxy.ts} +8 -6
  22. package/dist/cli.js +43 -8
  23. package/dist/cli.js.map +1 -1
  24. package/dist/config.js +56 -10
  25. package/dist/config.js.map +1 -1
  26. package/dist/core/budget.js +61 -16
  27. package/dist/core/budget.js.map +1 -1
  28. package/dist/core/handler.js +20 -6
  29. package/dist/core/handler.js.map +1 -1
  30. package/dist/core/state.js +10 -2
  31. package/dist/core/state.js.map +1 -1
  32. package/dist/db.js +39 -5
  33. package/dist/db.js.map +1 -1
  34. package/dist/middleware/auth.js +15 -8
  35. package/dist/middleware/auth.js.map +1 -1
  36. package/dist/routes/admin.js +26 -8
  37. package/dist/routes/admin.js.map +1 -1
  38. package/dist/routes/v1.js +15 -11
  39. package/dist/routes/v1.js.map +1 -1
  40. package/dist/server.js +4 -0
  41. package/dist/server.js.map +1 -1
  42. package/dist/upstream/client.js +9 -0
  43. package/dist/upstream/client.js.map +1 -1
  44. package/package.json +3 -4
  45. package/src/cli.ts +44 -8
  46. package/src/config.ts +57 -10
  47. package/src/core/budget.ts +77 -24
  48. package/src/core/handler.ts +24 -7
  49. package/src/core/state.ts +17 -2
  50. package/src/db.ts +50 -5
  51. package/src/middleware/auth.ts +18 -8
  52. package/src/routes/admin.ts +33 -12
  53. package/src/routes/v1.ts +15 -11
  54. package/src/server.ts +4 -0
  55. package/src/upstream/client.ts +9 -0
  56. package/dashboard/src/components/BudgetEditor.tsx +0 -97
package/CHANGELOG.md CHANGED
@@ -7,6 +7,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.2.0] — 2026-06-25
11
+
12
+ ### Added
13
+ - **Scoped budgets** — budgets are now multi-scope: cap spend **globally**, per
14
+ **provider**, per **model**, or per **API key**. Each carries its own unit
15
+ (USD or tokens), window, soft alert, and a hard `402 budget exceeded` stop;
16
+ spend is still derived from the usage table (restart-safe). The Budget Tracker
17
+ page shows them as a card grid with an inline Add/Edit panel and a searchable
18
+ scope picker. Configure via `budgets:` or `PUT /admin/budgets`. Replaces the
19
+ single gateway-wide budget.
20
+ - **Per-API-key budgets** — cap one gateway key's spend. The matched caller key
21
+ fingerprint is recorded on each usage row; `GET /admin/keys` lists keys for
22
+ the picker.
23
+ - **Budget note** — an optional label on a budget to say what it's for.
24
+ - **Headroom re-check** — a "Check" button to re-probe the Headroom proxy.
25
+ - **Usage timeframes** — the Usage window adds **Today** (since local midnight)
26
+ and **60D** alongside 24h / 7D / 30D.
27
+ - **Request log filters** — the request log is collapsible and gains Provider +
28
+ Start/End-date filters with a Clear button.
29
+
30
+ ### Changed
31
+ - **Budget Tracker** — the Quota page is renamed Budget Tracker; the budget
32
+ "Alert at" threshold is a slider with a typeable %, and the per-provider token
33
+ quota grid (superseded by per-provider budgets) only shows when one is set.
34
+ - **Providers** — enable/disable a provider directly from the list card; a
35
+ disabled provider fades, reads red, and its models drop out of the combo,
36
+ CLI-tool, and budget pickers.
37
+ - **CLI tools** — the setup list is trimmed to Claude Code + opencode.
38
+ - **Providers + OpenAI only** — the project is scoped to Anthropic- and
39
+ OpenAI-compatible providers; Gemini is no longer advertised.
40
+ - **Next 16** — adopt the `proxy` file convention (was `middleware`).
41
+
42
+ ### Fixed
43
+ - **Streaming usage** — openai-format streaming upstreams now report token
44
+ usage (`stream_options.include_usage`); previously every streamed call through
45
+ an openai-compatible provider logged 0 tokens in/out.
46
+ - **Session persistence** — the dashboard session secret is persisted to the
47
+ data dir, so a gateway restart no longer invalidates the cookie and forces a
48
+ re-login.
49
+ - Favicon (`icon.svg`) is served publicly past the auth gate.
50
+ - Editing a budget preserves its alert threshold.
51
+ - The launcher waits for the dashboard to be ready, not just the proxy port.
52
+
10
53
  ## [1.1.0] — 2026-06-24
11
54
 
12
55
  ### Added
@@ -30,15 +73,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
30
73
  ## [1.0.0] — 2026-06-24
31
74
 
32
75
  First public release. A personal AI gateway that routes, translates, and tracks
33
- requests across Anthropic / OpenAI / Gemini-compatible providers, with a built-in
76
+ requests across Anthropic and OpenAI-compatible providers, with a built-in
34
77
  dashboard.
35
78
 
36
79
  ### Added
37
80
 
38
81
  #### Gateway
39
82
  - **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.
83
+ translated to/from each provider's native wire format (`openai`, `anthropic`)
84
+ on both ingress and egress.
42
85
  - **Combos** — alias an ordered provider chain (`fallback` or `round-robin`)
43
86
  behind a single model name; the alias *is* the model you call.
44
87
  - **Key pool** — multiple keys per provider with health tracking, cooldown on
package/README.md CHANGED
@@ -28,8 +28,8 @@ 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
@@ -184,8 +184,8 @@ npm run build # compile to dist/
184
184
  ### Sorotan
185
185
 
186
186
  - **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.
187
+ atau Anthropic (`/v1/messages`); gateway menerjemahkan ke/dari provider yang
188
+ kompatibel OpenAI & Anthropic, termasuk streaming.
189
189
  - **Routing + fallback** — sebuah alias klien diarahkan ke rantai provider
190
190
  berprioritas; saat 429/5xx/timeout ia memutar key dan jatuh ke provider
191
191
  berikutnya.
@@ -38,11 +38,12 @@ providers: []
38
38
  # free: true
39
39
  # auto_models: true
40
40
  #
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 }
41
+ # - id: anthropic
42
+ # format: anthropic
43
+ # base_url: https://api.anthropic.com
44
+ # api_key: sk-ant-xxx
45
+ # models:
46
+ # - { id: claude-sonnet-4-6 }
46
47
 
47
48
  # Combos: client-facing alias -> ordered provider chain. Call the alias as the
48
49
  # 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;
@@ -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,258 @@
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", "daily", "weekly", "monthly"] as const;
11
+ type ScopeType = "global" | "provider" | "model" | "key";
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
+ { id: "key", icon: "key", label: "Per API key", hint: "Cap one gateway key's spend." },
38
+ ];
39
+
40
+ /**
41
+ * Inline Add / Edit panel for a scoped budget — same shape as the "Add a
42
+ * provider" flow: step 1 picks the scope (card grid, add only), step 2 is the
43
+ * field set. Editing jumps straight to step 2 with the scope locked (the scope
44
+ * is the budget's identity); every other field stays editable.
45
+ */
46
+ export function BudgetForm({
47
+ initial,
48
+ onSaved,
49
+ onCancel,
50
+ }: {
51
+ initial: BudgetStatus | null;
52
+ onSaved: () => void;
53
+ onCancel: () => void;
54
+ }) {
55
+ 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 : "");
58
+ const [catalog, setCatalog] = useState<ModelsPayload | null>(null);
59
+ const [keys, setKeys] = useState<{ fingerprint: string; name: string; masked: string }[]>([]);
60
+ const [pickerOpen, setPickerOpen] = useState(false);
61
+ const [unit, setUnit] = useState<"usd" | "tokens">(initial?.unit ?? "usd");
62
+ const [limit, setLimit] = useState(String(initial?.limit ?? ""));
63
+ const [window, setWindow] = useState<(typeof WINDOWS)[number]>(initial?.window ?? "monthly");
64
+ const [alertAt, setAlertAt] = useState(initial ? String(Math.round(initial.alert_at * 100)) : "80");
65
+ const [note, setNote] = useState(initial?.note ?? "");
66
+ const [error, setError] = useState("");
67
+ const [saving, setSaving] = useState(false);
68
+
69
+ useEffect(() => {
70
+ 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
+
74
+ const providerGroups: ModelGroup[] = catalog?.providers.length
75
+ ? [{ label: "Providers", items: catalog.providers.map((p) => ({ value: p.id, label: p.id })) }]
76
+ : [];
77
+ // one entry per distinct upstream model id (a budget keys on usage.model), grouped by provider.
78
+ const modelGroups: ModelGroup[] = (catalog?.providers ?? [])
79
+ .filter((p) => p.models.length > 0)
80
+ .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;
85
+
86
+ async function save() {
87
+ const limitNum = Number(limit);
88
+ if (!Number.isFinite(limitNum) || limitNum <= 0) return setError("limit must be a positive number");
89
+ const alertPct = Number(alertAt);
90
+ if (!Number.isFinite(alertPct) || alertPct <= 0 || alertPct > 100) return setError("alert % must be 1–100");
91
+ if (scopeType !== "global" && !scopeId.trim()) return setError(`pick a ${scopeType}`);
92
+ const scope =
93
+ scopeType === "global" ? { type: "global" as const } : { type: scopeType!, id: scopeId.trim() };
94
+ setSaving(true);
95
+ setError("");
96
+ try {
97
+ const r = await adminApi.setBudget({ scope, unit, limit: limitNum, window, alert_at: alertPct / 100, note: note.trim() || undefined });
98
+ if (!r.ok) return setError(r.error ?? "could not save budget");
99
+ onSaved();
100
+ } finally {
101
+ setSaving(false);
102
+ }
103
+ }
104
+
105
+ const panel = "mb-5 rounded-brand-lg border border-border bg-surface p-5 shadow-soft";
106
+
107
+ // step 1 (add only): pick the scope.
108
+ if (scopeType === null) {
109
+ return (
110
+ <div className={panel}>
111
+ <div className="flex items-start justify-between gap-3">
112
+ <div>
113
+ <h2 className="text-[14px] font-semibold text-text">Add a budget</h2>
114
+ <p className="mt-0.5 text-[12.5px] text-text-muted">Pick what this budget caps — the rest is one short form.</p>
115
+ </div>
116
+ <button type="button" onClick={onCancel} className="flex-none text-text-subtle hover:text-text" aria-label="Cancel">
117
+ <Icon name="close" size={18} />
118
+ </button>
119
+ </div>
120
+ <div className="mt-4 grid gap-3 sm:grid-cols-4">
121
+ {SCOPES.map((s) => (
122
+ <button
123
+ key={s.id}
124
+ type="button"
125
+ onClick={() => { setScopeType(s.id); setScopeId(""); }}
126
+ 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"
127
+ >
128
+ <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">
129
+ <Icon name={s.icon} size={20} />
130
+ </span>
131
+ <span className="min-w-0">
132
+ <span className="block text-[13.5px] font-semibold text-text">{s.label}</span>
133
+ <span className="mt-1 block text-[11.5px] text-text-muted">{s.hint}</span>
134
+ </span>
135
+ </button>
136
+ ))}
137
+ </div>
138
+ </div>
139
+ );
140
+ }
141
+
142
+ const scopeMeta = SCOPES.find((s) => s.id === scopeType)!;
143
+
144
+ // step 2: the field set.
145
+ return (
146
+ <div className={panel}>
147
+ <div className="mb-4 flex items-center gap-2.5 border-b border-border-subtle pb-4">
148
+ <span className="flex h-8 w-8 items-center justify-center rounded-brand bg-surface-2 text-text-muted">
149
+ <Icon name={scopeMeta.icon} size={17} />
150
+ </span>
151
+ <div>
152
+ <div className="text-[13.5px] font-semibold text-text">{editing ? "Edit budget" : scopeMeta.label}</div>
153
+ <div className="tnum text-[11px] text-text-subtle">
154
+ {scopeType === "global" ? "whole gateway" : `${scopeType} · ${editing ? initial!.label : scopeIdLabel || "—"}`}
155
+ </div>
156
+ </div>
157
+ {editing ? null : (
158
+ <button
159
+ type="button"
160
+ onClick={() => { setScopeType(null); setError(""); }}
161
+ className="ml-auto inline-flex items-center gap-1 text-[12px] text-text-subtle hover:text-text"
162
+ >
163
+ <Icon name="arrow_back" size={14} /> change scope
164
+ </button>
165
+ )}
166
+ </div>
167
+
168
+ <div className="space-y-3">
169
+ {/* scope target — only when adding; editing locks the scope (shown in the header) */}
170
+ {!editing && scopeType !== "global" && (
171
+ <Group label={scopeType === "provider" ? "Provider" : scopeType === "model" ? "Model" : "API key"}>
172
+ <button
173
+ type="button"
174
+ onClick={() => setPickerOpen(true)}
175
+ 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"
176
+ >
177
+ <span className={scopeId ? "text-text" : "text-text-subtle"}>
178
+ {scopeId ? scopeIdLabel : scopeType === "provider" ? "Choose a provider…" : scopeType === "model" ? "Choose a model…" : "Choose an API key…"}
179
+ </span>
180
+ <Icon name="search" size={15} className="text-text-subtle" />
181
+ </button>
182
+ </Group>
183
+ )}
184
+
185
+ <Group label="Unit">
186
+ <div className="flex flex-wrap gap-2">
187
+ <button type="button" onClick={() => setUnit("usd")} className={pill(unit === "usd")}>USD</button>
188
+ <button type="button" onClick={() => setUnit("tokens")} className={pill(unit === "tokens")}>Tokens</button>
189
+ </div>
190
+ </Group>
191
+ <Field label="Limit" hint={unit === "usd" ? "$" : "tokens"}>
192
+ <Input value={limit} onChange={(e) => setLimit(e.target.value)} inputMode="decimal" placeholder={unit === "usd" ? "50.00" : "1000000"} />
193
+ </Field>
194
+ <Group label="Window">
195
+ <div className="flex flex-wrap gap-2">
196
+ {WINDOWS.map((w) => (
197
+ <button key={w} type="button" onClick={() => setWindow(w)} className={pill(window === w)}>{w}</button>
198
+ ))}
199
+ </div>
200
+ </Group>
201
+ <Group label="Alert at">
202
+ <div className="flex items-center gap-3">
203
+ <input
204
+ type="range"
205
+ min={0}
206
+ max={100}
207
+ step={1}
208
+ value={Math.max(0, Math.min(100, Number(alertAt) || 0))}
209
+ onChange={(e) => setAlertAt(e.target.value)}
210
+ className="range-accent flex-1"
211
+ style={{
212
+ 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))}%)`,
213
+ }}
214
+ aria-label="Alert threshold percent"
215
+ />
216
+ <div className="relative w-16 flex-none">
217
+ <Input
218
+ value={alertAt}
219
+ onChange={(e) => {
220
+ const v = e.target.value.replace(/[^\d]/g, "");
221
+ if (v === "") return setAlertAt("");
222
+ setAlertAt(String(Math.max(0, Math.min(100, Number(v)))));
223
+ }}
224
+ inputMode="numeric"
225
+ className="pr-5 text-center tnum"
226
+ aria-label="Alert threshold percent (type)"
227
+ />
228
+ <span className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-[12px] text-text-subtle">%</span>
229
+ </div>
230
+ </div>
231
+ <p className="text-[11px] text-text-subtle">Warn once spend crosses this share of the limit.</p>
232
+ </Group>
233
+ <Field label="Note" hint="optional">
234
+ <Input value={note} onChange={(e) => setNote(e.target.value)} maxLength={200} placeholder="e.g. client X cap" />
235
+ </Field>
236
+ </div>
237
+
238
+ {error && <div className="mt-2 text-[12px] text-danger">{error}</div>}
239
+ <div className="mt-4 flex justify-end gap-2">
240
+ <Button type="button" variant="ghost" onClick={onCancel}>Cancel</Button>
241
+ <Button type="button" disabled={saving} onClick={save}>{saving ? "Saving…" : editing ? "Save changes" : "Add budget"}</Button>
242
+ </div>
243
+
244
+ {pickerOpen && scopeType !== "global" && (
245
+ <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}
250
+ selected={scopeId ? [scopeId] : []}
251
+ onToggle={(v) => { setScopeId(v); setPickerOpen(false); }}
252
+ onClose={() => setPickerOpen(false)}
253
+ showThinkingHint={scopeType === "model"}
254
+ />
255
+ )}
256
+ </div>
257
+ );
258
+ }
@@ -248,17 +248,39 @@ function HeadroomCard({
248
248
  const [url, setUrl] = useState(h.url);
249
249
  const [localBusy, setLocalBusy] = useState("");
250
250
  const [msg, setMsg] = useState("");
251
+ const [check, setCheck] = useState<{ ok: boolean; text: string } | null>(null);
251
252
  useEffect(() => setUrl(h.url), [h.url]);
252
253
 
253
254
  async function act(label: string, fn: () => Promise<{ ok: boolean; error?: string }>) {
254
255
  setLocalBusy(label);
255
256
  setMsg("");
257
+ setCheck(null);
256
258
  const r = await fn();
257
259
  setLocalBusy("");
258
260
  if (!r.ok) setMsg(r.error ?? "action failed");
259
261
  await refresh();
260
262
  }
261
263
 
264
+ // Live re-probe: ask the gateway whether the proxy at the configured URL
265
+ // actually answers right now, and surface the result inline.
266
+ async function checkProxy() {
267
+ setLocalBusy("check");
268
+ setMsg("");
269
+ setCheck(null);
270
+ const r = await adminApi.headroomStatus();
271
+ setLocalBusy("");
272
+ await refresh();
273
+ if (!r.ok || !r.data) {
274
+ setCheck({ ok: false, text: r.error ?? "could not reach the gateway" });
275
+ return;
276
+ }
277
+ setCheck(
278
+ r.data.running
279
+ ? { ok: true, text: `proxy is up at ${r.data.url}` }
280
+ : { ok: false, text: `no proxy responding at ${r.data.url}` },
281
+ );
282
+ }
283
+
262
284
  return (
263
285
  <RichCard className="lg:col-span-2" header={<CardTitle title="Headroom" sub="external context-compression proxy" />}>
264
286
  <div className="space-y-4">
@@ -312,6 +334,9 @@ function HeadroomCard({
312
334
  >
313
335
  <Icon name="stop" size={16} /> Stop
314
336
  </Button>
337
+ <Button variant="ghost" disabled={localBusy === "check"} onClick={checkProxy}>
338
+ <Icon name="sync" size={16} /> {localBusy === "check" ? "Checking…" : "Check"}
339
+ </Button>
315
340
  {hr && !hr.installed && (
316
341
  <span className="text-[11px] text-text-subtle">
317
342
  Headroom isn’t installed. Get it from{" "}
@@ -329,6 +354,11 @@ function HeadroomCard({
329
354
  </div>
330
355
 
331
356
  {msg && <p className="text-[12px] text-danger">{msg}</p>}
357
+ {check && (
358
+ <p className={`flex items-center gap-1 text-[12px] ${check.ok ? "text-success" : "text-danger"}`}>
359
+ <Icon name={check.ok ? "check_circle" : "error"} size={14} /> {check.text}
360
+ </p>
361
+ )}
332
362
  </div>
333
363
  </RichCard>
334
364
  );
@@ -15,11 +15,17 @@ const FILTERS: { key: StatusFilter; label: string }[] = [
15
15
  { key: "error", label: "Errors" },
16
16
  ];
17
17
 
18
+ // shared control style for the filter row — bordered surface chip, accent on focus.
19
+ const ctrl = "h-9 rounded-brand border border-border bg-surface-2 px-2.5 text-[12.5px] text-text focus:border-accent focus:outline-none";
20
+
18
21
  export function LogTable({ logs: initial }: { logs: UsageLog[] }) {
19
22
  const [logs, setLogs] = useState(initial);
20
23
  const [filter, setFilter] = useState<StatusFilter>("all");
21
24
  const [provFilter, setProvFilter] = useState<string>("all");
25
+ const [startDate, setStartDate] = useState("");
26
+ const [endDate, setEndDate] = useState("");
22
27
  const [live, setLive] = useState(true);
28
+ const [collapsed, setCollapsed] = useState(false);
23
29
  const [expandedIdx, setExpandedIdx] = useState<number | null>(null);
24
30
  const timer = useRef<ReturnType<typeof setInterval> | null>(null);
25
31
 
@@ -46,45 +52,51 @@ export function LogTable({ logs: initial }: { logs: UsageLog[] }) {
46
52
  const okCount = logs.filter((l) => l.status >= 200 && l.status < 300).length;
47
53
  const errCount = logs.length - okCount;
48
54
 
55
+ const startMs = startDate ? new Date(`${startDate}T00:00:00`).getTime() : null;
56
+ const endMs = endDate ? new Date(`${endDate}T23:59:59.999`).getTime() : null;
57
+
49
58
  const shown = logs.filter((l) => {
50
59
  if (filter === "ok" && !(l.status >= 200 && l.status < 300)) return false;
51
60
  if (filter === "error" && l.status >= 200 && l.status < 300) return false;
52
61
  if (provFilter !== "all" && l.provider !== provFilter) return false;
62
+ if (startMs !== null && l.ts < startMs) return false;
63
+ if (endMs !== null && l.ts > endMs) return false;
53
64
  return true;
54
65
  });
55
66
 
67
+ const hasFilters = filter !== "all" || provFilter !== "all" || startDate !== "" || endDate !== "";
68
+ const clearFilters = () => { setFilter("all"); setProvFilter("all"); setStartDate(""); setEndDate(""); };
69
+
56
70
  const count = (k: StatusFilter) => (k === "all" ? logs.length : k === "ok" ? okCount : errCount);
57
71
 
58
72
  return (
59
73
  <div className="overflow-hidden rounded-brand-lg border border-border bg-surface shadow-soft">
60
- <header className="flex flex-wrap items-center justify-between gap-3 border-b border-border-subtle px-4 py-3">
61
- <div className="flex items-center gap-1">
62
- {FILTERS.map((f) => (
74
+ <header className="flex flex-col gap-3 border-b border-border-subtle px-4 py-3">
75
+ <div className="flex flex-wrap items-center justify-between gap-3">
76
+ <div className="flex items-center gap-1.5">
63
77
  <button
64
- key={f.key}
65
- onClick={() => setFilter(f.key)}
66
- className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-[12px] font-medium transition-colors ${
67
- filter === f.key ? "bg-surface-2 text-text" : "text-text-muted hover:text-text"
68
- }`}
78
+ onClick={() => setCollapsed((v) => !v)}
79
+ className="flex-none rounded p-0.5 text-text-subtle transition-colors hover:text-text"
80
+ aria-label={collapsed ? "Expand requests" : "Collapse requests"}
81
+ title={collapsed ? "Expand" : "Collapse"}
69
82
  >
70
- {f.label}
71
- <span className="tnum text-text-subtle">{count(f.key)}</span>
83
+ <Icon name={collapsed ? "chevron_right" : "expand_more"} size={18} />
72
84
  </button>
73
- ))}
74
- </div>
75
- <div className="flex items-center gap-2">
76
- {providers.length > 1 && (
77
- <select
78
- value={provFilter}
79
- onChange={(e) => setProvFilter(e.target.value)}
80
- className="rounded border border-border-subtle bg-transparent px-2 py-1 text-[11px] text-text-muted focus:border-accent focus:outline-none"
81
- >
82
- <option value="all">All providers</option>
83
- {providers.map((p) => (
84
- <option key={p} value={p}>{p}</option>
85
+ <div className="flex items-center gap-1">
86
+ {FILTERS.map((f) => (
87
+ <button
88
+ key={f.key}
89
+ onClick={() => setFilter(f.key)}
90
+ className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-[12px] font-medium transition-colors ${
91
+ filter === f.key ? "bg-surface-2 text-text" : "text-text-muted hover:text-text"
92
+ }`}
93
+ >
94
+ {f.label}
95
+ <span className="tnum text-text-subtle">{count(f.key)}</span>
96
+ </button>
85
97
  ))}
86
- </select>
87
- )}
98
+ </div>
99
+ </div>
88
100
  <button
89
101
  onClick={() => setLive((v) => !v)}
90
102
  className={`flex items-center gap-1 rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors ${
@@ -96,9 +108,53 @@ export function LogTable({ logs: initial }: { logs: UsageLog[] }) {
96
108
  Live
97
109
  </button>
98
110
  </div>
111
+
112
+ {!collapsed && (
113
+ <div className="flex flex-wrap items-end gap-2.5">
114
+ <FilterField label="Provider">
115
+ <select
116
+ value={provFilter}
117
+ onChange={(e) => setProvFilter(e.target.value)}
118
+ className={ctrl + " w-40"}
119
+ >
120
+ <option value="all">All providers</option>
121
+ {providers.map((p) => (
122
+ <option key={p} value={p}>{p}</option>
123
+ ))}
124
+ </select>
125
+ </FilterField>
126
+ <FilterField label="Start date">
127
+ <input
128
+ type="date"
129
+ value={startDate}
130
+ max={endDate || undefined}
131
+ onChange={(e) => setStartDate(e.target.value)}
132
+ className={ctrl + " [color-scheme:dark]"}
133
+ />
134
+ </FilterField>
135
+ <FilterField label="End date">
136
+ <input
137
+ type="date"
138
+ value={endDate}
139
+ min={startDate || undefined}
140
+ onChange={(e) => setEndDate(e.target.value)}
141
+ className={ctrl + " [color-scheme:dark]"}
142
+ />
143
+ </FilterField>
144
+ <button
145
+ onClick={clearFilters}
146
+ disabled={!hasFilters}
147
+ className="flex h-9 items-center gap-1.5 rounded-brand border border-border bg-surface-2 px-3 text-[12.5px] font-medium text-text-muted transition-colors hover:border-text-subtle hover:text-text disabled:opacity-40 disabled:hover:border-border disabled:hover:text-text-muted"
148
+ title={hasFilters ? "Reset all filters" : "No filters applied"}
149
+ >
150
+ <Icon name="filter_alt_off" size={15} />
151
+ Clear
152
+ </button>
153
+ </div>
154
+ )}
99
155
  </header>
100
156
 
101
- {shown.length === 0 ? (
157
+ {collapsed ? null : shown.length === 0 ? (
102
158
  <div className="px-4 py-8 text-center text-[13px] text-text-muted">
103
159
  {logs.length === 0 ? "No requests recorded yet." : "No requests match this filter."}
104
160
  </div>
@@ -158,6 +214,15 @@ export function LogTable({ logs: initial }: { logs: UsageLog[] }) {
158
214
  );
159
215
  }
160
216
 
217
+ function FilterField({ label, children }: { label: string; children: React.ReactNode }) {
218
+ return (
219
+ <label className="flex flex-col gap-1">
220
+ <span className="text-[10px] font-medium uppercase tracking-wider text-text-subtle">{label}</span>
221
+ {children}
222
+ </label>
223
+ );
224
+ }
225
+
161
226
  function Td({
162
227
  children,
163
228
  muted,