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.
- package/CHANGELOG.md +65 -4
- package/README.md +32 -11
- package/config.example.yaml +6 -6
- package/dashboard/next.config.ts +6 -0
- package/dashboard/src/app/(console)/quota/page.tsx +2 -2
- package/dashboard/src/app/globals.css +47 -0
- package/dashboard/src/components/BudgetForm.tsx +256 -0
- package/dashboard/src/components/BudgetTracker.tsx +181 -0
- package/dashboard/src/components/CooldownTimer.tsx +1 -1
- package/dashboard/src/components/EndpointView.tsx +285 -47
- package/dashboard/src/components/LogTable.tsx +97 -25
- package/dashboard/src/components/ModelPicker.tsx +15 -7
- package/dashboard/src/components/ProviderDetail.tsx +27 -29
- package/dashboard/src/components/ProviderManager.tsx +39 -31
- package/dashboard/src/components/Rail.tsx +1 -1
- package/dashboard/src/components/RoutingView.tsx +8 -4
- 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/components/ui.tsx +6 -1
- package/dashboard/src/lib/cliTools.ts +0 -43
- package/dashboard/src/lib/client.ts +14 -7
- package/dashboard/src/lib/gateway.ts +33 -15
- 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 +136 -27
- package/dist/config.js.map +1 -1
- package/dist/core/budget.js +62 -17
- package/dist/core/budget.js.map +1 -1
- package/dist/core/fallback.js +0 -6
- package/dist/core/fallback.js.map +1 -1
- package/dist/core/handler.js +24 -9
- package/dist/core/handler.js.map +1 -1
- package/dist/core/keysUsage.js +15 -0
- package/dist/core/keysUsage.js.map +1 -0
- package/dist/core/ratelimit.js +15 -0
- package/dist/core/ratelimit.js.map +1 -0
- package/dist/core/state.js +15 -15
- package/dist/core/state.js.map +1 -1
- package/dist/core/window.js +35 -0
- package/dist/core/window.js.map +1 -0
- package/dist/db.js +39 -25
- 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 +80 -17
- package/dist/routes/admin.js.map +1 -1
- package/dist/routes/v1.js +28 -11
- package/dist/routes/v1.js.map +1 -1
- package/dist/server.js +5 -7
- package/dist/server.js.map +1 -1
- package/dist/stream/openai-stream.js +3 -0
- package/dist/stream/openai-stream.js.map +1 -1
- package/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 +142 -29
- package/src/core/budget.ts +78 -25
- package/src/core/fallback.ts +0 -9
- package/src/core/handler.ts +31 -12
- package/src/core/keysUsage.ts +49 -0
- package/src/core/ratelimit.ts +25 -0
- package/src/core/state.ts +21 -16
- package/src/core/window.ts +45 -0
- package/src/db.ts +50 -28
- package/src/middleware/auth.ts +18 -8
- package/src/routes/admin.ts +93 -20
- package/src/routes/v1.ts +32 -11
- package/src/server.ts +5 -8
- package/src/stream/openai-stream.ts +3 -1
- package/src/upstream/client.ts +9 -0
- package/dashboard/src/components/BudgetEditor.tsx +0 -97
- package/dashboard/src/components/QuotaView.tsx +0 -152
- 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
|
-
## [
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
- **
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
- **Share it safely** — hand a gateway key to a teammate or a friend and set its
|
|
39
|
+
model allowlist, rate limit, **spend cap**, and **expiry** in one place. Each
|
|
40
|
+
key's budget resets on its own rolling cycle, so a shared key behaves like a
|
|
41
|
+
self-renewing monthly allowance; an expired key is refused with `403`.
|
|
42
|
+
- **Budgets + cost** — scoped spend caps (global/provider/model/key) over rolling
|
|
43
|
+
`5h`/`24h`/`7day`/`30day` windows anchored to when each budget was created, with a
|
|
44
|
+
live reset countdown and SQLite-backed usage/cost tracking. The Budgets page
|
|
45
|
+
splits **Overall** caps from a **Keys** view that shows every key's spend.
|
|
46
|
+
- **Dashboard** — providers, combos, usage, budgets, CLI tools, a live server
|
|
41
47
|
console, and a settings page with a per-model pricing editor.
|
|
42
48
|
|
|
43
49
|
### Token savers
|
|
@@ -117,7 +123,6 @@ providers:
|
|
|
117
123
|
format: anthropic
|
|
118
124
|
base_url: https://api.anthropic.com/v1
|
|
119
125
|
api_keys: [sk-ant-xxx]
|
|
120
|
-
quota: { window: weekly, reset_at: monday, timezone: Asia/Jakarta }
|
|
121
126
|
- id: opencode-free
|
|
122
127
|
format: openai
|
|
123
128
|
base_url: https://opencode.ai/zen/v1
|
|
@@ -130,6 +135,12 @@ models: # routing: client alias -> prioritized provider chai
|
|
|
130
135
|
model: [claude-sonnet-4-6, claude-sonnet-4-5]
|
|
131
136
|
price_in: 3 # USD per 1M tokens (for cost tracking)
|
|
132
137
|
price_out: 15
|
|
138
|
+
|
|
139
|
+
budgets: # spend caps; window = rolling 5h | 24h | 7day | 30day
|
|
140
|
+
- scope: { type: global }
|
|
141
|
+
unit: usd # usd (cost) or tokens
|
|
142
|
+
limit: 50
|
|
143
|
+
window: 30day # rolling 30-day lookback (epoch-aligned bucket)
|
|
133
144
|
```
|
|
134
145
|
|
|
135
146
|
A **combo** is one of these `models` entries: an alias your CLI tool calls,
|
|
@@ -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
|
|
188
|
-
|
|
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
|
-
- **
|
|
196
|
-
|
|
197
|
-
|
|
206
|
+
- **Bagikan dengan aman** — kasih satu gateway key ke teman/rekan, lalu atur
|
|
207
|
+
allowlist model, rate limit, **batas spend**, dan **kedaluwarsa** di satu tempat.
|
|
208
|
+
Budget tiap key reset di siklus rolling-nya sendiri (jadi terasa seperti jatah
|
|
209
|
+
bulanan yang isi ulang otomatis); key yang kedaluwarsa ditolak `403`.
|
|
210
|
+
- **Budget + biaya** — batas spend berskop (global/provider/model/key) atas jendela
|
|
211
|
+
rolling `5h`/`24h`/`7day`/`30day` yang di-anchor ke saat budget dibuat, dengan
|
|
212
|
+
hitung mundur reset dan pelacakan pemakaian/biaya berbasis SQLite. Halaman Budgets
|
|
213
|
+
memisah cap **Overall** dari tampilan **Keys** (pemakaian tiap key).
|
|
214
|
+
- **Dashboard** — providers, combos, usage, budgets, CLI tools, server console
|
|
198
215
|
live, dan halaman settings dengan editor harga per-model.
|
|
199
216
|
|
|
200
217
|
### Penghemat token
|
|
@@ -293,6 +310,10 @@ npm run build # compile ke dist/
|
|
|
293
310
|
|
|
294
311
|
---
|
|
295
312
|
|
|
313
|
+
## Acknowledgements
|
|
314
|
+
|
|
315
|
+
Inspired by [9router](https://github.com/decolua/9router) — its feature set and dashboard shaped much of this project's direction. / Terinspirasi oleh [9router](https://github.com/decolua/9router).
|
|
316
|
+
|
|
296
317
|
## License
|
|
297
318
|
|
|
298
319
|
[MIT](./LICENSE) © xk1ko
|
package/config.example.yaml
CHANGED
|
@@ -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:
|
|
42
|
-
# format:
|
|
43
|
-
# base_url: https://
|
|
44
|
-
#
|
|
45
|
-
#
|
|
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.
|
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,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
|
+
}
|