aigetwey 1.0.1 → 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.
- package/CHANGELOG.md +58 -3
- package/README.md +4 -4
- package/config.example.yaml +6 -5
- package/dashboard/next.config.ts +6 -0
- package/dashboard/src/app/globals.css +47 -0
- package/dashboard/src/components/BudgetForm.tsx +258 -0
- package/dashboard/src/components/EndpointView.tsx +30 -0
- package/dashboard/src/components/LogTable.tsx +90 -25
- package/dashboard/src/components/ModelPicker.tsx +15 -7
- package/dashboard/src/components/ProviderDetail.tsx +27 -29
- package/dashboard/src/components/ProviderManager.tsx +36 -3
- package/dashboard/src/components/QuotaView.tsx +106 -18
- package/dashboard/src/components/Rail.tsx +1 -1
- package/dashboard/src/components/RoutingView.tsx +2 -2
- package/dashboard/src/components/ToolDetail.tsx +5 -3
- package/dashboard/src/components/TopBar.tsx +1 -1
- package/dashboard/src/components/UsageView.tsx +25 -6
- package/dashboard/src/lib/cliTools.ts +0 -43
- package/dashboard/src/lib/client.ts +17 -1
- package/dashboard/src/lib/gateway.ts +25 -1
- package/dashboard/src/{middleware.ts → proxy.ts} +8 -6
- package/dist/cli.js +43 -8
- package/dist/cli.js.map +1 -1
- package/dist/config.js +75 -0
- package/dist/config.js.map +1 -1
- package/dist/core/budget.js +97 -0
- package/dist/core/budget.js.map +1 -0
- package/dist/core/handler.js +21 -1
- package/dist/core/handler.js.map +1 -1
- package/dist/core/quota.js +33 -7
- package/dist/core/quota.js.map +1 -1
- package/dist/core/state.js +17 -2
- package/dist/core/state.js.map +1 -1
- package/dist/db.js +39 -5
- package/dist/db.js.map +1 -1
- package/dist/middleware/auth.js +15 -8
- package/dist/middleware/auth.js.map +1 -1
- package/dist/routes/admin.js +34 -4
- package/dist/routes/admin.js.map +1 -1
- package/dist/routes/v1.js +15 -10
- package/dist/routes/v1.js.map +1 -1
- package/dist/server.js +5 -1
- package/dist/server.js.map +1 -1
- package/dist/upstream/client.js +9 -0
- package/dist/upstream/client.js.map +1 -1
- package/package.json +3 -4
- package/src/cli.ts +44 -8
- package/src/config.ts +81 -0
- package/src/core/budget.ts +128 -0
- package/src/core/handler.ts +26 -1
- package/src/core/quota.ts +40 -1
- package/src/core/state.ts +24 -0
- package/src/db.ts +50 -5
- package/src/middleware/auth.ts +18 -8
- package/src/routes/admin.ts +45 -7
- package/src/routes/v1.ts +15 -10
- package/src/server.ts +5 -1
- package/src/upstream/client.ts +9 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,61 @@ 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
|
+
|
|
53
|
+
## [1.1.0] — 2026-06-24
|
|
54
|
+
|
|
55
|
+
### Added
|
|
56
|
+
- **Gateway-wide budget** — a single spend budget in USD **or** tokens (pick
|
|
57
|
+
one), with a soft alert (default 80%) and a hard stop that returns `402
|
|
58
|
+
budget exceeded` once the window is spent. Budget spend is derived from the
|
|
59
|
+
usage table (one source of truth, restart-safe), and the dashboard's Quota
|
|
60
|
+
page shows it as an editable card with a converse-unit estimate and a reset
|
|
61
|
+
countdown. Configure via `budget:` in config or `PUT /admin/budget`.
|
|
62
|
+
- **Per-provider quota alert** — quotas now carry an optional `alert_at`
|
|
63
|
+
threshold and surface an "alert" badge on the Quota page before they exhaust.
|
|
64
|
+
|
|
10
65
|
## [1.0.1] — 2026-06-24
|
|
11
66
|
|
|
12
67
|
### Fixed
|
|
@@ -18,15 +73,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
18
73
|
## [1.0.0] — 2026-06-24
|
|
19
74
|
|
|
20
75
|
First public release. A personal AI gateway that routes, translates, and tracks
|
|
21
|
-
requests across Anthropic
|
|
76
|
+
requests across Anthropic and OpenAI-compatible providers, with a built-in
|
|
22
77
|
dashboard.
|
|
23
78
|
|
|
24
79
|
### Added
|
|
25
80
|
|
|
26
81
|
#### Gateway
|
|
27
82
|
- **Multi-format translation** — one canonical (OpenAI Chat) request shape,
|
|
28
|
-
translated to/from each provider's native wire format (`openai`, `anthropic
|
|
29
|
-
|
|
83
|
+
translated to/from each provider's native wire format (`openai`, `anthropic`)
|
|
84
|
+
on both ingress and egress.
|
|
30
85
|
- **Combos** — alias an ordered provider chain (`fallback` or `round-robin`)
|
|
31
86
|
behind a single model name; the alias *is* the model you call.
|
|
32
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
|
|
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
|
|
188
|
-
|
|
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.
|
package/config.example.yaml
CHANGED
|
@@ -38,11 +38,12 @@ providers: []
|
|
|
38
38
|
# free: true
|
|
39
39
|
# auto_models: true
|
|
40
40
|
#
|
|
41
|
-
# - id:
|
|
42
|
-
# format:
|
|
43
|
-
# base_url: https://
|
|
44
|
-
#
|
|
45
|
-
#
|
|
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.
|
package/dashboard/next.config.ts
CHANGED
|
@@ -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
|
);
|