summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--internal/adminapi/api.go362
-rw-r--r--internal/adminui/assets/app.js153
-rw-r--r--internal/adminui/assets/index.html69
-rw-r--r--internal/adminui/assets/style.css11
-rw-r--r--internal/billing/ledger.go171
-rw-r--r--internal/billing/service.go352
-rw-r--r--internal/billing/service_test.go190
-rw-r--r--internal/billing/stripe.go203
-rw-r--r--internal/billing/types.go83
-rw-r--r--internal/catalog/catalog.go9
-rw-r--r--internal/config/config.go89
-rw-r--r--internal/config/config_test.go25
-rw-r--r--internal/controlplane/access.go156
-rw-r--r--internal/controlplane/access_test.go39
-rw-r--r--internal/controlplane/audit.go52
-rw-r--r--internal/controlplane/limits.go81
-rw-r--r--internal/controlplane/limits_integration_test.go62
-rw-r--r--internal/controlplane/manager.go20
-rw-r--r--internal/controlplane/manager_test.go20
-rw-r--r--internal/controlplane/mutations.go16
-rw-r--r--internal/controlplane/queries.go89
-rw-r--r--internal/controlplane/schema.sql179
-rw-r--r--internal/controlplane/snapshot.go41
-rw-r--r--internal/controlplane/types.go163
-rw-r--r--internal/controlplane/usage.go151
-rw-r--r--internal/domain/types.go21
-rw-r--r--internal/httpapi/api.go113
-rw-r--r--internal/httpapi/api_test.go83
-rw-r--r--internal/limits/limits.go316
-rw-r--r--internal/limits/limits_test.go103
30 files changed, 3291 insertions, 131 deletions
diff --git a/internal/adminapi/api.go b/internal/adminapi/api.go
index 1eb2a5d..7f5f8bd 100644
--- a/internal/adminapi/api.go
+++ b/internal/adminapi/api.go
@@ -1,15 +1,21 @@
package adminapi
import (
+ "context"
+ "crypto/rand"
"crypto/subtle"
+ "encoding/hex"
"encoding/json"
"errors"
"log/slog"
+ "net"
"net/http"
"strings"
+ "time"
"aigw/internal/adminui"
"aigw/internal/apierror"
+ "aigw/internal/billing"
"aigw/internal/controlplane"
"github.com/jackc/pgx/v5/pgconn"
@@ -18,14 +24,33 @@ import (
type API struct {
store *controlplane.Store
manager *controlplane.Manager
+ billing *billing.Service
token []byte
logger *slog.Logger
prefix string
}
+type actorKey struct{}
+type auditWriter struct {
+ http.ResponseWriter
+ status int
+}
+
+func (w *auditWriter) WriteHeader(status int) {
+ w.status = status
+ w.ResponseWriter.WriteHeader(status)
+}
+func (w *auditWriter) Write(body []byte) (int, error) {
+ if w.status == 0 {
+ w.status = http.StatusOK
+ }
+ return w.ResponseWriter.Write(body)
+}
+
type Options struct {
Store *controlplane.Store
Manager *controlplane.Manager
+ Billing *billing.Service
Token string
Logger *slog.Logger
Prefix string
@@ -36,7 +61,7 @@ func New(options Options) *API {
if prefix == "" {
prefix = "/admin"
}
- return &API{store: options.Store, manager: options.Manager, token: []byte(options.Token), logger: options.Logger, prefix: prefix}
+ return &API{store: options.Store, manager: options.Manager, billing: options.Billing, token: []byte(options.Token), logger: options.Logger, prefix: prefix}
}
func (a *API) Handler() http.Handler {
@@ -47,40 +72,119 @@ func (a *API) Handler() http.Handler {
})
mux.Handle(a.prefix+"/", http.StripPrefix(a.prefix, adminui.Handler()))
- mux.HandleFunc("GET "+apiPrefix+"/overview", a.withAuth(a.overview))
- mux.HandleFunc("GET "+apiPrefix+"/tenants", a.withAuth(a.listTenants))
- mux.HandleFunc("POST "+apiPrefix+"/tenants", a.withAuth(a.createTenant))
- mux.HandleFunc("GET "+apiPrefix+"/projects", a.withAuth(a.listProjects))
- mux.HandleFunc("POST "+apiPrefix+"/projects", a.withAuth(a.createProject))
- mux.HandleFunc("GET "+apiPrefix+"/keys", a.withAuth(a.listKeys))
- mux.HandleFunc("POST "+apiPrefix+"/keys", a.withAuth(a.createKey))
- mux.HandleFunc("POST "+apiPrefix+"/keys/{id}/revoke", a.withAuth(a.revokeKey))
- mux.HandleFunc("GET "+apiPrefix+"/providers", a.withAuth(a.listProviders))
- mux.HandleFunc("POST "+apiPrefix+"/providers", a.withAuth(a.createProvider))
- mux.HandleFunc("POST "+apiPrefix+"/providers/{id}/toggle", a.withAuth(a.toggleProvider))
- mux.HandleFunc("GET "+apiPrefix+"/models", a.withAuth(a.listModels))
- mux.HandleFunc("POST "+apiPrefix+"/models", a.withAuth(a.createModel))
- mux.HandleFunc("POST "+apiPrefix+"/models/{id}/toggle", a.withAuth(a.toggleModel))
- mux.HandleFunc("POST "+apiPrefix+"/reload", a.withAuth(a.reload))
+ mux.HandleFunc("GET "+apiPrefix+"/overview", a.withAuth("overview.read", a.overview))
+ mux.HandleFunc("GET "+apiPrefix+"/tenants", a.withAuth("tenants.read", a.listTenants))
+ mux.HandleFunc("POST "+apiPrefix+"/tenants", a.withAuth("tenants.write", a.createTenant))
+ mux.HandleFunc("GET "+apiPrefix+"/projects", a.withAuth("projects.read", a.listProjects))
+ mux.HandleFunc("POST "+apiPrefix+"/projects", a.withAuth("projects.write", a.createProject))
+ mux.HandleFunc("GET "+apiPrefix+"/keys", a.withAuth("keys.read", a.listKeys))
+ mux.HandleFunc("POST "+apiPrefix+"/keys", a.withAuth("keys.write", a.createKey))
+ mux.HandleFunc("POST "+apiPrefix+"/keys/{id}/revoke", a.withAuth("keys.write", a.revokeKey))
+ mux.HandleFunc("GET "+apiPrefix+"/providers", a.withAuth("platform.read", a.listProviders))
+ mux.HandleFunc("POST "+apiPrefix+"/providers", a.withAuth("platform.write", a.createProvider))
+ mux.HandleFunc("POST "+apiPrefix+"/providers/{id}/toggle", a.withAuth("platform.write", a.toggleProvider))
+ mux.HandleFunc("GET "+apiPrefix+"/models", a.withAuth("platform.read", a.listModels))
+ mux.HandleFunc("POST "+apiPrefix+"/models", a.withAuth("platform.write", a.createModel))
+ mux.HandleFunc("POST "+apiPrefix+"/models/{id}/toggle", a.withAuth("platform.write", a.toggleModel))
+ mux.HandleFunc("POST "+apiPrefix+"/reload", a.withAuth("platform.write", a.reload))
+ if a.billing != nil {
+ mux.HandleFunc("GET "+apiPrefix+"/billing/accounts", a.withAuth("billing.read", a.listBillingAccounts))
+ mux.HandleFunc("GET "+apiPrefix+"/billing/ledger", a.withAuth("billing.read", a.listBillingLedger))
+ mux.HandleFunc("POST "+apiPrefix+"/billing/adjustments", a.withAuth("billing.adjust", a.adjustBalance))
+ mux.HandleFunc("POST "+apiPrefix+"/billing/checkout-sessions", a.withAuth("billing.topup", a.createCheckoutSession))
+ }
+ mux.HandleFunc("GET "+apiPrefix+"/usage", a.withAuth("usage.read", a.listUsage))
+ mux.HandleFunc("GET "+apiPrefix+"/usage/summary", a.withAuth("usage.read", a.usageSummary))
+ mux.HandleFunc("GET "+apiPrefix+"/limits", a.withAuth("limits.read", a.listLimits))
+ mux.HandleFunc("POST "+apiPrefix+"/limits/{project_id}", a.withAuth("limits.write", a.setLimit))
+ mux.HandleFunc("GET "+apiPrefix+"/users", a.withAuth("users.read", a.listUsers))
+ mux.HandleFunc("POST "+apiPrefix+"/users", a.withAuth("users.write", a.createUser))
+ mux.HandleFunc("POST "+apiPrefix+"/users/{id}/revoke", a.withAuth("users.write", a.revokeUser))
+ mux.HandleFunc("GET "+apiPrefix+"/audit", a.withAuth("audit.read", a.listAudit))
+ mux.HandleFunc("GET "+apiPrefix+"/me", a.withAuth("overview.read", a.me))
return mux
}
-func (a *API) withAuth(next http.HandlerFunc) http.HandlerFunc {
+func (a *API) withAuth(permission string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("X-AIGW-Request-ID") == "" {
+ r.Header.Set("X-AIGW-Request-ID", adminRequestID(r))
+ }
+ w.Header().Set("X-AIGW-Request-ID", r.Header.Get("X-AIGW-Request-ID"))
provided := strings.TrimSpace(r.Header.Get("X-Admin-Token"))
if provided == "" {
provided = bearerToken(r.Header.Get("Authorization"))
}
- if len(provided) == 0 || subtle.ConstantTimeCompare([]byte(provided), a.token) != 1 {
+ actor := controlplane.ConsoleActor{}
+ if len(provided) > 0 && len(a.token) > 0 && subtle.ConstantTimeCompare([]byte(provided), a.token) == 1 {
+ actor = controlplane.ConsoleActor{Role: controlplane.RolePlatformAdmin, DisplayName: "Bootstrap administrator", Bootstrap: true}
+ } else if provided != "" && a.store != nil {
+ var err error
+ actor, err = a.store.AuthenticateConsoleToken(r.Context(), provided)
+ if err != nil && !errors.Is(err, controlplane.ErrConsoleUnauthorized) {
+ a.logger.Error("console_authentication_failed", "error", err)
+ apierror.Write(w, apierror.Error{Status: http.StatusServiceUnavailable, Type: "control_plane_unavailable", Message: "Control plane authentication is temporarily unavailable"}, requestID(r))
+ return
+ }
+ if err != nil {
+ actor = controlplane.ConsoleActor{}
+ }
+ }
+ if actor.Role == "" {
apierror.Write(w, apierror.Error{Status: http.StatusUnauthorized, Type: "admin_unauthorized", Message: "Administrator authentication required"}, requestID(r))
return
}
- next(w, r)
+ if !actor.Can(permission) {
+ aw := &auditWriter{ResponseWriter: w}
+ apierror.Write(aw, apierror.Error{Status: http.StatusForbidden, Type: "admin_forbidden", Message: "You do not have permission for this operation"}, requestID(r))
+ a.writeAudit(r, actor, permission, aw.status)
+ return
+ }
+ request := r.WithContext(context.WithValue(r.Context(), actorKey{}, actor))
+ aw := &auditWriter{ResponseWriter: w}
+ next(aw, request)
+ if aw.status == 0 {
+ aw.status = http.StatusOK
+ }
+ a.writeAudit(request, actor, permission, aw.status)
+ }
+}
+
+func (a *API) actor(r *http.Request) controlplane.ConsoleActor {
+ actor, _ := r.Context().Value(actorKey{}).(controlplane.ConsoleActor)
+ return actor
+}
+
+func (a *API) writeAudit(r *http.Request, actor controlplane.ConsoleActor, action string, status int) {
+ if a.store == nil {
+ return
+ }
+ ip := r.RemoteAddr
+ if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
+ ip = host
}
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ if err := a.store.WriteAudit(ctx, controlplane.AuditInput{Actor: actor, RequestID: requestID(r), Method: r.Method, Path: r.URL.Path, Action: action, StatusCode: status, RemoteIP: ip, UserAgent: r.UserAgent()}); err != nil && a.logger != nil {
+ a.logger.Warn("admin_audit_write_failed", "error", err)
+ }
+}
+
+func adminRequestID(r *http.Request) string {
+ if id := r.Header.Get("X-AIGW-Request-ID"); id != "" {
+ return id
+ }
+ var b [12]byte
+ if _, err := rand.Read(b[:]); err == nil {
+ return "adm_" + hex.EncodeToString(b[:])
+ }
+ return "adm_unknown"
}
func (a *API) overview(w http.ResponseWriter, r *http.Request) {
- result, err := a.store.Overview(r.Context())
+ actor := a.actor(r)
+ tenantID := actor.TenantID
+ result, err := a.store.OverviewFor(r.Context(), tenantID)
if err != nil {
a.databaseError(w, r, err)
return
@@ -88,11 +192,67 @@ func (a *API) overview(w http.ResponseWriter, r *http.Request) {
result.RuntimeGeneration = a.manager.Generation()
result.RedisConfigured = a.manager.RedisConfigured()
result.RedisConnected = a.manager.RedisConnected()
+ result.BillingEnabled = a.billing != nil
+ if a.billing != nil {
+ result.StripeEnabled = a.billing.StripeEnabled()
+ result.BillingCurrency = a.billing.Currency()
+ }
+ writeJSON(w, result)
+}
+
+func (a *API) listBillingAccounts(w http.ResponseWriter, r *http.Request) {
+ result, err := a.billing.ListAccounts(r.Context(), a.actor(r).TenantID)
+ if err != nil {
+ a.billingError(w, r, err)
+ return
+ }
writeJSON(w, result)
}
+func (a *API) listBillingLedger(w http.ResponseWriter, r *http.Request) {
+ tenantID := a.actor(r).TenantID
+ if tenantID == "" {
+ tenantID = r.URL.Query().Get("tenant_id")
+ }
+ result, err := a.billing.ListLedger(r.Context(), tenantID, 200)
+ if err != nil {
+ a.billingError(w, r, err)
+ return
+ }
+ writeJSON(w, result)
+}
+
+func (a *API) adjustBalance(w http.ResponseWriter, r *http.Request) {
+ var input billing.AdjustmentInput
+ if !decodeBody(w, r, &input) {
+ return
+ }
+ result, err := a.billing.AdjustBalance(r.Context(), input)
+ if err != nil {
+ a.billingError(w, r, err)
+ return
+ }
+ writeStatusJSON(w, http.StatusCreated, result)
+}
+
+func (a *API) createCheckoutSession(w http.ResponseWriter, r *http.Request) {
+ var input billing.CheckoutInput
+ if !decodeBody(w, r, &input) {
+ return
+ }
+ if tenantID := a.actor(r).TenantID; tenantID != "" {
+ input.TenantID = tenantID
+ }
+ result, err := a.billing.CreateCheckout(r.Context(), input)
+ if err != nil {
+ a.billingError(w, r, err)
+ return
+ }
+ writeStatusJSON(w, http.StatusCreated, result)
+}
+
func (a *API) listTenants(w http.ResponseWriter, r *http.Request) {
- result, err := a.store.ListTenants(r.Context())
+ result, err := a.store.ListTenantsFor(r.Context(), a.actor(r).TenantID)
if err != nil {
a.databaseError(w, r, err)
return
@@ -117,7 +277,7 @@ func (a *API) createTenant(w http.ResponseWriter, r *http.Request) {
}
func (a *API) listProjects(w http.ResponseWriter, r *http.Request) {
- result, err := a.store.ListProjects(r.Context())
+ result, err := a.store.ListProjectsFor(r.Context(), a.actor(r).TenantID)
if err != nil {
a.databaseError(w, r, err)
return
@@ -130,6 +290,9 @@ func (a *API) createProject(w http.ResponseWriter, r *http.Request) {
if !decodeBody(w, r, &input) {
return
}
+ if tenantID := a.actor(r).TenantID; tenantID != "" {
+ input.TenantID = tenantID
+ }
result, generation, err := a.store.CreateProject(r.Context(), input)
if err != nil {
a.mutationError(w, r, err)
@@ -142,7 +305,7 @@ func (a *API) createProject(w http.ResponseWriter, r *http.Request) {
}
func (a *API) listKeys(w http.ResponseWriter, r *http.Request) {
- result, err := a.store.ListAPIKeys(r.Context())
+ result, err := a.store.ListAPIKeysFor(r.Context(), a.actor(r).TenantID)
if err != nil {
a.databaseError(w, r, err)
return
@@ -155,6 +318,9 @@ func (a *API) createKey(w http.ResponseWriter, r *http.Request) {
if !decodeBody(w, r, &input) {
return
}
+ if tenantID := a.actor(r).TenantID; tenantID != "" {
+ input.TenantID = tenantID
+ }
result, generation, err := a.store.CreateAPIKey(r.Context(), input)
if err != nil {
a.mutationError(w, r, err)
@@ -167,6 +333,10 @@ func (a *API) createKey(w http.ResponseWriter, r *http.Request) {
}
func (a *API) revokeKey(w http.ResponseWriter, r *http.Request) {
+ if err := a.requireResourceTenant(r, "api_key", r.PathValue("id")); err != nil {
+ a.scopeError(w, r)
+ return
+ }
generation, err := a.store.RevokeAPIKey(r.Context(), r.PathValue("id"))
if err != nil {
a.mutationError(w, r, err)
@@ -275,6 +445,127 @@ func (a *API) reload(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"generation": generation, "status": "reloaded"})
}
+func (a *API) listUsage(w http.ResponseWriter, r *http.Request) {
+ query := controlplane.UsageQuery{TenantID: a.actor(r).TenantID, ProjectID: r.URL.Query().Get("project_id"), Model: r.URL.Query().Get("model"), Limit: 200}
+ result, err := a.store.ListUsage(r.Context(), query)
+ if err != nil {
+ a.databaseError(w, r, err)
+ return
+ }
+ writeJSON(w, result)
+}
+
+func (a *API) usageSummary(w http.ResponseWriter, r *http.Request) {
+ result, err := a.store.UsageSummary(r.Context(), a.actor(r).TenantID, r.URL.Query().Get("project_id"))
+ if err != nil {
+ a.databaseError(w, r, err)
+ return
+ }
+ writeJSON(w, result)
+}
+
+func (a *API) listLimits(w http.ResponseWriter, r *http.Request) {
+ result, err := a.store.ListProjectLimits(r.Context(), a.actor(r).TenantID)
+ if err != nil {
+ a.databaseError(w, r, err)
+ return
+ }
+ writeJSON(w, result)
+}
+
+func (a *API) setLimit(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("project_id")
+ if err := a.requireResourceTenant(r, "project", id); err != nil {
+ a.scopeError(w, r)
+ return
+ }
+ var input controlplane.SetProjectLimitInput
+ if !decodeBody(w, r, &input) {
+ return
+ }
+ result, generation, err := a.store.SetProjectLimit(r.Context(), id, input)
+ if err != nil {
+ a.mutationError(w, r, err)
+ return
+ }
+ if !a.changed(w, r, generation, "project_limit", id) {
+ return
+ }
+ writeJSON(w, result)
+}
+
+func (a *API) listUsers(w http.ResponseWriter, r *http.Request) {
+ result, err := a.store.ListConsoleUsers(r.Context(), a.actor(r).TenantID)
+ if err != nil {
+ a.databaseError(w, r, err)
+ return
+ }
+ writeJSON(w, result)
+}
+
+func (a *API) createUser(w http.ResponseWriter, r *http.Request) {
+ var input controlplane.CreateConsoleUserInput
+ if !decodeBody(w, r, &input) {
+ return
+ }
+ if tenantID := a.actor(r).TenantID; tenantID != "" {
+ input.TenantID = tenantID
+ if strings.HasPrefix(input.Role, "platform_") {
+ a.scopeError(w, r)
+ return
+ }
+ }
+ result, err := a.store.CreateConsoleUser(r.Context(), input)
+ if err != nil {
+ a.mutationError(w, r, err)
+ return
+ }
+ writeStatusJSON(w, http.StatusCreated, result)
+}
+
+func (a *API) revokeUser(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ if err := a.requireResourceTenant(r, "console_user", id); err != nil {
+ a.scopeError(w, r)
+ return
+ }
+ if err := a.store.RevokeConsoleUser(r.Context(), id, a.actor(r).TenantID); err != nil {
+ a.mutationError(w, r, err)
+ return
+ }
+ writeJSON(w, map[string]any{"status": "revoked", "id": id})
+}
+
+func (a *API) listAudit(w http.ResponseWriter, r *http.Request) {
+ result, err := a.store.ListAudit(r.Context(), a.actor(r).TenantID, 200)
+ if err != nil {
+ a.databaseError(w, r, err)
+ return
+ }
+ writeJSON(w, result)
+}
+
+func (a *API) me(w http.ResponseWriter, r *http.Request) {
+ actor := a.actor(r)
+ writeJSON(w, map[string]any{"actor": actor, "permissions": actor.Permissions()})
+}
+
+func (a *API) requireResourceTenant(r *http.Request, resource, id string) error {
+ actor := a.actor(r)
+ if actor.IsPlatform() {
+ return nil
+ }
+ tenantID, err := a.store.ResourceTenantID(r.Context(), resource, id)
+ if err != nil || tenantID == "" || tenantID != actor.TenantID {
+ return errors.New("resource is outside tenant scope")
+ }
+ return nil
+}
+
+func (a *API) scopeError(w http.ResponseWriter, r *http.Request) {
+ apierror.Write(w, apierror.Error{Status: http.StatusForbidden, Type: "tenant_scope", Message: "Resource is outside your tenant"}, requestID(r))
+}
+
func (a *API) changed(w http.ResponseWriter, r *http.Request, generation int64, resource, id string) bool {
if err := a.manager.AfterMutation(r.Context(), generation, resource, id); err != nil {
a.logger.Error("admin_control_plane_sync_failed", "resource", resource, "id", id, "error", err)
@@ -314,6 +605,29 @@ func (a *API) mutationError(w http.ResponseWriter, r *http.Request, err error) {
apierror.Write(w, apierror.Error{Status: status, Type: typeName, Message: message}, requestID(r))
}
+func (a *API) billingError(w http.ResponseWriter, r *http.Request, err error) {
+ status := http.StatusInternalServerError
+ typeName := "billing_error"
+ message := "Billing operation failed"
+ switch {
+ case errors.Is(err, billing.ErrInvalidAmount):
+ status = http.StatusBadRequest
+ typeName = "invalid_amount"
+ message = "Amount is outside the configured bounds"
+ case errors.Is(err, billing.ErrInsufficientBalance):
+ status = http.StatusPaymentRequired
+ typeName = "insufficient_balance"
+ message = "Available balance is insufficient"
+ case errors.Is(err, billing.ErrStripeDisabled):
+ status = http.StatusServiceUnavailable
+ typeName = "stripe_disabled"
+ message = "Stripe top-ups are disabled"
+ default:
+ a.logger.Error("admin_billing_error", "error", err)
+ }
+ apierror.Write(w, apierror.Error{Status: status, Type: typeName, Message: message}, requestID(r))
+}
+
func decodeBody(w http.ResponseWriter, r *http.Request, destination any) bool {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
defer r.Body.Close()
@@ -348,5 +662,5 @@ func requestID(r *http.Request) string {
if value := r.Header.Get("X-AIGW-Request-ID"); value != "" {
return value
}
- return "admin"
+ return adminRequestID(r)
}
diff --git a/internal/adminui/assets/app.js b/internal/adminui/assets/app.js
index f5ba048..6b8bf87 100644
--- a/internal/adminui/assets/app.js
+++ b/internal/adminui/assets/app.js
@@ -1,70 +1,131 @@
-const state = { token: sessionStorage.getItem('aigw_admin_token') || '', tenants: [], projects: [], providers: [], models: [], keys: [] };
+const state = {
+ token: sessionStorage.getItem('aigw_admin_token') || '', actor: {}, permissions: new Set(), overview: {},
+ tenants: [], projects: [], keys: [], providers: [], models: [], billingAccounts: [], ledger: [],
+ usage: [], usageSummary: [], limits: [], users: [], audit: []
+};
const $ = (selector) => document.querySelector(selector);
const $$ = (selector) => [...document.querySelectorAll(selector)];
+const can = (permission) => state.permissions.has(permission);
-function esc(value) {
- return String(value ?? '').replace(/[&<>'"]/g, (char) => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', "'":'&#39;', '"':'&quot;' }[char]));
-}
+function esc(value) { return String(value ?? '').replace(/[&<>'"]/g, (char) => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', "'":'&#39;', '"':'&quot;' }[char])); }
function date(value) { return value ? new Date(value).toLocaleString() : '—'; }
-function toast(message, error = false) {
- const node = $('#toast'); node.textContent = message; node.className = `toast visible ${error ? 'error' : ''}`;
- setTimeout(() => { node.className = 'toast'; }, 3200);
-}
+function shortID(value) { const text = String(value || ''); return text ? `${text.slice(0, 10)}${text.length > 10 ? '…' : ''}` : '—'; }
+function percent(part, total) { return total ? `${Math.round((part / total) * 100)}%` : '—'; }
+function toast(message, error = false) { const node = $('#toast'); node.textContent = message; node.className = `toast visible ${error ? 'error' : ''}`; setTimeout(() => { node.className = 'toast'; }, 3200); }
async function api(path, options = {}) {
- const response = await fetch(`./api${path}`, { ...options, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${state.token}`, ...(options.headers || {}) } });
+ const response = await fetch(`./api${path}`, { ...options, headers: { 'Content-Type':'application/json', 'Authorization':`Bearer ${state.token}`, ...(options.headers || {}) } });
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload?.error?.message || `Request failed (${response.status})`);
return payload;
}
function setConnected(connected) {
- $('#connection-state').textContent = connected ? 'Connected' : 'Offline';
+ $('#connection-state').textContent = connected ? state.actor.role?.replaceAll('_', ' ') || 'Connected' : 'Offline';
$('#connection-state').className = `state ${connected ? 'online' : ''}`;
+ $('#actor-label').textContent = connected ? state.actor.display_name || state.actor.email || '' : '';
}
function formJSON(form) { return Object.fromEntries(new FormData(form).entries()); }
function selectOptions(items, valueKey, labelKey, empty = 'Select…') { return `<option value="">${empty}</option>${items.map(item => `<option value="${esc(item[valueKey])}">${esc(item[labelKey])}</option>`).join('')}`; }
+function decimalToScaled(value, digits) {
+ const match = String(value).trim().match(/^(-?)(\d+)(?:\.(\d+))?$/); if (!match) throw new Error('Enter a valid decimal amount');
+ const fraction = match[3] || ''; if (fraction.length > digits && /[1-9]/.test(fraction.slice(digits))) throw new Error(`Use at most ${digits} decimal places`);
+ const scale = 10n ** BigInt(digits); const absolute = BigInt(match[2]) * scale + BigInt((fraction.slice(0, digits) + '0'.repeat(digits)).slice(0, digits) || '0');
+ const result = Number(match[1] ? -absolute : absolute); if (!Number.isSafeInteger(result)) throw new Error('Amount is too large'); return result;
+}
+function scaledToDecimal(value, digits) { const number = BigInt(value || 0); const whole = number / (10n ** BigInt(digits)); const fraction = String(number % (10n ** BigInt(digits))).padStart(digits, '0').replace(/0+$/, ''); return fraction ? `${whole}.${fraction}` : String(whole); }
+function currencyDigits(currency) { return ['bif','clp','djf','gnf','jpy','kmf','krw','mga','pyg','rwf','ugx','vnd','vuv','xaf','xof','xpf'].includes(currency) ? 0 : ['bhd','jod','kwd','omr','tnd'].includes(currency) ? 3 : 2; }
+function money(micros, currency = state.overview.billing_currency || 'usd') { return new Intl.NumberFormat(undefined, { style:'currency', currency:currency.toUpperCase(), minimumFractionDigits:2, maximumFractionDigits:6 }).format(Number(micros || 0) / 1_000_000); }
+function integer(value) { return new Intl.NumberFormat().format(Number(value || 0)); }
+function emptyRow(span) { return `<tr><td colspan="${span}" class="empty">No records yet</td></tr>`; }
+function showSecret(title, value) { $('#secret-title').textContent = title; $('#created-secret').textContent = value; $('#secret-dialog').showModal(); }
+async function permitted(permission, path) { if (!can(permission)) return []; return api(path); }
async function loadAll() {
if (!state.token) { setConnected(false); return; }
try {
- [state.tenants, state.projects, state.providers, state.models, state.keys] = await Promise.all([
- api('/tenants'), api('/projects'), api('/providers'), api('/models'), api('/keys')
+ const session = await api('/me'); state.actor = session.actor || {}; state.permissions = new Set(session.permissions || []);
+ state.overview = await api('/overview');
+ const results = await Promise.all([
+ permitted('tenants.read','/tenants'), permitted('projects.read','/projects'), permitted('keys.read','/keys'),
+ permitted('platform.read','/providers'), permitted('platform.read','/models'), permitted('usage.read','/usage'),
+ permitted('usage.read','/usage/summary'), permitted('limits.read','/limits'), permitted('users.read','/users'),
+ permitted('audit.read','/audit'), state.overview.billing_enabled ? permitted('billing.read','/billing/accounts') : [],
+ state.overview.billing_enabled ? permitted('billing.read','/billing/ledger') : []
]);
- const overview = await api('/overview');
- renderOverview(overview); renderTenants(); renderProjects(); renderKeys(); renderProviders(); renderModels(); renderRouteEditor(); setConnected(true);
+ [state.tenants,state.projects,state.keys,state.providers,state.models,state.usage,state.usageSummary,state.limits,state.users,state.audit,state.billingAccounts,state.ledger] = results;
+ renderAll(); setConnected(true);
} catch (error) { setConnected(false); toast(error.message, true); }
}
-function renderOverview(data) {
- const propagation = !data.redis_configured ? 'PG polling' : data.redis_connected ? 'Redis live' : 'PG fallback';
- const items = [['Tenants', data.tenants, 'active accounts'], ['Projects', data.projects, 'workspaces'], ['API keys', data.api_keys, 'active credentials'], ['Providers', data.providers, 'enabled upstreams'], ['Models', data.models, 'public routes'], ['Runtime', data.runtime_generation, `PG generation ${data.generation}`], ['Propagation', propagation, data.redis_configured ? 'automatic recovery' : 'Redis not configured']];
- $('#metrics').innerHTML = items.map(([label, value, sub]) => `<article class="metric"><span>${label}</span><strong>${esc(value)}</strong><small>${sub}</small></article>`).join('');
+
+function applyPermissions() {
+ $$('[data-permission]').forEach(node => node.classList.toggle('hidden', !can(node.dataset.permission)));
+ $('#billing-tab').classList.toggle('hidden', !state.overview.billing_enabled || !can('billing.read'));
+ $('#topup-form').classList.toggle('hidden', !state.overview.stripe_enabled || !can('billing.topup'));
+ const active = $('.tab.active'); if (active?.classList.contains('hidden')) $('.tab[data-section="overview"]').click();
}
-function renderTenants() { $('#tenants-body').innerHTML = state.tenants.map(item => `<tr><td><strong>${esc(item.name)}</strong></td><td><code>${esc(item.slug)}</code></td><td><span class="badge ${item.status}">${esc(item.status)}</span></td><td>${date(item.created_at)}</td></tr>`).join('') || emptyRow(4); $('#project-tenant').innerHTML = selectOptions(state.tenants, 'id', 'name'); $('#key-tenant').innerHTML = selectOptions(state.tenants, 'id', 'name'); }
-function renderProjects() { $('#projects-body').innerHTML = state.projects.map(item => `<tr><td><strong>${esc(item.name)}</strong></td><td><code>${esc(item.tenant_id).slice(0, 8)}…</code></td><td>${esc(item.slug)}</td><td><span class="badge ${item.status}">${esc(item.status)}</span></td></tr>`).join('') || emptyRow(4); renderKeyProjects(); }
-function renderKeyProjects() { const tenant = $('#key-tenant').value; const projects = state.projects.filter(item => !tenant || item.tenant_id === tenant); $('#key-project').innerHTML = selectOptions(projects, 'id', 'name'); }
-function renderKeys() { $('#keys-body').innerHTML = state.keys.map(item => `<tr><td><strong>${esc(item.name)}</strong></td><td><code>${esc(item.key_prefix)}</code></td><td><code>${esc(item.project_id).slice(0, 8)}…</code></td><td>${(item.scopes || []).map(scope => `<span class="tag">${esc(scope)}</span>`).join('')}</td><td><span class="badge ${item.status}">${esc(item.status)}</span></td><td>${item.status === 'active' ? `<button class="text-button danger" data-revoke-key="${esc(item.id)}">Revoke</button>` : ''}</td></tr>`).join('') || emptyRow(6); }
-function renderProviders() { $('#providers-body').innerHTML = state.providers.map(item => `<tr><td><strong>${esc(item.name)}</strong></td><td><span class="tag">${esc(item.protocol)}</span></td><td class="truncate">${esc(item.base_url)}</td><td>${esc(item.route_count)}</td><td><span class="badge ${item.enabled ? 'active' : 'suspended'}">${item.enabled ? 'enabled' : 'disabled'}</span></td><td><button class="text-button" data-toggle-provider="${esc(item.id)}" data-enabled="${!item.enabled}">${item.enabled ? 'Disable' : 'Enable'}</button></td></tr>`).join('') || emptyRow(6); }
-function renderModels() { $('#models-body').innerHTML = state.models.map(item => `<tr><td><strong>${esc(item.public_id)}</strong></td><td>${esc(item.owned_by || '—')}</td><td><div class="route-list">${(item.routes || []).map(route => `<span>${esc(route.provider_name || route.provider_id).slice(0, 24)} → ${esc(route.upstream_model)} <em>p${route.priority} / w${route.weight}</em></span>`).join('')}</div></td><td><span class="badge ${item.enabled ? 'active' : 'suspended'}">${item.enabled ? 'enabled' : 'disabled'}</span></td><td><button class="text-button" data-toggle-model="${esc(item.id)}" data-enabled="${!item.enabled}">${item.enabled ? 'Disable' : 'Enable'}</button></td></tr>`).join('') || emptyRow(5); }
-function renderRouteEditor() { const current = $('#route-editor'); if (!current.children.length) addRoute(); $$('.route-provider').forEach(select => { const selected = select.value; select.innerHTML = selectOptions(state.providers.filter(item => item.enabled), 'id', 'name', 'Provider…'); select.value = selected; }); }
-function addRoute() { const wrapper = document.createElement('div'); wrapper.className = 'route-row'; wrapper.innerHTML = `<select class="route-provider" required></select><input class="route-upstream" required placeholder="Upstream model"><input class="route-priority" type="number" min="0" value="0" title="Priority"><input class="route-weight" type="number" min="1" max="100" value="100" title="Weight"><button class="icon-button remove-route" type="button" aria-label="Remove route">×</button>`; $('#route-editor').appendChild(wrapper); renderRouteEditor(); }
-function emptyRow(span) { return `<tr><td colspan="${span}" class="empty">No records yet</td></tr>`; }
+function renderAll() {
+ applyPermissions(); renderOverview(); renderTenants(); renderProjects(); renderKeys(); renderProviders(); renderModels(); renderBilling();
+ renderUsage(); renderLimits(); renderUsers(); renderAudit(); renderRouteEditor();
+}
+function renderOverview() {
+ const data = state.overview; const propagation = !data.redis_configured ? 'PG polling' : data.redis_connected ? 'Redis live' : 'PG fallback';
+ const now = new Date(); const current = state.usageSummary.filter(item => { const period = new Date(item.period_start); return period.getUTCMonth() === now.getUTCMonth() && period.getUTCFullYear() === now.getUTCFullYear(); });
+ const totals = current.reduce((acc,item) => { acc.requests += item.request_count; acc.success += item.successful_requests; acc.tokens += item.total_tokens; acc.cost += item.cost_micros; return acc; }, {requests:0,success:0,tokens:0,cost:0});
+ const items = [['Requests', integer(totals.requests), 'this month'], ['Success rate', percent(totals.success,totals.requests), 'completed requests'], ['Tokens', integer(totals.tokens), 'this month'], ['Spend', money(totals.cost), 'metered cost'], ['Projects', data.projects, 'active workspaces'], ['Propagation', propagation, `generation ${data.runtime_generation}`]];
+ $('#metrics').innerHTML = items.map(([label,value,sub]) => `<article class="metric"><span>${label}</span><strong>${esc(value)}</strong><small>${esc(sub)}</small></article>`).join('');
+ $('#overview-usage-body').innerHTML = current.map(item => `<tr><td><strong>${esc(item.project_name)}</strong></td><td>${integer(item.request_count)}</td><td>${percent(item.successful_requests,item.request_count)}</td><td>${integer(item.total_tokens)}</td><td>${money(item.cost_micros)}</td><td class="${item.uncollected_micros ? 'money-negative':''}">${money(item.uncollected_micros)}</td></tr>`).join('') || emptyRow(6);
+}
+function renderTenants() {
+ $('#tenants-body').innerHTML = state.tenants.map(item => `<tr><td><strong>${esc(item.name)}</strong></td><td><code>${esc(item.slug)}</code></td><td><span class="badge ${item.status}">${esc(item.status)}</span></td><td>${date(item.created_at)}</td></tr>`).join('') || emptyRow(4);
+ ['project-tenant','key-tenant','topup-tenant','adjustment-tenant','user-tenant'].forEach(id => { const node=$(`#${id}`); if (node) node.innerHTML=selectOptions(state.tenants,'id','name', state.actor.tenant_id ? 'Current tenant' : 'Select tenant…'); });
+ if (state.actor.tenant_id) ['project-tenant','key-tenant','topup-tenant','adjustment-tenant','user-tenant'].forEach(id => { const node=$(`#${id}`); if (node) node.value=state.actor.tenant_id; });
+}
+function renderProjects() { $('#projects-body').innerHTML = state.projects.map(item => `<tr><td><strong>${esc(item.name)}</strong></td><td><code>${shortID(item.tenant_id)}</code></td><td>${esc(item.slug)}</td><td><span class="badge ${item.status}">${esc(item.status)}</span></td></tr>`).join('') || emptyRow(4); renderKeyProjects(); }
+function renderKeyProjects() { const tenant = $('#key-tenant').value; const projects = state.projects.filter(item => !tenant || item.tenant_id === tenant); $('#key-project').innerHTML = selectOptions(projects,'id','name'); }
+function renderKeys() { $('#keys-body').innerHTML = state.keys.map(item => `<tr><td><strong>${esc(item.name)}</strong></td><td><code>${esc(item.key_prefix)}</code></td><td><code>${shortID(item.project_id)}</code></td><td>${(item.scopes||[]).map(scope=>`<span class="tag">${esc(scope)}</span>`).join('')}</td><td><span class="badge ${item.status}">${esc(item.status)}</span></td><td>${item.status==='active'&&can('keys.write')?`<button class="text-button danger" data-revoke-key="${esc(item.id)}">Revoke</button>`:''}</td></tr>`).join('') || emptyRow(6); }
+function renderProviders() { $('#providers-body').innerHTML = state.providers.map(item => `<tr><td><strong>${esc(item.name)}</strong></td><td><span class="tag">${esc(item.protocol)}</span></td><td class="truncate">${esc(item.base_url)}</td><td>${integer(item.route_count)}</td><td><span class="badge ${item.enabled?'active':'suspended'}">${item.enabled?'enabled':'disabled'}</span></td><td>${can('platform.write')?`<button class="text-button" data-toggle-provider="${esc(item.id)}" data-enabled="${!item.enabled}">${item.enabled?'Disable':'Enable'}</button>`:''}</td></tr>`).join('') || emptyRow(6); }
+function renderModels() { $('#models-body').innerHTML = state.models.map(item => `<tr><td><strong>${esc(item.public_id)}</strong></td><td>${esc(item.owned_by||'—')}<small class="price-line">in ${money(item.input_price_micros_per_million)}/1M · out ${money(item.output_price_micros_per_million)}/1M</small></td><td><div class="route-list">${(item.routes||[]).map(route=>`<span>${esc(route.provider_name||route.provider_id).slice(0,24)} → ${esc(route.upstream_model)} <em>p${route.priority} / w${route.weight}</em></span>`).join('')}</div></td><td><span class="badge ${item.enabled?'active':'suspended'}">${item.enabled?'enabled':'disabled'}</span></td><td>${can('platform.write')?`<button class="text-button" data-toggle-model="${esc(item.id)}" data-enabled="${!item.enabled}">${item.enabled?'Disable':'Enable'}</button>`:''}</td></tr>`).join('') || emptyRow(5); }
+function renderBilling() {
+ $('#billing-currency').textContent=(state.overview.billing_currency||'').toUpperCase();
+ $('#billing-accounts-body').innerHTML=state.billingAccounts.map(item=>`<tr><td><strong>${esc(item.tenant_name)}</strong><br><code>${shortID(item.tenant_id)}</code></td><td>${money(item.balance_micros,item.currency)}</td><td>${money(item.reserved_micros,item.currency)}</td><td><strong>${money(item.available_micros,item.currency)}</strong></td><td>${date(item.updated_at)}</td></tr>`).join('')||emptyRow(5);
+ $('#billing-ledger-body').innerHTML=state.ledger.map(item=>`<tr><td>${date(item.created_at)}</td><td><code>${shortID(item.tenant_id)}</code></td><td><span class="tag">${esc(item.kind)}</span></td><td class="${item.amount_micros>=0?'money-positive':'money-negative'}">${money(item.amount_micros,item.currency)}</td><td>${money(item.balance_after_micros,item.currency)}</td><td title="${esc(item.description)}"><code>${shortID(item.source_id)}</code></td></tr>`).join('')||emptyRow(6);
+}
+function renderUsage() {
+ $('#usage-summary-body').innerHTML=state.usageSummary.map(item=>`<tr><td>${new Date(item.period_start).toLocaleDateString(undefined,{year:'numeric',month:'short'})}</td><td><strong>${esc(item.project_name)}</strong></td><td>${integer(item.request_count)}</td><td>${percent(item.successful_requests,item.request_count)}</td><td>${integer(item.input_tokens)}</td><td>${integer(item.output_tokens)}</td><td>${money(item.cost_micros)}</td></tr>`).join('')||emptyRow(7);
+ $('#usage-events-body').innerHTML=state.usage.map(item=>`<tr><td>${date(item.started_at)}</td><td><code title="${esc(item.request_id)}">${shortID(item.request_id)}</code></td><td>${esc(item.public_model)}</td><td><span class="badge ${item.success?'active':'suspended'}">${item.status_code}</span>${item.error_type?`<small class="error-label">${esc(item.error_type)}</small>`:''}</td><td>${integer(item.total_tokens)}</td><td>${money(item.cost_micros)}</td><td>${integer(item.duration_ms)} ms</td></tr>`).join('')||emptyRow(7);
+}
+function renderLimits() {
+ const existing=new Map(state.limits.map(item=>[item.project_id,item]));
+ $('#limits-body').innerHTML=state.projects.map(project=>{const item=existing.get(project.id)||{};return `<tr data-limit-project="${esc(project.id)}"><td><strong>${esc(project.name)}</strong><br><code>${shortID(project.id)}</code></td><td><input class="limit-rpm" type="number" min="0" value="${item.requests_per_minute||0}" ${can('limits.write')?'':'disabled'}></td><td><input class="limit-tpm" type="number" min="0" value="${item.tokens_per_minute||0}" ${can('limits.write')?'':'disabled'}></td><td><input class="limit-concurrency" type="number" min="0" value="${item.concurrent_requests||0}" ${can('limits.write')?'':'disabled'}></td><td><input class="limit-spend" inputmode="decimal" value="${scaledToDecimal(item.monthly_spend_micros||0,6)}" ${can('limits.write')?'':'disabled'}></td><td>${can('limits.write')?'<button class="button secondary save-limit">Save</button>':''}</td></tr>`}).join('')||emptyRow(6);
+}
+function renderUsers() {
+ const roles = state.actor.tenant_id ? [['tenant_admin','Tenant admin'],['tenant_billing','Billing'],['tenant_developer','Developer'],['tenant_viewer','Viewer']] : [['platform_admin','Platform admin'],['platform_viewer','Platform viewer'],['tenant_admin','Tenant admin'],['tenant_billing','Billing'],['tenant_developer','Developer'],['tenant_viewer','Viewer']];
+ $('#user-role').innerHTML=roles.map(([value,label])=>`<option value="${value}">${label}</option>`).join('');
+ $('#users-body').innerHTML=state.users.map(item=>`<tr><td><strong>${esc(item.display_name)}</strong><br><span class="muted">${esc(item.email)}</span></td><td><span class="tag">${esc(item.role.replaceAll('_',' '))}</span></td><td><code>${shortID(item.tenant_id)}</code></td><td><code>${esc(item.token_prefix)}</code></td><td>${date(item.last_used_at)}</td><td><span class="badge ${item.status}">${esc(item.status)}</span></td><td>${item.status==='active'&&can('users.write')?`<button class="text-button danger" data-revoke-user="${esc(item.id)}">Revoke</button>`:''}</td></tr>`).join('')||emptyRow(7);
+}
+function renderAudit() { $('#audit-body').innerHTML=state.audit.map(item=>`<tr><td>${date(item.created_at)}</td><td><span class="tag">${esc(item.actor_role.replaceAll('_',' '))}</span></td><td>${esc(item.action)}</td><td><code>${esc(item.method)}</code></td><td><span class="badge ${item.status_code<400?'active':'suspended'}">${item.status_code}</span></td><td><code>${shortID(item.request_id)}</code></td><td><code>${esc(item.remote_ip||'—')}</code></td></tr>`).join('')||emptyRow(7); }
+function renderRouteEditor() { const current=$('#route-editor');if(!current.children.length&&can('platform.write'))addRoute();$$('.route-provider').forEach(select=>{const selected=select.value;select.innerHTML=selectOptions(state.providers.filter(item=>item.enabled),'id','name','Provider…');select.value=selected;}); }
+function addRoute() { const wrapper=document.createElement('div');wrapper.className='route-row';wrapper.innerHTML='<select class="route-provider" required></select><input class="route-upstream" required placeholder="Upstream model"><input class="route-priority" type="number" min="0" value="0" title="Priority"><input class="route-weight" type="number" min="1" max="100" value="100" title="Weight"><button class="icon-button remove-route" type="button" aria-label="Remove route">×</button>';$('#route-editor').appendChild(wrapper);renderRouteEditor(); }
-document.addEventListener('click', async (event) => {
- const tab = event.target.closest('.tab'); if (tab) { $$('.tab').forEach(node => node.classList.toggle('active', node === tab)); $$('.section').forEach(node => node.classList.toggle('active', node.id === tab.dataset.section)); return; }
- if (event.target.id === 'reload') { try { await api('/reload', { method: 'POST', body: '{}' }); await loadAll(); toast('Snapshot reloaded'); } catch (error) { toast(error.message, true); } }
- if (event.target.id === 'add-route') addRoute();
- if (event.target.closest('.remove-route')) { event.target.closest('.route-row').remove(); }
- const revoke = event.target.closest('[data-revoke-key]'); if (revoke && confirm('Revoke this API key?')) { try { await api(`/keys/${revoke.dataset.revokeKey}/revoke`, { method: 'POST', body: '{}' }); await loadAll(); toast('API key revoked'); } catch (error) { toast(error.message, true); } }
- const provider = event.target.closest('[data-toggle-provider]'); if (provider) { try { await api(`/providers/${provider.dataset.toggleProvider}/toggle`, { method: 'POST', body: JSON.stringify({ enabled: provider.dataset.enabled === 'true' }) }); await loadAll(); toast('Provider updated'); } catch (error) { toast(error.message, true); } }
- const model = event.target.closest('[data-toggle-model]'); if (model) { try { await api(`/models/${model.dataset.toggleModel}/toggle`, { method: 'POST', body: JSON.stringify({ enabled: model.dataset.enabled === 'true' }) }); await loadAll(); toast('Model updated'); } catch (error) { toast(error.message, true); } }
+document.addEventListener('click',async(event)=>{
+ const tab=event.target.closest('.tab');if(tab){$$('.tab').forEach(node=>node.classList.toggle('active',node===tab));$$('.section').forEach(node=>node.classList.toggle('active',node.id===tab.dataset.section));return;}
+ if(event.target.id==='reload'){try{await api('/reload',{method:'POST',body:'{}'});await loadAll();toast('Snapshot reloaded');}catch(error){toast(error.message,true);}}
+ if(event.target.id==='add-route')addRoute();if(event.target.closest('.remove-route'))event.target.closest('.route-row').remove();
+ const revokeKey=event.target.closest('[data-revoke-key]');if(revokeKey&&confirm('Revoke this API key?')){try{await api(`/keys/${revokeKey.dataset.revokeKey}/revoke`,{method:'POST',body:'{}'});await loadAll();toast('API key revoked');}catch(error){toast(error.message,true);}}
+ const revokeUser=event.target.closest('[data-revoke-user]');if(revokeUser&&confirm('Revoke this console credential?')){try{await api(`/users/${revokeUser.dataset.revokeUser}/revoke`,{method:'POST',body:'{}'});await loadAll();toast('Console credential revoked');}catch(error){toast(error.message,true);}}
+ const provider=event.target.closest('[data-toggle-provider]');if(provider){try{await api(`/providers/${provider.dataset.toggleProvider}/toggle`,{method:'POST',body:JSON.stringify({enabled:provider.dataset.enabled==='true'})});await loadAll();toast('Provider updated');}catch(error){toast(error.message,true);}}
+ const model=event.target.closest('[data-toggle-model]');if(model){try{await api(`/models/${model.dataset.toggleModel}/toggle`,{method:'POST',body:JSON.stringify({enabled:model.dataset.enabled==='true'})});await loadAll();toast('Model updated');}catch(error){toast(error.message,true);}}
+ const save=event.target.closest('.save-limit');if(save){const row=save.closest('[data-limit-project]');try{await api(`/limits/${row.dataset.limitProject}`,{method:'POST',body:JSON.stringify({requests_per_minute:Number(row.querySelector('.limit-rpm').value),tokens_per_minute:Number(row.querySelector('.limit-tpm').value),concurrent_requests:Number(row.querySelector('.limit-concurrency').value),monthly_spend_micros:decimalToScaled(row.querySelector('.limit-spend').value,6)})});await loadAll();toast('Project limits updated');}catch(error){toast(error.message,true);}}
});
-$('#key-tenant').addEventListener('change', renderKeyProjects);
-$('#session-form').addEventListener('submit', async (event) => { event.preventDefault(); state.token = $('#admin-token').value.trim(); sessionStorage.setItem('aigw_admin_token', state.token); await loadAll(); });
-$('#tenant-form').addEventListener('submit', async (event) => { event.preventDefault(); try { await api('/tenants', { method: 'POST', body: JSON.stringify(formJSON(event.target)) }); event.target.reset(); await loadAll(); toast('Tenant created'); } catch (error) { toast(error.message, true); } });
-$('#project-form').addEventListener('submit', async (event) => { event.preventDefault(); try { await api('/projects', { method: 'POST', body: JSON.stringify(formJSON(event.target)) }); event.target.reset(); await loadAll(); toast('Project created'); } catch (error) { toast(error.message, true); } });
-$('#key-form').addEventListener('submit', async (event) => { event.preventDefault(); try { const data = formJSON(event.target); data.scopes = data.scopes.split(',').map(value => value.trim()).filter(Boolean); const result = await api('/keys', { method: 'POST', body: JSON.stringify(data) }); event.target.reset(); $('#created-secret').textContent = result.key; $('#secret-dialog').showModal(); await loadAll(); } catch (error) { toast(error.message, true); } });
-$('#provider-form').addEventListener('submit', async (event) => { event.preventDefault(); try { await api('/providers', { method: 'POST', body: JSON.stringify(formJSON(event.target)) }); event.target.reset(); await loadAll(); toast('Provider added'); } catch (error) { toast(error.message, true); } });
-$('#model-form').addEventListener('submit', async (event) => { event.preventDefault(); try { const data = formJSON(event.target); data.routes = $$('.route-row').map(row => ({ provider_id: row.querySelector('.route-provider').value, upstream_model: row.querySelector('.route-upstream').value, priority: Number(row.querySelector('.route-priority').value), weight: Number(row.querySelector('.route-weight').value) })); await api('/models', { method: 'POST', body: JSON.stringify(data) }); event.target.reset(); $('#route-editor').innerHTML = ''; renderRouteEditor(); await loadAll(); toast('Model created'); } catch (error) { toast(error.message, true); } });
-$('#close-dialog').addEventListener('click', () => $('#secret-dialog').close());
-$('#copy-secret').addEventListener('click', async () => { await navigator.clipboard.writeText($('#created-secret').textContent); toast('Key copied'); });
-$('#admin-token').value = state.token;
-if (state.token) loadAll();
+
+$('#key-tenant').addEventListener('change',renderKeyProjects);
+$('#session-form').addEventListener('submit',async(event)=>{event.preventDefault();state.token=$('#admin-token').value.trim();sessionStorage.setItem('aigw_admin_token',state.token);await loadAll();});
+$('#tenant-form').addEventListener('submit',async(event)=>{event.preventDefault();try{await api('/tenants',{method:'POST',body:JSON.stringify(formJSON(event.target))});event.target.reset();await loadAll();toast('Tenant created');}catch(error){toast(error.message,true);}});
+$('#project-form').addEventListener('submit',async(event)=>{event.preventDefault();try{await api('/projects',{method:'POST',body:JSON.stringify(formJSON(event.target))});event.target.reset();await loadAll();toast('Project created');}catch(error){toast(error.message,true);}});
+$('#key-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);data.scopes=data.scopes.split(',').map(value=>value.trim()).filter(Boolean);const result=await api('/keys',{method:'POST',body:JSON.stringify(data)});event.target.reset();showSecret('API key created',result.key);await loadAll();}catch(error){toast(error.message,true);}});
+$('#provider-form').addEventListener('submit',async(event)=>{event.preventDefault();try{await api('/providers',{method:'POST',body:JSON.stringify(formJSON(event.target))});event.target.reset();await loadAll();toast('Provider added');}catch(error){toast(error.message,true);}});
+$('#model-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);data.input_price_micros_per_million=decimalToScaled(data.input_price,6);data.output_price_micros_per_million=decimalToScaled(data.output_price,6);data.cache_read_price_micros_per_million=decimalToScaled(data.cache_read_price,6);data.cache_write_price_micros_per_million=decimalToScaled(data.cache_write_price,6);delete data.input_price;delete data.output_price;delete data.cache_read_price;delete data.cache_write_price;data.routes=$$('.route-row').map(row=>({provider_id:row.querySelector('.route-provider').value,upstream_model:row.querySelector('.route-upstream').value,priority:Number(row.querySelector('.route-priority').value),weight:Number(row.querySelector('.route-weight').value)}));await api('/models',{method:'POST',body:JSON.stringify(data)});event.target.reset();$('#route-editor').innerHTML='';renderRouteEditor();await loadAll();toast('Model created');}catch(error){toast(error.message,true);}});
+$('#topup-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);const digits=currencyDigits(state.overview.billing_currency||'usd');const result=await api('/billing/checkout-sessions',{method:'POST',body:JSON.stringify({tenant_id:data.tenant_id,amount_minor:decimalToScaled(data.amount,digits)})});window.location.assign(result.url);}catch(error){toast(error.message,true);}});
+$('#adjustment-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);await api('/billing/adjustments',{method:'POST',body:JSON.stringify({tenant_id:data.tenant_id,amount_micros:decimalToScaled(data.amount,6),description:data.description})});event.target.reset();await loadAll();toast('Balance adjusted');}catch(error){toast(error.message,true);}});
+$('#user-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const result=await api('/users',{method:'POST',body:JSON.stringify(formJSON(event.target))});event.target.reset();showSecret('Console token issued',result.token);await loadAll();}catch(error){toast(error.message,true);}});
+$('#close-dialog').addEventListener('click',()=>$('#secret-dialog').close());$('#copy-secret').addEventListener('click',async()=>{await navigator.clipboard.writeText($('#created-secret').textContent);toast('Credential copied');});
+$('#admin-token').value=state.token;applyPermissions();if(state.token)loadAll();
diff --git a/internal/adminui/assets/index.html b/internal/adminui/assets/index.html
index 7869855..c299037 100644
--- a/internal/adminui/assets/index.html
+++ b/internal/adminui/assets/index.html
@@ -10,57 +10,98 @@
<body>
<header class="topbar">
<div class="brand"><span class="brand-mark">A</span><div><strong>AIGW</strong><small>CONTROL PLANE</small></div></div>
- <form class="session" id="session-form"><input id="admin-token" name="admin-token" type="password" placeholder="Admin token" autocomplete="current-password" aria-label="Admin token"><button type="submit">Connect</button><span id="connection-state" class="state">Offline</span></form>
+ <form class="session" id="session-form"><span id="actor-label" class="actor-label"></span><input class="visually-hidden" name="username" autocomplete="username" value="aigw-console" aria-hidden="true" tabindex="-1"><input id="admin-token" name="admin-token" type="password" placeholder="Console token" autocomplete="current-password" aria-label="Console token"><button type="submit">Connect</button><span id="connection-state" class="state">Offline</span></form>
</header>
<main class="shell">
<nav class="tabs" aria-label="Admin sections">
<button class="tab active" data-section="overview">Overview</button>
- <button class="tab" data-section="tenants">Tenants</button>
- <button class="tab" data-section="projects">Projects</button>
- <button class="tab" data-section="keys">API keys</button>
- <button class="tab" data-section="providers">Providers</button>
- <button class="tab" data-section="models">Models & routes</button>
+ <button class="tab" data-section="usage" data-permission="usage.read">Usage</button>
+ <button class="tab" data-section="billing" data-permission="billing.read" id="billing-tab">Billing</button>
+ <button class="tab" data-section="projects" data-permission="projects.read">Projects</button>
+ <button class="tab" data-section="keys" data-permission="keys.read">API keys</button>
+ <button class="tab" data-section="limits" data-permission="limits.read">Limits</button>
+ <button class="tab" data-section="team" data-permission="users.read">Team</button>
+ <button class="tab" data-section="audit" data-permission="audit.read">Audit</button>
+ <button class="tab" data-section="tenants" data-permission="tenants.read">Tenants</button>
+ <button class="tab" data-section="providers" data-permission="platform.read">Providers</button>
+ <button class="tab" data-section="models" data-permission="platform.read">Models & routes</button>
</nav>
<section id="overview" class="section active">
- <div class="section-heading"><div><span class="eyebrow">OPERATIONS</span><h1>Control plane overview</h1></div><button class="button secondary" id="reload">Reload snapshot</button></div>
+ <div class="section-heading"><div><span class="eyebrow">OPERATIONS</span><h1>Account overview</h1></div><button class="button secondary" id="reload" data-permission="platform.write">Reload snapshot</button></div>
<div class="metric-grid" id="metrics"></div>
- <div class="panel note"><div class="note-icon">i</div><div><strong>Runtime snapshot</strong><p>Writes commit to PostgreSQL and apply locally first. Redis accelerates propagation when available; PostgreSQL polling keeps every gateway convergent.</p></div></div>
+ <div class="section-heading ledger-heading"><div><span class="eyebrow">CURRENT PERIOD</span><h2>Project usage</h2></div></div>
+ <div class="panel table-wrap"><table><thead><tr><th>Project</th><th>Requests</th><th>Success</th><th>Tokens</th><th>Cost</th><th>Uncollected</th></tr></thead><tbody id="overview-usage-body"></tbody></table></div>
+ </section>
+
+ <section id="usage" class="section">
+ <div class="section-heading"><div><span class="eyebrow">METERING</span><h1>Usage ledger</h1></div></div>
+ <div class="panel table-wrap"><table><thead><tr><th>Period</th><th>Project</th><th>Requests</th><th>Success</th><th>Input</th><th>Output</th><th>Cost</th></tr></thead><tbody id="usage-summary-body"></tbody></table></div>
+ <div class="section-heading ledger-heading"><div><span class="eyebrow">REQUESTS</span><h2>Recent events</h2></div></div>
+ <div class="panel table-wrap"><table><thead><tr><th>Time</th><th>Request</th><th>Model</th><th>Status</th><th>Tokens</th><th>Cost</th><th>Latency</th></tr></thead><tbody id="usage-events-body"></tbody></table></div>
</section>
<section id="tenants" class="section">
<div class="section-heading"><div><span class="eyebrow">IDENTITY</span><h1>Tenants</h1></div></div>
- <form class="panel form-grid" id="tenant-form"><label>Slug<input name="slug" required pattern="[a-z0-9][a-z0-9-]{1,62}[a-z0-9]" placeholder="acme"></label><label>Name<input name="name" required placeholder="Acme Inc."></label><button class="button primary" type="submit">Create tenant</button></form>
+ <form class="panel form-grid" id="tenant-form" data-permission="tenants.write"><label>Slug<input name="slug" required pattern="[a-z0-9][a-z0-9-]{1,62}[a-z0-9]" placeholder="acme"></label><label>Name<input name="name" required placeholder="Acme Inc."></label><button class="button primary" type="submit">Create tenant</button></form>
<div class="panel table-wrap"><table><thead><tr><th>Name</th><th>Slug</th><th>Status</th><th>Created</th></tr></thead><tbody id="tenants-body"></tbody></table></div>
</section>
<section id="projects" class="section">
<div class="section-heading"><div><span class="eyebrow">IDENTITY</span><h1>Projects</h1></div></div>
- <form class="panel form-grid" id="project-form"><label>Tenant<select name="tenant_id" id="project-tenant" required></select></label><label>Slug<input name="slug" required placeholder="production"></label><label>Name<input name="name" required placeholder="Production API"></label><button class="button primary" type="submit">Create project</button></form>
+ <form class="panel form-grid" id="project-form" data-permission="projects.write"><label>Tenant<select name="tenant_id" id="project-tenant" required></select></label><label>Slug<input name="slug" required placeholder="production"></label><label>Name<input name="name" required placeholder="Production API"></label><button class="button primary" type="submit">Create project</button></form>
<div class="panel table-wrap"><table><thead><tr><th>Name</th><th>Tenant</th><th>Slug</th><th>Status</th></tr></thead><tbody id="projects-body"></tbody></table></div>
</section>
<section id="keys" class="section">
<div class="section-heading"><div><span class="eyebrow">ACCESS</span><h1>API keys</h1></div></div>
- <form class="panel form-grid" id="key-form"><label>Tenant<select name="tenant_id" id="key-tenant" required></select></label><label>Project<select name="project_id" id="key-project" required></select></label><label>Name<input name="name" required placeholder="CLI production key"></label><label>Scopes<input name="scopes" value="inference" placeholder="inference,admin"></label><button class="button primary" type="submit">Create key</button></form>
+ <form class="panel form-grid" id="key-form" data-permission="keys.write"><label>Tenant<select name="tenant_id" id="key-tenant" required></select></label><label>Project<select name="project_id" id="key-project" required></select></label><label>Name<input name="name" required placeholder="CLI production key"></label><label>Scopes<input name="scopes" value="inference" placeholder="inference"></label><button class="button primary" type="submit">Create key</button></form>
<div class="panel warning"><strong>Key visibility</strong><span>The secret is shown only once after creation.</span></div>
<div class="panel table-wrap"><table><thead><tr><th>Name</th><th>Prefix</th><th>Project</th><th>Scopes</th><th>Status</th><th></th></tr></thead><tbody id="keys-body"></tbody></table></div>
</section>
<section id="providers" class="section">
<div class="section-heading"><div><span class="eyebrow">UPSTREAMS</span><h1>Providers</h1></div></div>
- <form class="panel form-grid" id="provider-form"><label>Name<input name="name" required placeholder="openai-primary"></label><label>Protocol<select name="protocol"><option value="openai">OpenAI</option><option value="anthropic">Anthropic</option></select></label><label>Base URL<input name="base_url" type="url" required placeholder="https://api.example.com/v1"></label><label>API key<input name="api_key" type="password" required autocomplete="new-password" placeholder="Stored encrypted"></label><button class="button primary" type="submit">Add provider</button></form>
+ <form class="panel form-grid" id="provider-form" data-permission="platform.write"><input class="visually-hidden" autocomplete="username" value="aigw-provider" aria-hidden="true" tabindex="-1"><label>Name<input name="name" required placeholder="openai-primary"></label><label>Protocol<select name="protocol"><option value="openai">OpenAI</option><option value="anthropic">Anthropic</option></select></label><label>Base URL<input name="base_url" type="url" required placeholder="https://api.example.com/v1"></label><label>API key<input name="api_key" type="password" required autocomplete="new-password" placeholder="Stored encrypted"></label><button class="button primary" type="submit">Add provider</button></form>
<div class="panel table-wrap"><table><thead><tr><th>Name</th><th>Protocol</th><th>Base URL</th><th>Routes</th><th>Status</th><th></th></tr></thead><tbody id="providers-body"></tbody></table></div>
</section>
<section id="models" class="section">
<div class="section-heading"><div><span class="eyebrow">ROUTING</span><h1>Models & routes</h1></div></div>
- <form class="panel form-grid" id="model-form"><label>Public model ID<input name="public_id" required placeholder="openai/gpt-4.1-mini"></label><label>Owned by<input name="owned_by" placeholder="openai"></label><div class="route-editor" id="route-editor"></div><button class="button subtle" type="button" id="add-route">Add route</button><button class="button primary" type="submit">Create model</button></form>
+ <form class="panel form-grid" id="model-form" data-permission="platform.write"><label>Public model ID<input name="public_id" required placeholder="openai/gpt-4.1-mini"></label><label>Owned by<input name="owned_by" placeholder="openai"></label><label>Input price / 1M<input name="input_price" inputmode="decimal" value="0" required></label><label>Output price / 1M<input name="output_price" inputmode="decimal" value="0" required></label><label>Cache read / 1M<input name="cache_read_price" inputmode="decimal" value="0" required></label><label>Cache write / 1M<input name="cache_write_price" inputmode="decimal" value="0" required></label><div class="route-editor" id="route-editor"></div><button class="button subtle" type="button" id="add-route">Add route</button><button class="button primary" type="submit">Create model</button></form>
<div class="panel table-wrap"><table><thead><tr><th>Public ID</th><th>Owner</th><th>Routes</th><th>Status</th><th></th></tr></thead><tbody id="models-body"></tbody></table></div>
</section>
+
+ <section id="billing" class="section">
+ <div class="section-heading"><div><span class="eyebrow">REVENUE</span><h1>Balances & ledger</h1></div><span class="currency-label" id="billing-currency"></span></div>
+ <div class="billing-actions">
+ <form class="panel form-grid compact-form" id="topup-form" data-permission="billing.topup"><label>Tenant<select name="tenant_id" id="topup-tenant" required></select></label><label>Amount<input name="amount" inputmode="decimal" min="0" required placeholder="25.00"></label><button class="button primary" type="submit">Open Stripe Checkout</button></form>
+ <form class="panel form-grid compact-form" id="adjustment-form" data-permission="billing.adjust"><label>Tenant<select name="tenant_id" id="adjustment-tenant" required></select></label><label>Signed amount<input name="amount" inputmode="decimal" required placeholder="10.00 or -5.00"></label><label>Reference<input name="description" maxlength="240" placeholder="Support credit"></label><button class="button secondary" type="submit">Post adjustment</button></form>
+ </div>
+ <div class="panel table-wrap"><table><thead><tr><th>Tenant</th><th>Balance</th><th>Reserved</th><th>Available</th><th>Updated</th></tr></thead><tbody id="billing-accounts-body"></tbody></table></div>
+ <div class="section-heading ledger-heading"><div><span class="eyebrow">AUDIT</span><h2>Recent ledger entries</h2></div></div>
+ <div class="panel table-wrap"><table><thead><tr><th>Time</th><th>Tenant</th><th>Kind</th><th>Amount</th><th>Balance after</th><th>Reference</th></tr></thead><tbody id="billing-ledger-body"></tbody></table></div>
+ </section>
+
+ <section id="limits" class="section">
+ <div class="section-heading"><div><span class="eyebrow">GUARDRAILS</span><h1>Project limits</h1></div></div>
+ <div class="panel table-wrap"><table class="limits-table"><thead><tr><th>Project</th><th>Requests / min</th><th>Estimated tokens / min</th><th>Concurrent</th><th>Monthly spend</th><th></th></tr></thead><tbody id="limits-body"></tbody></table></div>
+ </section>
+
+ <section id="team" class="section">
+ <div class="section-heading"><div><span class="eyebrow">RBAC</span><h1>Console access</h1></div></div>
+ <form class="panel form-grid" id="user-form" data-permission="users.write"><label>Tenant<select name="tenant_id" id="user-tenant"></select></label><label>Email<input name="email" type="email" required autocomplete="email" placeholder="operator@example.com"></label><label>Display name<input name="display_name" required placeholder="Operations"></label><label>Role<select name="role" id="user-role" required></select></label><button class="button primary" type="submit">Issue console token</button></form>
+ <div class="panel warning"><strong>Token visibility</strong><span>The console token is shown only once after creation.</span></div>
+ <div class="panel table-wrap"><table><thead><tr><th>User</th><th>Role</th><th>Tenant</th><th>Token</th><th>Last used</th><th>Status</th><th></th></tr></thead><tbody id="users-body"></tbody></table></div>
+ </section>
+
+ <section id="audit" class="section">
+ <div class="section-heading"><div><span class="eyebrow">SECURITY</span><h1>Audit log</h1></div></div>
+ <div class="panel table-wrap"><table><thead><tr><th>Time</th><th>Actor</th><th>Action</th><th>Method</th><th>Status</th><th>Request</th><th>IP</th></tr></thead><tbody id="audit-body"></tbody></table></div>
+ </section>
</main>
<div id="toast" class="toast" role="status"></div>
- <dialog id="secret-dialog"><div class="dialog-content"><div class="section-heading"><div><span class="eyebrow">ONE-TIME SECRET</span><h2>API key created</h2></div><button class="icon-button" id="close-dialog" aria-label="Close">×</button></div><p>Copy this key now. It will not be shown again.</p><code id="created-secret"></code><button class="button primary" id="copy-secret">Copy key</button></div></dialog>
+ <dialog id="secret-dialog"><div class="dialog-content"><div class="section-heading"><div><span class="eyebrow">ONE-TIME SECRET</span><h2 id="secret-title">Credential created</h2></div><button class="icon-button" id="close-dialog" aria-label="Close">×</button></div><p>Copy this credential now. It will not be shown again.</p><code id="created-secret"></code><button class="button primary" id="copy-secret">Copy credential</button></div></dialog>
<script src="./app.js" defer></script>
</body>
</html>
diff --git a/internal/adminui/assets/style.css b/internal/adminui/assets/style.css
index dadb019..b158d5b 100644
--- a/internal/adminui/assets/style.css
+++ b/internal/adminui/assets/style.css
@@ -1,13 +1,16 @@
:root { --bg:#f3f5f7; --panel:#fff; --ink:#18212b; --muted:#71808e; --line:#dce3e8; --accent:#146c94; --accent-soft:#e5f2f7; --danger:#b4494d; --shadow:0 8px 24px rgba(29,47,61,.06); font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
* { box-sizing:border-box; } body { margin:0; color:var(--ink); background:var(--bg); font-size:14px; } button,input,select { font:inherit; } button { cursor:pointer; }
-.topbar { height:72px; background:#102a3a; color:#fff; padding:0 32px; display:flex; align-items:center; justify-content:space-between; gap:24px; } .brand { display:flex; align-items:center; gap:11px; letter-spacing:0; } .brand-mark { width:32px; height:32px; display:grid; place-items:center; border:1px solid #8fd0df; color:#b8eef7; font-weight:800; } .brand strong { display:block; font-size:15px; } .brand small { color:#8ba9b9; font-size:9px; letter-spacing:0; } .session { display:flex; align-items:center; gap:8px; } .session input { width:220px; border:1px solid #3b5b6c; background:#18384b; color:#fff; padding:9px 11px; outline:none; } .session input::placeholder { color:#91acb9; } .session button { min-height:40px; border:1px solid #8fd0df; background:#b8eef7; color:#102a3a; padding:0 14px; font-weight:750; } .session button:hover { background:#d4f6fb; } .state { color:#9db0bb; font-size:12px; } .state.online { color:#86d5ad; }
-.shell { width:min(1240px,calc(100% - 48px)); margin:28px auto 60px; } .tabs { display:flex; gap:4px; border-bottom:1px solid var(--line); margin-bottom:26px; overflow:auto; } .tab { white-space:nowrap; border:0; background:transparent; color:var(--muted); padding:12px 15px; border-bottom:2px solid transparent; } .tab.active { color:var(--accent); border-bottom-color:var(--accent); font-weight:700; }
+.topbar { height:72px; background:#102a3a; color:#fff; padding:0 32px; display:flex; align-items:center; justify-content:space-between; gap:24px; } .brand { display:flex; align-items:center; gap:11px; letter-spacing:0; } .brand-mark { width:32px; height:32px; display:grid; place-items:center; border:1px solid #8fd0df; color:#b8eef7; font-weight:800; } .brand strong { display:block; font-size:15px; } .brand small { color:#8ba9b9; font-size:9px; letter-spacing:0; } .session { display:flex; align-items:center; gap:8px; } .session input { width:220px; border:1px solid #3b5b6c; background:#18384b; color:#fff; padding:9px 11px; outline:none; } .session input::placeholder { color:#91acb9; } .session button { min-height:40px; border:1px solid #8fd0df; background:#b8eef7; color:#102a3a; padding:0 14px; font-weight:750; } .session button:hover { background:#d4f6fb; } .state { color:#9db0bb; font-size:12px; text-transform:capitalize; } .state.online { color:#86d5ad; } .actor-label { max-width:190px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#c6d7df; font-size:12px; }
+.shell { width:min(1240px,calc(100% - 48px)); margin:28px auto 60px; } .tabs { display:flex; flex-wrap:wrap; gap:4px; border-bottom:1px solid var(--line); margin-bottom:26px; } .tab { white-space:nowrap; border:0; background:transparent; color:var(--muted); padding:12px 15px; border-bottom:2px solid transparent; } .tab.active { color:var(--accent); border-bottom-color:var(--accent); font-weight:700; }
.section { display:none; } .section.active { display:block; } .section-heading { display:flex; justify-content:space-between; align-items:flex-end; gap:20px; margin-bottom:19px; } .eyebrow { color:var(--accent); font-size:10px; letter-spacing:0; font-weight:800; } h1 { font-size:28px; line-height:1.1; margin:7px 0 0; letter-spacing:0; } h2 { margin:4px 0 0; font-size:20px; }
.metric-grid { display:grid; grid-template-columns:repeat(6,1fr); gap:12px; } .metric { background:var(--panel); border:1px solid var(--line); padding:18px; box-shadow:var(--shadow); } .metric span,.metric small { display:block; color:var(--muted); } .metric strong { display:block; font-size:28px; margin:12px 0 3px; font-weight:750; } .metric small { font-size:11px; }
.panel { background:var(--panel); border:1px solid var(--line); box-shadow:var(--shadow); padding:20px; margin-bottom:16px; } .note,.warning { display:flex; align-items:flex-start; gap:12px; } .note-icon { flex:0 0 22px; height:22px; border:1px solid var(--accent); color:var(--accent); display:grid; place-items:center; font-weight:700; } .note p { margin:5px 0 0; color:var(--muted); } .warning { color:#6d5523; background:#fff9e9; border-color:#ead9a9; box-shadow:none; } .warning span { margin-left:8px; color:#887650; }
.form-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); align-items:end; gap:13px; } label { display:flex; flex-direction:column; gap:7px; color:var(--muted); font-size:12px; font-weight:650; } input,select { width:100%; border:1px solid var(--line); background:#fff; color:var(--ink); padding:10px 11px; min-height:40px; outline:none; } input:focus,select:focus { border-color:#69a9bf; box-shadow:0 0 0 3px var(--accent-soft); } .button { border:1px solid transparent; min-height:40px; padding:0 15px; font-weight:700; } .button.primary { color:#fff; background:var(--accent); } .button.primary:hover { background:#0d5879; } .button.secondary { color:var(--accent); background:var(--accent-soft); border-color:#c5e1ea; } .button.subtle { color:var(--accent); background:#fff; border-color:var(--line); grid-column:1; }
.table-wrap { overflow:auto; padding:0; } table { width:100%; border-collapse:collapse; min-width:700px; } th,td { padding:14px 18px; text-align:left; border-bottom:1px solid var(--line); vertical-align:middle; } th { color:var(--muted); font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:0; background:#fbfcfd; } tbody tr:last-child td { border-bottom:0; } td { font-size:13px; } code { font-family:"SFMono-Regular",Consolas,monospace; font-size:12px; color:#486071; } .badge,.tag { display:inline-flex; align-items:center; padding:4px 7px; font-size:11px; line-height:1; } .badge { border:1px solid #d9e0e4; color:var(--muted); } .badge.active { color:#187151; background:#e9f7f0; border-color:#c7e9d9; } .badge.revoked,.badge.suspended { color:var(--danger); background:#fff0f0; border-color:#f0cccc; } .tag { color:#4c6572; background:#eef3f5; margin:2px 3px 2px 0; } .text-button { border:0; background:transparent; color:var(--accent); padding:5px 0; } .text-button.danger { color:var(--danger); } .empty { color:var(--muted); text-align:center; padding:32px; } .truncate { max-width:280px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.route-editor { grid-column:1/-1; display:flex; flex-direction:column; gap:8px; } .route-row { display:grid; grid-template-columns:1.4fr 1.4fr .6fr .6fr 34px; gap:8px; } .icon-button { border:1px solid var(--line); background:#fff; color:var(--muted); width:34px; height:34px; font-size:19px; } .route-list { display:flex; flex-direction:column; gap:3px; color:#486071; font-size:12px; } .route-list em { color:var(--muted); font-style:normal; margin-left:4px; }
+.hidden { display:none !important; } .visually-hidden { position:absolute !important; width:1px !important; height:1px !important; padding:0 !important; margin:-1px !important; overflow:hidden !important; clip:rect(0,0,0,0) !important; white-space:nowrap !important; border:0 !important; } .billing-actions { display:grid; grid-template-columns:1fr 1fr; gap:16px; } .compact-form { grid-template-columns:1fr 1fr; } .compact-form .button { grid-column:1/-1; } .currency-label { color:var(--muted); font-size:12px; text-transform:uppercase; } .ledger-heading { margin-top:28px; } .money-positive { color:#187151; } .money-negative { color:var(--danger); }
+.price-line { display:block; color:var(--muted); font-size:10px; margin-top:5px; white-space:nowrap; }
+.muted,.error-label { display:block; color:var(--muted); font-size:11px; margin-top:4px; } .error-label { color:var(--danger); } .limits-table input { min-width:118px; padding:8px 9px; } .limits-table .button { min-height:36px; } input:disabled,select:disabled { background:#f5f7f8; color:#697985; cursor:not-allowed; }
.toast { position:fixed; bottom:24px; right:24px; background:#102a3a; color:#fff; padding:12px 16px; opacity:0; transform:translateY(8px); pointer-events:none; transition:.2s; } .toast.visible { opacity:1; transform:none; } .toast.error { background:#8f3d42; } dialog { border:0; padding:0; width:min(460px,calc(100% - 32px)); box-shadow:0 18px 70px rgba(0,0,0,.22); } dialog::backdrop { background:rgba(16,42,58,.45); } .dialog-content { padding:24px; } .dialog-content p { color:var(--muted); } .dialog-content code { display:block; background:#f3f5f7; padding:15px; overflow:auto; color:var(--ink); margin:18px 0; }
-@media (max-width:900px) { .metric-grid { grid-template-columns:repeat(3,1fr); } .form-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } .form-grid .button.primary { grid-column:1/-1; } }
-@media (max-width:620px) { .topbar { height:auto; padding:16px; align-items:flex-start; flex-direction:column; } .session { width:100%; } .session input { flex:1; width:auto; min-width:0; } .shell { width:calc(100% - 24px); margin-top:18px; } .metric-grid { grid-template-columns:repeat(2,1fr); } .form-grid { grid-template-columns:1fr; } .route-row { grid-template-columns:minmax(0,1fr) minmax(0,1fr) 34px; } .route-provider,.route-upstream { grid-column:1/-1; } h1 { font-size:24px; } }
+@media (max-width:900px) { .metric-grid { grid-template-columns:repeat(3,1fr); } .form-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } .form-grid .button.primary { grid-column:1/-1; } .billing-actions { grid-template-columns:1fr; } }
+@media (max-width:620px) { .topbar { height:auto; padding:16px; align-items:flex-start; flex-direction:column; } .session { width:100%; display:grid; grid-template-columns:minmax(0,1fr) auto; } .session input { width:100%; min-width:0; } .session .actor-label,.session .state { grid-column:1/-1; } .shell { width:calc(100% - 24px); margin-top:18px; } .tabs { margin-bottom:20px; flex-wrap:nowrap; overflow:auto; } .metric-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } .metric { padding:14px; } .metric strong { font-size:22px; overflow-wrap:anywhere; } .form-grid { grid-template-columns:1fr; } .route-row { grid-template-columns:minmax(0,1fr) minmax(0,1fr) 34px; } .route-provider,.route-upstream { grid-column:1/-1; } h1 { font-size:24px; } .section-heading { align-items:flex-start; } }
diff --git a/internal/billing/ledger.go b/internal/billing/ledger.go
new file mode 100644
index 0000000..28cbe4c
--- /dev/null
+++ b/internal/billing/ledger.go
@@ -0,0 +1,171 @@
+package billing
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "math"
+ "strings"
+
+ "github.com/jackc/pgx/v5"
+)
+
+func (s *Service) ListAccounts(ctx context.Context, tenantID string) ([]Account, error) {
+ query := `
+ SELECT t.id::text, t.name, COALESCE(w.currency, $1), COALESCE(w.balance_micros, 0),
+ COALESCE(w.reserved_micros, 0), COALESCE(w.balance_micros - w.reserved_micros, 0),
+ COALESCE(w.updated_at, t.created_at)
+ FROM tenants t LEFT JOIN tenant_wallets w ON w.tenant_id = t.id`
+ args := []any{s.currency}
+ if strings.TrimSpace(tenantID) != "" {
+ query += ` WHERE t.id=$2`
+ args = append(args, tenantID)
+ }
+ query += ` ORDER BY t.name`
+ rows, err := s.db.Query(ctx, query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("query billing accounts: %w", err)
+ }
+ defer rows.Close()
+ result := make([]Account, 0)
+ for rows.Next() {
+ var item Account
+ if err := rows.Scan(&item.TenantID, &item.TenantName, &item.Currency, &item.BalanceMicros,
+ &item.ReservedMicros, &item.AvailableMicros, &item.UpdatedAt); err != nil {
+ return nil, fmt.Errorf("scan billing account: %w", err)
+ }
+ result = append(result, item)
+ }
+ return result, rows.Err()
+}
+
+func (s *Service) ListLedger(ctx context.Context, tenantID string, limit int) ([]LedgerEntry, error) {
+ if limit < 1 || limit > 500 {
+ limit = 200
+ }
+ query := `
+ SELECT id::text, tenant_id::text, COALESCE(project_id::text, ''), currency, amount_micros,
+ balance_after_micros, kind, source_type, source_id, description, created_at
+ FROM billing_ledger`
+ args := []any{}
+ if strings.TrimSpace(tenantID) != "" {
+ query += ` WHERE tenant_id = $1 ORDER BY created_at DESC LIMIT $2`
+ args = append(args, tenantID, limit)
+ } else {
+ query += ` ORDER BY created_at DESC LIMIT $1`
+ args = append(args, limit)
+ }
+ rows, err := s.db.Query(ctx, query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("query billing ledger: %w", err)
+ }
+ defer rows.Close()
+ result := make([]LedgerEntry, 0)
+ for rows.Next() {
+ var item LedgerEntry
+ if err := rows.Scan(&item.ID, &item.TenantID, &item.ProjectID, &item.Currency, &item.AmountMicros,
+ &item.BalanceAfterMicros, &item.Kind, &item.SourceType, &item.SourceID,
+ &item.Description, &item.CreatedAt); err != nil {
+ return nil, fmt.Errorf("scan billing ledger: %w", err)
+ }
+ result = append(result, item)
+ }
+ return result, rows.Err()
+}
+
+func (s *Service) AdjustBalance(ctx context.Context, input AdjustmentInput) (LedgerEntry, error) {
+ input.TenantID = strings.TrimSpace(input.TenantID)
+ input.Description = normalizeDescription(input.Description)
+ if input.TenantID == "" || input.AmountMicros == 0 {
+ return LedgerEntry{}, ErrInvalidAmount
+ }
+ tx, err := s.db.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.ReadCommitted})
+ if err != nil {
+ return LedgerEntry{}, fmt.Errorf("begin balance adjustment: %w", err)
+ }
+ defer tx.Rollback(ctx)
+ if _, err := tx.Exec(ctx, `
+ INSERT INTO tenant_wallets (tenant_id, currency) VALUES ($1, $2)
+ ON CONFLICT (tenant_id) DO NOTHING`, input.TenantID, s.currency); err != nil {
+ return LedgerEntry{}, fmt.Errorf("ensure adjustment wallet: %w", err)
+ }
+ var currency string
+ var balance, held int64
+ if err := tx.QueryRow(ctx, `SELECT currency, balance_micros, reserved_micros FROM tenant_wallets WHERE tenant_id = $1 FOR UPDATE`, input.TenantID).Scan(&currency, &balance, &held); err != nil {
+ return LedgerEntry{}, fmt.Errorf("lock adjustment wallet: %w", err)
+ }
+ if currency != s.currency {
+ return LedgerEntry{}, fmt.Errorf("tenant wallet currency %s does not match %s", currency, s.currency)
+ }
+ if input.AmountMicros > 0 && balance > math.MaxInt64-input.AmountMicros {
+ return LedgerEntry{}, ErrInvalidAmount
+ }
+ newBalance := balance + input.AmountMicros
+ if newBalance < held || newBalance < 0 {
+ return LedgerEntry{}, ErrInsufficientBalance
+ }
+ if _, err := tx.Exec(ctx, `UPDATE tenant_wallets SET balance_micros = $2, updated_at = now() WHERE tenant_id = $1`, input.TenantID, newBalance); err != nil {
+ return LedgerEntry{}, fmt.Errorf("apply balance adjustment: %w", err)
+ }
+ sourceID := "adj_" + randomHex(16)
+ var result LedgerEntry
+ err = tx.QueryRow(ctx, `
+ INSERT INTO billing_ledger (tenant_id, currency, amount_micros, balance_after_micros, kind, source_type, source_id, description)
+ VALUES ($1,$2,$3,$4,'adjustment','admin',$5,$6)
+ RETURNING id::text, tenant_id::text, '', currency, amount_micros, balance_after_micros,
+ kind, source_type, source_id, description, created_at`,
+ input.TenantID, s.currency, input.AmountMicros, newBalance, sourceID, input.Description,
+ ).Scan(&result.ID, &result.TenantID, &result.ProjectID, &result.Currency, &result.AmountMicros,
+ &result.BalanceAfterMicros, &result.Kind, &result.SourceType, &result.SourceID,
+ &result.Description, &result.CreatedAt)
+ if err != nil {
+ return LedgerEntry{}, fmt.Errorf("write adjustment ledger entry: %w", err)
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return LedgerEntry{}, fmt.Errorf("commit balance adjustment: %w", err)
+ }
+ return result, nil
+}
+
+func (s *Service) createTopUpOrder(ctx context.Context, input CheckoutInput) (string, int64, error) {
+ if !s.stripeEnabled {
+ return "", 0, ErrStripeDisabled
+ }
+ if strings.TrimSpace(input.TenantID) == "" || input.AmountMinor < s.minTopUpMinor || input.AmountMinor > s.maxTopUpMinor {
+ return "", 0, ErrInvalidAmount
+ }
+ amountMicros, err := minorToMicros(s.currency, input.AmountMinor)
+ if err != nil {
+ return "", 0, err
+ }
+ var orderID string
+ err = s.db.QueryRow(ctx, `
+ INSERT INTO topup_orders (tenant_id, amount_minor, amount_micros, currency)
+ VALUES ($1,$2,$3,$4) RETURNING id::text`, input.TenantID, input.AmountMinor, amountMicros, s.currency,
+ ).Scan(&orderID)
+ if err != nil {
+ return "", 0, fmt.Errorf("create top-up order: %w", err)
+ }
+ return orderID, amountMicros, nil
+}
+
+func minorToMicros(currency string, amount int64) (int64, error) {
+ if amount <= 0 {
+ return 0, ErrInvalidAmount
+ }
+ factor := int64(10_000)
+ switch strings.ToLower(currency) {
+ case "bif", "clp", "djf", "gnf", "jpy", "kmf", "krw", "mga", "pyg", "rwf", "ugx", "vnd", "vuv", "xaf", "xof", "xpf":
+ factor = 1_000_000
+ case "bhd", "jod", "kwd", "omr", "tnd":
+ factor = 1_000
+ }
+ if amount > math.MaxInt64/factor {
+ return 0, ErrInvalidAmount
+ }
+ return amount * factor, nil
+}
+
+func isNotFound(err error) bool {
+ return errors.Is(err, pgx.ErrNoRows)
+}
diff --git a/internal/billing/service.go b/internal/billing/service.go
new file mode 100644
index 0000000..b87a0bd
--- /dev/null
+++ b/internal/billing/service.go
@@ -0,0 +1,352 @@
+package billing
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "math/big"
+ "strings"
+ "time"
+
+ "aigw/internal/domain"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+const microsPerUnit = int64(1_000_000)
+
+type Service struct {
+ db *pgxpool.Pool
+ currency string
+ defaultMaxOutputTokens int64
+ minTopUpMinor int64
+ maxTopUpMinor int64
+ stripeEnabled bool
+ stripeWebhookSecret string
+ stripeSuccessURL string
+ stripeCancelURL string
+ integrationIdentifier string
+ createStripeCheckout stripeCheckoutCreator
+}
+
+func New(ctx context.Context, options Options) (*Service, error) {
+ db, err := pgxpool.New(ctx, options.DatabaseURL)
+ if err != nil {
+ return nil, fmt.Errorf("configure billing PostgreSQL: %w", err)
+ }
+ if err := db.Ping(ctx); err != nil {
+ db.Close()
+ return nil, fmt.Errorf("connect billing PostgreSQL: %w", err)
+ }
+ service := &Service{
+ db: db, currency: options.Currency, defaultMaxOutputTokens: options.DefaultMaxOutputTokens,
+ minTopUpMinor: options.MinTopUpMinor, maxTopUpMinor: options.MaxTopUpMinor,
+ stripeEnabled: options.StripeEnabled, stripeWebhookSecret: options.StripeWebhookSecret,
+ stripeSuccessURL: options.StripeSuccessURL, stripeCancelURL: options.StripeCancelURL,
+ integrationIdentifier: "aigw_balance_" + randomLetters(8),
+ }
+ if options.StripeEnabled {
+ service.createStripeCheckout = newStripeCheckoutCreator(options.StripeAPIKey)
+ }
+ return service, nil
+}
+
+func (s *Service) Close() {
+ s.db.Close()
+}
+
+func (s *Service) StripeEnabled() bool {
+ return s.stripeEnabled
+}
+
+func (s *Service) Currency() string {
+ return s.currency
+}
+
+func (s *Service) Authorize(ctx context.Context, input Authorization) error {
+ if input.RequestID == "" || input.Principal.TenantID == "" || input.Principal.ProjectID == "" || input.Principal.KeyID == "" {
+ return errors.New("billing authorization identity is incomplete")
+ }
+ reserved, err := reservationCost(input.Model, input.Body, s.defaultMaxOutputTokens)
+ if err != nil {
+ return err
+ }
+ tx, err := s.db.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.ReadCommitted})
+ if err != nil {
+ return fmt.Errorf("begin billing authorization: %w", err)
+ }
+ defer tx.Rollback(ctx)
+ if _, err := tx.Exec(ctx, `
+ INSERT INTO tenant_wallets (tenant_id, currency) VALUES ($1, $2)
+ ON CONFLICT (tenant_id) DO NOTHING`, input.Principal.TenantID, s.currency); err != nil {
+ return fmt.Errorf("ensure tenant wallet: %w", err)
+ }
+ var currency string
+ var balance, held int64
+ if err := tx.QueryRow(ctx, `
+ SELECT currency, balance_micros, reserved_micros FROM tenant_wallets
+ WHERE tenant_id = $1 FOR UPDATE`, input.Principal.TenantID).Scan(&currency, &balance, &held); err != nil {
+ return fmt.Errorf("lock tenant wallet: %w", err)
+ }
+ if currency != s.currency {
+ return fmt.Errorf("tenant wallet currency %s does not match billing currency %s", currency, s.currency)
+ }
+ if input.Policy.MonthlySpendMicros > 0 {
+ period := time.Date(time.Now().UTC().Year(), time.Now().UTC().Month(), 1, 0, 0, 0, 0, time.UTC)
+ nextPeriod := period.AddDate(0, 1, 0)
+ var used, pending int64
+ if err := tx.QueryRow(ctx, `SELECT
+ COALESCE((SELECT cost_micros FROM usage_monthly_rollups WHERE project_id=$1 AND period_start=$2),0),
+ COALESCE((SELECT sum(reserved_micros) FROM billing_reservations WHERE project_id=$1 AND status='pending' AND created_at >= $2 AND created_at < $3),0)`,
+ input.Principal.ProjectID, period, nextPeriod).Scan(&used, &pending); err != nil {
+ return fmt.Errorf("read monthly spend quota: %w", err)
+ }
+ if reserved > input.Policy.MonthlySpendMicros || used > input.Policy.MonthlySpendMicros-reserved || pending > input.Policy.MonthlySpendMicros-used-reserved {
+ return ErrQuotaExceeded
+ }
+ }
+ if balance-held < reserved {
+ return ErrInsufficientBalance
+ }
+ if _, err := tx.Exec(ctx, `
+ INSERT INTO billing_reservations (
+ request_id, tenant_id, project_id, key_id, public_model, currency, reserved_micros,
+ input_price_micros_per_million, output_price_micros_per_million,
+ cache_read_price_micros_per_million, cache_write_price_micros_per_million)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
+ input.RequestID, input.Principal.TenantID, input.Principal.ProjectID, input.Principal.KeyID,
+ input.Model.ID, s.currency, reserved, input.Model.InputPriceMicrosPerMillion,
+ input.Model.OutputPriceMicrosPerMillion, input.Model.CacheReadPriceMicrosPerMillion,
+ input.Model.CacheWritePriceMicrosPerMillion); err != nil {
+ return fmt.Errorf("create billing reservation: %w", err)
+ }
+ if _, err := tx.Exec(ctx, `
+ UPDATE tenant_wallets SET reserved_micros = reserved_micros + $2, updated_at = now()
+ WHERE tenant_id = $1`, input.Principal.TenantID, reserved); err != nil {
+ return fmt.Errorf("reserve tenant balance: %w", err)
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return fmt.Errorf("commit billing authorization: %w", err)
+ }
+ return nil
+}
+
+func (s *Service) Settle(ctx context.Context, event domain.UsageEvent) error {
+ tx, err := s.db.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.ReadCommitted})
+ if err != nil {
+ return fmt.Errorf("begin usage settlement: %w", err)
+ }
+ defer tx.Rollback(ctx)
+
+ var tenantID, projectID, keyID, modelID, currency, status string
+ var reserved, inputPrice, outputPrice, cacheReadPrice, cacheWritePrice int64
+ err = tx.QueryRow(ctx, `
+ SELECT tenant_id::text, project_id::text, key_id::text, public_model, currency, reserved_micros, status,
+ input_price_micros_per_million, output_price_micros_per_million,
+ cache_read_price_micros_per_million, cache_write_price_micros_per_million
+ FROM billing_reservations WHERE request_id = $1 FOR UPDATE`, event.RequestID,
+ ).Scan(&tenantID, &projectID, &keyID, &modelID, &currency, &reserved, &status,
+ &inputPrice, &outputPrice, &cacheReadPrice, &cacheWritePrice)
+ if err != nil {
+ return fmt.Errorf("load billing reservation: %w", err)
+ }
+ if status != "pending" {
+ return tx.Commit(ctx)
+ }
+
+ actualCost := int64(0)
+ if event.StatusCode >= 200 && event.StatusCode < 300 {
+ actualCost, err = usageCost(event.Usage, inputPrice, outputPrice, cacheReadPrice, cacheWritePrice)
+ if err != nil {
+ return err
+ }
+ }
+ var balance, held int64
+ if err := tx.QueryRow(ctx, `SELECT balance_micros, reserved_micros FROM tenant_wallets WHERE tenant_id = $1 FOR UPDATE`, tenantID).Scan(&balance, &held); err != nil {
+ return fmt.Errorf("lock wallet for settlement: %w", err)
+ }
+ charged, err := collectibleCharge(actualCost, balance, held, reserved)
+ if err != nil {
+ return err
+ }
+ uncollected := actualCost - charged
+ newBalance := balance - charged
+ if _, err := tx.Exec(ctx, `
+ UPDATE tenant_wallets SET balance_micros = $2, reserved_micros = reserved_micros - $3, updated_at = now()
+ WHERE tenant_id = $1`, tenantID, newBalance, reserved); err != nil {
+ return fmt.Errorf("settle tenant wallet: %w", err)
+ }
+ reservationStatus := "released"
+ if actualCost > 0 {
+ reservationStatus = "settled"
+ }
+ if _, err := tx.Exec(ctx, `
+ UPDATE billing_reservations
+ SET status = $2, actual_cost_micros = $3, charged_micros = $4, uncollected_micros = $5, settled_at = now()
+ WHERE request_id = $1`, event.RequestID, reservationStatus, actualCost, charged, uncollected); err != nil {
+ return fmt.Errorf("update billing reservation: %w", err)
+ }
+ usageAlreadyRecorded := false
+ if err := tx.QueryRow(ctx, `SELECT true FROM usage_events WHERE request_id=$1 FOR UPDATE`, event.RequestID).Scan(&usageAlreadyRecorded); err != nil && !errors.Is(err, pgx.ErrNoRows) {
+ return fmt.Errorf("lock existing usage event: %w", err)
+ }
+ if usageAlreadyRecorded {
+ if _, err := tx.Exec(ctx, `UPDATE usage_events SET cost_micros=$2, charged_micros=$3, uncollected_micros=$4 WHERE request_id=$1`,
+ event.RequestID, actualCost, charged, uncollected); err != nil {
+ return fmt.Errorf("apply usage charge: %w", err)
+ }
+ } else if _, err := tx.Exec(ctx, `
+ INSERT INTO usage_events (
+ request_id, tenant_id, project_id, key_id, public_model, provider_id, upstream_model,
+ protocol, stream, status_code, success, error_type, attempts, started_at, duration_ms,
+ input_tokens, output_tokens, total_tokens, cache_creation_input_tokens, cache_read_input_tokens,
+ cost_micros, charged_micros, uncollected_micros)
+ VALUES ($1,$2,$3,$4,$5,NULLIF($6,''),NULLIF($7,''),$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23)
+ ON CONFLICT (request_id) DO NOTHING`,
+ event.RequestID, tenantID, projectID, keyID, modelID, event.ProviderID, event.UpstreamModel,
+ string(event.Protocol), event.Stream, event.StatusCode, event.Success, event.ErrorType, event.Attempts,
+ event.StartedAt, event.DurationMS, event.Usage.InputTokens, event.Usage.OutputTokens,
+ event.Usage.TotalTokens, event.Usage.CacheCreationInputTokens, event.Usage.CacheReadInputTokens,
+ actualCost, charged, uncollected); err != nil {
+ return fmt.Errorf("persist usage event: %w", err)
+ }
+ if charged > 0 {
+ if _, err := tx.Exec(ctx, `
+ INSERT INTO billing_ledger (tenant_id, project_id, currency, amount_micros, balance_after_micros, kind, source_type, source_id, description)
+ VALUES ($1,$2,$3,$4,$5,'usage','request',$6,$7)
+ ON CONFLICT (source_type, source_id) DO NOTHING`, tenantID, projectID, currency, -charged, newBalance, event.RequestID, modelID); err != nil {
+ return fmt.Errorf("write usage ledger entry: %w", err)
+ }
+ }
+ period := time.Date(event.StartedAt.UTC().Year(), event.StartedAt.UTC().Month(), 1, 0, 0, 0, 0, time.UTC)
+ if usageAlreadyRecorded {
+ if _, err := tx.Exec(ctx, `UPDATE usage_monthly_rollups SET cost_micros=cost_micros+$3,
+ charged_micros=charged_micros+$4, uncollected_micros=uncollected_micros+$5, updated_at=now()
+ WHERE project_id=$1 AND period_start=$2`, projectID, period, actualCost, charged, uncollected); err != nil {
+ return fmt.Errorf("apply usage rollup charge: %w", err)
+ }
+ } else if _, err := tx.Exec(ctx, `
+ INSERT INTO usage_monthly_rollups
+ (period_start, tenant_id, project_id, request_count, successful_requests, input_tokens, output_tokens, total_tokens, cost_micros, charged_micros, uncollected_micros)
+ VALUES ($1,$2,$3,1,$4,$5,$6,$7,$8,$9,$10)
+ ON CONFLICT (project_id, period_start) DO UPDATE SET request_count=usage_monthly_rollups.request_count+1,
+ successful_requests=usage_monthly_rollups.successful_requests+EXCLUDED.successful_requests,
+ input_tokens=usage_monthly_rollups.input_tokens+EXCLUDED.input_tokens,
+ output_tokens=usage_monthly_rollups.output_tokens+EXCLUDED.output_tokens,
+ total_tokens=usage_monthly_rollups.total_tokens+EXCLUDED.total_tokens,
+ cost_micros=usage_monthly_rollups.cost_micros+EXCLUDED.cost_micros,
+ charged_micros=usage_monthly_rollups.charged_micros+EXCLUDED.charged_micros,
+ uncollected_micros=usage_monthly_rollups.uncollected_micros+EXCLUDED.uncollected_micros,
+ updated_at=now()`, period, tenantID, projectID, boolToInt(event.Success), event.Usage.InputTokens,
+ event.Usage.OutputTokens, event.Usage.TotalTokens, actualCost, charged, uncollected); err != nil {
+ return fmt.Errorf("update usage monthly rollup: %w", err)
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return fmt.Errorf("commit usage settlement: %w", err)
+ }
+ return nil
+}
+
+func boolToInt(value bool) int {
+ if value {
+ return 1
+ }
+ return 0
+}
+
+func reservationCost(model domain.Model, body []byte, defaultMaxOutput int64) (int64, error) {
+ maxOutput := defaultMaxOutput
+ var limits struct {
+ MaxTokens int64 `json:"max_tokens"`
+ MaxCompletionTokens int64 `json:"max_completion_tokens"`
+ MaxOutputTokens int64 `json:"max_output_tokens"`
+ }
+ if json.Unmarshal(body, &limits) == nil {
+ explicitMax := int64(0)
+ for _, value := range []int64{limits.MaxTokens, limits.MaxCompletionTokens, limits.MaxOutputTokens} {
+ if value > explicitMax {
+ explicitMax = value
+ }
+ }
+ if explicitMax > 0 {
+ maxOutput = explicitMax
+ }
+ }
+ cacheReservePrice := model.CacheReadPriceMicrosPerMillion
+ if model.CacheWritePriceMicrosPerMillion > cacheReservePrice {
+ cacheReservePrice = model.CacheWritePriceMicrosPerMillion
+ }
+ return calculateCost(int64(len(body)), maxOutput, 0, int64(len(body)),
+ model.InputPriceMicrosPerMillion, model.OutputPriceMicrosPerMillion,
+ 0, cacheReservePrice)
+}
+
+func usageCost(usage domain.Usage, inputPrice, outputPrice, cacheReadPrice, cacheWritePrice int64) (int64, error) {
+ return calculateCost(usage.InputTokens, usage.OutputTokens, usage.CacheReadInputTokens,
+ usage.CacheCreationInputTokens, inputPrice, outputPrice, cacheReadPrice, cacheWritePrice)
+}
+
+func calculateCost(input, output, cacheRead, cacheWrite, inputPrice, outputPrice, cacheReadPrice, cacheWritePrice int64) (int64, error) {
+ values := []int64{input, output, cacheRead, cacheWrite, inputPrice, outputPrice, cacheReadPrice, cacheWritePrice}
+ for _, value := range values {
+ if value < 0 {
+ return 0, errors.New("billing values cannot be negative")
+ }
+ }
+ total := new(big.Int)
+ for _, pair := range [][2]int64{{input, inputPrice}, {output, outputPrice}, {cacheRead, cacheReadPrice}, {cacheWrite, cacheWritePrice}} {
+ total.Add(total, new(big.Int).Mul(big.NewInt(pair[0]), big.NewInt(pair[1])))
+ }
+ if total.Sign() == 0 {
+ return 0, nil
+ }
+ total.Add(total, big.NewInt(microsPerUnit-1))
+ total.Div(total, big.NewInt(microsPerUnit))
+ if !total.IsInt64() {
+ return 0, errors.New("calculated charge exceeds supported range")
+ }
+ return total.Int64(), nil
+}
+
+func collectibleCharge(actualCost, balance, held, reservation int64) (int64, error) {
+ if actualCost < 0 || balance < 0 || held < 0 || reservation < 0 || held > balance || reservation > held {
+ return 0, errors.New("wallet reservation invariant violated")
+ }
+ spendable := balance - (held - reservation)
+ if actualCost > spendable {
+ return spendable, nil
+ }
+ return actualCost, nil
+}
+
+func randomHex(bytes int) string {
+ buffer := make([]byte, bytes)
+ if _, err := rand.Read(buffer); err != nil {
+ return fmt.Sprintf("%08x", time.Now().UnixNano())[:bytes*2]
+ }
+ return hex.EncodeToString(buffer)
+}
+
+func randomLetters(length int) string {
+ const letters = "abcdefghijklmnopqrstuvwxyz"
+ buffer := make([]byte, length)
+ if _, err := rand.Read(buffer); err != nil {
+ return strings.Repeat("a", length)
+ }
+ for i := range buffer {
+ buffer[i] = letters[int(buffer[i])%len(letters)]
+ }
+ return string(buffer)
+}
+
+func normalizeDescription(value string) string {
+ value = strings.TrimSpace(value)
+ if len(value) > 240 {
+ value = value[:240]
+ }
+ return value
+}
diff --git a/internal/billing/service_test.go b/internal/billing/service_test.go
new file mode 100644
index 0000000..17f8143
--- /dev/null
+++ b/internal/billing/service_test.go
@@ -0,0 +1,190 @@
+package billing
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "aigw/internal/controlplane"
+ "aigw/internal/domain"
+
+ "github.com/stripe/stripe-go/v86"
+ "github.com/stripe/stripe-go/v86/webhook"
+)
+
+func TestUsageCostUsesFixedPointAndRoundsOnce(t *testing.T) {
+ usage := domain.Usage{InputTokens: 3, OutputTokens: 2, CacheReadInputTokens: 5}
+ cost, err := usageCost(usage, 150_000, 600_000, 30_000, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // (3*150000 + 2*600000 + 5*30000) / 1e6 = 1.8 micros.
+ if cost != 2 {
+ t.Fatalf("cost = %d, want 2", cost)
+ }
+}
+
+func TestReservationUsesExplicitOutputLimit(t *testing.T) {
+ model := domain.Model{InputPriceMicrosPerMillion: 1_000_000, OutputPriceMicrosPerMillion: 1_000_000}
+ body := []byte(`{"max_tokens":8192}`)
+ cost, err := reservationCost(model, body, 4096)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := int64(len(body)) + 8192
+ if cost != want {
+ t.Fatalf("reservation = %d, want %d", cost, want)
+ }
+}
+
+func TestMinorToMicrosSupportsCurrencyExponents(t *testing.T) {
+ tests := []struct {
+ currency string
+ minor int64
+ want int64
+ }{{"usd", 123, 1_230_000}, {"jpy", 123, 123_000_000}, {"bhd", 123, 123_000}}
+ for _, test := range tests {
+ got, err := minorToMicros(test.currency, test.minor)
+ if err != nil {
+ t.Fatalf("%s: %v", test.currency, err)
+ }
+ if got != test.want {
+ t.Fatalf("%s: got %d, want %d", test.currency, got, test.want)
+ }
+ }
+}
+
+func TestCollectibleChargePreservesOtherReservations(t *testing.T) {
+ charged, err := collectibleCharge(100, 100, 80, 40)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if charged != 60 {
+ t.Fatalf("charged = %d, want 60", charged)
+ }
+ if remainingBalance, remainingHeld := int64(100)-charged, int64(80)-40; remainingBalance < remainingHeld {
+ t.Fatalf("remaining balance %d does not cover held %d", remainingBalance, remainingHeld)
+ }
+}
+
+func TestIntegrationIdentifierSuffixUsesLetters(t *testing.T) {
+ value := randomLetters(8)
+ if len(value) != 8 {
+ t.Fatalf("length = %d", len(value))
+ }
+ for _, char := range value {
+ if char < 'a' || char > 'z' {
+ t.Fatalf("non-letter suffix %q", value)
+ }
+ }
+}
+
+func TestWebhookRejectsInvalidSignatureBeforeProcessing(t *testing.T) {
+ service := &Service{stripeWebhookSecret: "whsec_test"}
+ request := httptest.NewRequest(http.MethodPost, "/billing/stripe/webhook", strings.NewReader(`{"id":"evt_fake"}`))
+ request.Header.Set("Stripe-Signature", "invalid")
+ response := httptest.NewRecorder()
+ service.WebhookHandler().ServeHTTP(response, request)
+ if response.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400", response.Code)
+ }
+}
+
+func TestStripeWebhookCreditsPaidOrderExactlyOncePostgres(t *testing.T) {
+ databaseURL := os.Getenv("AIGW_TEST_DATABASE_URL")
+ if databaseURL == "" {
+ t.Skip("AIGW_TEST_DATABASE_URL is not set")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ if err := controlplane.MigrateDatabase(ctx, databaseURL); err != nil {
+ t.Fatal(err)
+ }
+ service, err := New(ctx, Options{
+ DatabaseURL: databaseURL, Currency: "usd", MinTopUpMinor: 500, MaxTopUpMinor: 1_000_000,
+ StripeEnabled: true, StripeAPIKey: "rk_test_placeholder", StripeWebhookSecret: "whsec_integration_test",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(service.Close)
+
+ var tenantID string
+ slug := fmt.Sprintf("stripe-%d", time.Now().UnixNano())
+ if err := service.db.QueryRow(ctx, `INSERT INTO tenants (slug, name) VALUES ($1,'Stripe integration') RETURNING id::text`, slug).Scan(&tenantID); err != nil {
+ t.Fatal(err)
+ }
+ eventID := fmt.Sprintf("evt_aigw_%d", time.Now().UnixNano())
+ t.Cleanup(func() {
+ cleanupCtx := context.Background()
+ for _, statement := range []struct {
+ query string
+ arg string
+ }{
+ {`DELETE FROM stripe_webhook_events WHERE event_id=$1`, eventID},
+ {`DELETE FROM billing_ledger WHERE tenant_id=$1`, tenantID},
+ {`DELETE FROM tenant_wallets WHERE tenant_id=$1`, tenantID},
+ {`DELETE FROM topup_orders WHERE tenant_id=$1`, tenantID},
+ {`DELETE FROM tenants WHERE id=$1`, tenantID},
+ } {
+ if _, cleanupErr := service.db.Exec(cleanupCtx, statement.query, statement.arg); cleanupErr != nil {
+ t.Errorf("cleanup Stripe integration data: %v", cleanupErr)
+ }
+ }
+ })
+ const amountMinor int64 = 2500
+ orderID, amountMicros, err := service.createTopUpOrder(ctx, CheckoutInput{TenantID: tenantID, AmountMinor: amountMinor})
+ if err != nil {
+ t.Fatal(err)
+ }
+ sessionID := fmt.Sprintf("cs_test_aigw_%d", time.Now().UnixNano())
+ payload, err := json.Marshal(map[string]any{
+ "id": eventID, "object": "event", "api_version": stripe.APIVersion,
+ "type": string(stripe.EventTypeCheckoutSessionCompleted),
+ "data": map[string]any{"object": map[string]any{
+ "id": sessionID, "object": "checkout.session", "client_reference_id": orderID,
+ "amount_total": amountMinor, "currency": "usd", "payment_status": "paid",
+ }},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ signed := webhook.GenerateTestSignedPayload(&webhook.UnsignedPayload{Payload: payload, Secret: service.stripeWebhookSecret})
+ for delivery := 0; delivery < 2; delivery++ {
+ request := httptest.NewRequest(http.MethodPost, "/billing/stripe/webhook", strings.NewReader(string(payload)))
+ request.Header.Set("Stripe-Signature", signed.Header)
+ response := httptest.NewRecorder()
+ service.WebhookHandler().ServeHTTP(response, request)
+ if response.Code != http.StatusOK {
+ t.Fatalf("delivery %d status = %d, body = %s", delivery+1, response.Code, response.Body.String())
+ }
+ }
+
+ var balance int64
+ if err := service.db.QueryRow(ctx, `SELECT balance_micros FROM tenant_wallets WHERE tenant_id=$1`, tenantID).Scan(&balance); err != nil {
+ t.Fatal(err)
+ }
+ if balance != amountMicros {
+ t.Fatalf("balance = %d, want %d", balance, amountMicros)
+ }
+ var ledgerCount, webhookCount int
+ var orderStatus string
+ if err := service.db.QueryRow(ctx, `SELECT count(*) FROM billing_ledger WHERE source_type='stripe_checkout' AND source_id=$1`, sessionID).Scan(&ledgerCount); err != nil {
+ t.Fatal(err)
+ }
+ if err := service.db.QueryRow(ctx, `SELECT count(*) FROM stripe_webhook_events WHERE event_id=$1`, eventID).Scan(&webhookCount); err != nil {
+ t.Fatal(err)
+ }
+ if err := service.db.QueryRow(ctx, `SELECT status FROM topup_orders WHERE id=$1`, orderID).Scan(&orderStatus); err != nil {
+ t.Fatal(err)
+ }
+ if ledgerCount != 1 || webhookCount != 1 || orderStatus != "paid" {
+ t.Fatalf("ledger=%d webhook=%d order=%s, want 1/1/paid", ledgerCount, webhookCount, orderStatus)
+ }
+}
diff --git a/internal/billing/stripe.go b/internal/billing/stripe.go
new file mode 100644
index 0000000..b06eb6a
--- /dev/null
+++ b/internal/billing/stripe.go
@@ -0,0 +1,203 @@
+package billing
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/stripe/stripe-go/v86"
+ "github.com/stripe/stripe-go/v86/webhook"
+)
+
+const maxWebhookBodyBytes = 1 << 20
+
+type stripeCheckoutCreator func(context.Context, *stripe.CheckoutSessionCreateParams) (*stripe.CheckoutSession, error)
+
+func newStripeCheckoutCreator(apiKey string) stripeCheckoutCreator {
+ client := stripe.NewClient(apiKey)
+ return client.V1CheckoutSessions.Create
+}
+
+func (s *Service) CreateCheckout(ctx context.Context, input CheckoutInput) (CheckoutResult, error) {
+ orderID, _, err := s.createTopUpOrder(ctx, input)
+ if err != nil {
+ return CheckoutResult{}, err
+ }
+ params := &stripe.CheckoutSessionCreateParams{
+ Mode: stripe.String("payment"),
+ ClientReferenceID: stripe.String(orderID),
+ IntegrationIdentifier: stripe.String(s.integrationIdentifier),
+ SuccessURL: stripe.String(s.stripeSuccessURL),
+ CancelURL: stripe.String(s.stripeCancelURL),
+ Metadata: map[string]string{
+ "aigw_topup_order_id": orderID,
+ "aigw_tenant_id": strings.TrimSpace(input.TenantID),
+ },
+ LineItems: []*stripe.CheckoutSessionCreateLineItemParams{{
+ Quantity: stripe.Int64(1),
+ PriceData: &stripe.CheckoutSessionCreateLineItemPriceDataParams{
+ Currency: stripe.String(s.currency),
+ UnitAmount: stripe.Int64(input.AmountMinor),
+ ProductData: &stripe.CheckoutSessionCreateLineItemPriceDataProductDataParams{
+ Name: stripe.String("AIGW prepaid balance"),
+ Description: stripe.String("Prepaid API usage credit"),
+ },
+ },
+ }},
+ }
+ params.SetIdempotencyKey("aigw_topup_" + orderID)
+ session, err := s.createStripeCheckout(ctx, params)
+ if err != nil {
+ _, _ = s.db.Exec(ctx, `UPDATE topup_orders SET status = 'failed' WHERE id = $1 AND status = 'pending'`, orderID)
+ return CheckoutResult{}, fmt.Errorf("create Stripe Checkout Session: %w", err)
+ }
+ if session.ID == "" || session.URL == "" {
+ return CheckoutResult{}, errors.New("Stripe returned an incomplete Checkout Session")
+ }
+ if _, err := s.db.Exec(ctx, `
+ UPDATE topup_orders SET stripe_session_id = $2, checkout_url = $3
+ WHERE id = $1`, orderID, session.ID, session.URL); err != nil {
+ return CheckoutResult{}, fmt.Errorf("persist Stripe Checkout Session: %w", err)
+ }
+ return CheckoutResult{OrderID: orderID, SessionID: session.ID, URL: session.URL}, nil
+}
+
+func (s *Service) WebhookHandler() http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ w.Header().Set("Allow", http.MethodPost)
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodyBytes+1))
+ if err != nil || len(body) > maxWebhookBodyBytes {
+ http.Error(w, "invalid webhook body", http.StatusBadRequest)
+ return
+ }
+ event, err := webhook.ConstructEvent(body, r.Header.Get("Stripe-Signature"), s.stripeWebhookSecret)
+ if err != nil {
+ http.Error(w, "invalid webhook signature", http.StatusBadRequest)
+ return
+ }
+ if err := s.processStripeEvent(r.Context(), event); err != nil {
+ if errors.Is(err, ErrInvalidAmount) || isNotFound(err) {
+ http.Error(w, "invalid checkout event", http.StatusBadRequest)
+ return
+ }
+ http.Error(w, "webhook processing failed", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = io.WriteString(w, `{"received":true}`+"\n")
+ })
+}
+
+func (s *Service) processStripeEvent(ctx context.Context, event stripe.Event) error {
+ typeName := string(event.Type)
+ switch event.Type {
+ case stripe.EventTypeCheckoutSessionCompleted,
+ stripe.EventTypeCheckoutSessionAsyncPaymentSucceeded,
+ stripe.EventTypeCheckoutSessionAsyncPaymentFailed,
+ stripe.EventTypeCheckoutSessionExpired:
+ default:
+ return nil
+ }
+ if event.Data == nil {
+ return ErrInvalidAmount
+ }
+ var session stripe.CheckoutSession
+ if err := json.Unmarshal(event.Data.Raw, &session); err != nil {
+ return ErrInvalidAmount
+ }
+ if event.ID == "" || session.ID == "" || session.ClientReferenceID == "" {
+ return ErrInvalidAmount
+ }
+ tx, err := s.db.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.ReadCommitted})
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback(ctx)
+ tag, err := tx.Exec(ctx, `
+ INSERT INTO stripe_webhook_events (event_id, event_type) VALUES ($1,$2)
+ ON CONFLICT (event_id) DO NOTHING`, event.ID, typeName)
+ if err != nil {
+ return fmt.Errorf("record Stripe event: %w", err)
+ }
+ if tag.RowsAffected() == 0 {
+ return tx.Commit(ctx)
+ }
+
+ var tenantID, currency, status string
+ var amountMinor, amountMicros int64
+ var storedSessionID *string
+ err = tx.QueryRow(ctx, `
+ SELECT tenant_id::text, amount_minor, amount_micros, currency, status, stripe_session_id
+ FROM topup_orders WHERE id = $1 FOR UPDATE`, session.ClientReferenceID,
+ ).Scan(&tenantID, &amountMinor, &amountMicros, &currency, &status, &storedSessionID)
+ if err != nil {
+ return err
+ }
+ if (storedSessionID != nil && *storedSessionID != session.ID) || amountMinor != session.AmountTotal || currency != string(session.Currency) {
+ return ErrInvalidAmount
+ }
+ if event.Type == stripe.EventTypeCheckoutSessionAsyncPaymentFailed || event.Type == stripe.EventTypeCheckoutSessionExpired {
+ orderStatus := "failed"
+ if event.Type == stripe.EventTypeCheckoutSessionExpired {
+ orderStatus = "expired"
+ }
+ if _, err := tx.Exec(ctx, `UPDATE topup_orders SET status = $2, stripe_session_id = COALESCE(stripe_session_id, $3) WHERE id = $1 AND status = 'pending'`, session.ClientReferenceID, orderStatus, session.ID); err != nil {
+ return err
+ }
+ _, err = tx.Exec(ctx, `UPDATE stripe_webhook_events SET processed_at = now() WHERE event_id = $1`, event.ID)
+ if err != nil {
+ return err
+ }
+ return tx.Commit(ctx)
+ }
+ if session.PaymentStatus != stripe.CheckoutSessionPaymentStatusPaid {
+ _, err = tx.Exec(ctx, `UPDATE stripe_webhook_events SET processed_at = now() WHERE event_id = $1`, event.ID)
+ if err != nil {
+ return err
+ }
+ return tx.Commit(ctx)
+ }
+ if status != "paid" {
+ if _, err := tx.Exec(ctx, `
+ INSERT INTO tenant_wallets (tenant_id, currency) VALUES ($1,$2)
+ ON CONFLICT (tenant_id) DO NOTHING`, tenantID, currency); err != nil {
+ return err
+ }
+ var walletCurrency string
+ var balance int64
+ if err := tx.QueryRow(ctx, `SELECT currency, balance_micros FROM tenant_wallets WHERE tenant_id = $1 FOR UPDATE`, tenantID).Scan(&walletCurrency, &balance); err != nil {
+ return err
+ }
+ if walletCurrency != currency || amountMicros <= 0 || balance > int64(^uint64(0)>>1)-amountMicros {
+ return ErrInvalidAmount
+ }
+ newBalance := balance + amountMicros
+ if _, err := tx.Exec(ctx, `UPDATE tenant_wallets SET balance_micros = $2, updated_at = now() WHERE tenant_id = $1`, tenantID, newBalance); err != nil {
+ return err
+ }
+ if _, err := tx.Exec(ctx, `
+ INSERT INTO billing_ledger (tenant_id, currency, amount_micros, balance_after_micros, kind, source_type, source_id, description)
+ VALUES ($1,$2,$3,$4,'topup','stripe_checkout',$5,'Stripe balance top-up')
+ ON CONFLICT (source_type, source_id) DO NOTHING`, tenantID, currency, amountMicros, newBalance, session.ID); err != nil {
+ return err
+ }
+ if _, err := tx.Exec(ctx, `
+ UPDATE topup_orders SET status = 'paid', stripe_session_id = COALESCE(stripe_session_id, $2), paid_at = now()
+ WHERE id = $1`, session.ClientReferenceID, session.ID); err != nil {
+ return err
+ }
+ }
+ if _, err := tx.Exec(ctx, `UPDATE stripe_webhook_events SET processed_at = now() WHERE event_id = $1`, event.ID); err != nil {
+ return err
+ }
+ return tx.Commit(ctx)
+}
diff --git a/internal/billing/types.go b/internal/billing/types.go
new file mode 100644
index 0000000..24694cc
--- /dev/null
+++ b/internal/billing/types.go
@@ -0,0 +1,83 @@
+package billing
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ "aigw/internal/domain"
+)
+
+var (
+ ErrInsufficientBalance = errors.New("insufficient balance")
+ ErrStripeDisabled = errors.New("Stripe top-ups are disabled")
+ ErrInvalidAmount = errors.New("invalid amount")
+ ErrQuotaExceeded = errors.New("monthly spend quota exceeded")
+)
+
+type Meter interface {
+ Authorize(context.Context, Authorization) error
+ Settle(context.Context, domain.UsageEvent) error
+}
+
+type Authorization struct {
+ RequestID string
+ Principal domain.Principal
+ Model domain.Model
+ Body []byte
+ Policy domain.LimitPolicy
+}
+
+type Options struct {
+ DatabaseURL string
+ Currency string
+ DefaultMaxOutputTokens int64
+ MinTopUpMinor int64
+ MaxTopUpMinor int64
+ StripeEnabled bool
+ StripeAPIKey string
+ StripeWebhookSecret string
+ StripeSuccessURL string
+ StripeCancelURL string
+}
+
+type Account struct {
+ TenantID string `json:"tenant_id"`
+ TenantName string `json:"tenant_name"`
+ Currency string `json:"currency"`
+ BalanceMicros int64 `json:"balance_micros"`
+ ReservedMicros int64 `json:"reserved_micros"`
+ AvailableMicros int64 `json:"available_micros"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+type LedgerEntry struct {
+ ID string `json:"id"`
+ TenantID string `json:"tenant_id"`
+ ProjectID string `json:"project_id,omitempty"`
+ Currency string `json:"currency"`
+ AmountMicros int64 `json:"amount_micros"`
+ BalanceAfterMicros int64 `json:"balance_after_micros"`
+ Kind string `json:"kind"`
+ SourceType string `json:"source_type"`
+ SourceID string `json:"source_id"`
+ Description string `json:"description"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+type AdjustmentInput struct {
+ TenantID string `json:"tenant_id"`
+ AmountMicros int64 `json:"amount_micros"`
+ Description string `json:"description"`
+}
+
+type CheckoutInput struct {
+ TenantID string `json:"tenant_id"`
+ AmountMinor int64 `json:"amount_minor"`
+}
+
+type CheckoutResult struct {
+ OrderID string `json:"order_id"`
+ SessionID string `json:"session_id"`
+ URL string `json:"url"`
+}
diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go
index 76e33b1..7966d9a 100644
--- a/internal/catalog/catalog.go
+++ b/internal/catalog/catalog.go
@@ -32,7 +32,14 @@ func New(cfg config.Config) *Catalog {
models := make([]domain.Model, 0, len(cfg.Models))
for _, modelCfg := range cfg.Models {
- model := domain.Model{ID: modelCfg.ID, OwnedBy: modelCfg.OwnedBy}
+ model := domain.Model{
+ ID: modelCfg.ID,
+ OwnedBy: modelCfg.OwnedBy,
+ InputPriceMicrosPerMillion: modelCfg.InputPriceMicrosPerMillion,
+ OutputPriceMicrosPerMillion: modelCfg.OutputPriceMicrosPerMillion,
+ CacheReadPriceMicrosPerMillion: modelCfg.CacheReadPriceMicrosPerMillion,
+ CacheWritePriceMicrosPerMillion: modelCfg.CacheWritePriceMicrosPerMillion,
+ }
for _, route := range modelCfg.Routes {
model.Routes = append(model.Routes, domain.Route{
Provider: providers[route.Provider],
diff --git a/internal/config/config.go b/internal/config/config.go
index 5e1e518..8c21c6c 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -21,6 +21,7 @@ type Config struct {
UpstreamHTTP UpstreamHTTPConfig `json:"upstream_http"`
Providers []ProviderConfig `json:"providers"`
Models []ModelConfig `json:"models"`
+ Billing BillingConfig `json:"billing"`
Observability ObservabilityConfig `json:"observability"`
}
@@ -74,9 +75,13 @@ type ProviderConfig struct {
}
type ModelConfig struct {
- ID string `json:"id"`
- OwnedBy string `json:"owned_by"`
- Routes []RouteConfig `json:"routes"`
+ ID string `json:"id"`
+ OwnedBy string `json:"owned_by"`
+ InputPriceMicrosPerMillion int64 `json:"input_price_micros_per_million"`
+ OutputPriceMicrosPerMillion int64 `json:"output_price_micros_per_million"`
+ CacheReadPriceMicrosPerMillion int64 `json:"cache_read_price_micros_per_million"`
+ CacheWritePriceMicrosPerMillion int64 `json:"cache_write_price_micros_per_million"`
+ Routes []RouteConfig `json:"routes"`
}
type RouteConfig struct {
@@ -91,6 +96,25 @@ type ObservabilityConfig struct {
ExposeMetrics bool `json:"expose_metrics"`
}
+type BillingConfig struct {
+ Enabled bool `json:"enabled"`
+ Currency string `json:"currency"`
+ DefaultMaxOutputTokens int64 `json:"default_max_output_tokens"`
+ MinTopUpMinor int64 `json:"min_top_up_minor"`
+ MaxTopUpMinor int64 `json:"max_top_up_minor"`
+ Stripe StripeConfig `json:"stripe"`
+}
+
+type StripeConfig struct {
+ Enabled bool `json:"enabled"`
+ APIKeyEnv string `json:"api_key_env"`
+ WebhookSecretEnv string `json:"webhook_secret_env"`
+ SuccessURL string `json:"success_url"`
+ CancelURL string `json:"cancel_url"`
+ APIKey string `json:"-"`
+ WebhookSecret string `json:"-"`
+}
+
func Load(path string) (Config, error) {
f, err := os.Open(path)
if err != nil {
@@ -179,6 +203,24 @@ func applyDefaults(cfg *Config) {
if cfg.Observability.UsageBuffer == 0 {
cfg.Observability.UsageBuffer = 8192
}
+ if cfg.Billing.Currency == "" {
+ cfg.Billing.Currency = "usd"
+ }
+ if cfg.Billing.DefaultMaxOutputTokens == 0 {
+ cfg.Billing.DefaultMaxOutputTokens = 4096
+ }
+ if cfg.Billing.MaxTopUpMinor == 0 {
+ cfg.Billing.MaxTopUpMinor = 1000000
+ }
+ if cfg.Billing.MinTopUpMinor == 0 {
+ cfg.Billing.MinTopUpMinor = 500
+ }
+ if cfg.Billing.Stripe.APIKeyEnv == "" {
+ cfg.Billing.Stripe.APIKeyEnv = "AIGW_STRIPE_API_KEY"
+ }
+ if cfg.Billing.Stripe.WebhookSecretEnv == "" {
+ cfg.Billing.Stripe.WebhookSecretEnv = "AIGW_STRIPE_WEBHOOK_SECRET"
+ }
for i := range cfg.Models {
for j := range cfg.Models[i].Routes {
if cfg.Models[i].Routes[j].Weight == 0 {
@@ -197,6 +239,10 @@ func resolveSecrets(cfg *Config) error {
if cfg.Admin.Enabled {
cfg.Admin.Token = os.Getenv(cfg.Admin.TokenEnv)
}
+ if cfg.Billing.Enabled && cfg.Billing.Stripe.Enabled {
+ cfg.Billing.Stripe.APIKey = os.Getenv(cfg.Billing.Stripe.APIKeyEnv)
+ cfg.Billing.Stripe.WebhookSecret = os.Getenv(cfg.Billing.Stripe.WebhookSecretEnv)
+ }
for i := range cfg.Providers {
provider := &cfg.Providers[i]
if provider.APIKeyEnv == "" {
@@ -240,6 +286,40 @@ func Validate(cfg Config) error {
return errors.New("admin.base_path must start with / and cannot be /")
}
}
+ if cfg.Billing.Enabled {
+ if !cfg.ControlPlane.Enabled {
+ return errors.New("billing requires control_plane.enabled")
+ }
+ if cfg.Auth.AllowAnonymous {
+ return errors.New("billing cannot be enabled with auth.allow_anonymous")
+ }
+ if len(cfg.Billing.Currency) != 3 || strings.ToLower(cfg.Billing.Currency) != cfg.Billing.Currency {
+ return errors.New("billing.currency must be a lowercase ISO 4217 code")
+ }
+ if cfg.Billing.DefaultMaxOutputTokens < 1 {
+ return errors.New("billing.default_max_output_tokens must be positive")
+ }
+ if cfg.Billing.MinTopUpMinor < 1 || cfg.Billing.MaxTopUpMinor < cfg.Billing.MinTopUpMinor {
+ return errors.New("billing top-up bounds are invalid")
+ }
+ if cfg.Billing.Stripe.Enabled {
+ if cfg.Billing.Stripe.APIKey == "" {
+ return fmt.Errorf("billing.stripe: environment variable %s is empty", cfg.Billing.Stripe.APIKeyEnv)
+ }
+ if cfg.Billing.Stripe.WebhookSecret == "" {
+ return fmt.Errorf("billing.stripe: environment variable %s is empty", cfg.Billing.Stripe.WebhookSecretEnv)
+ }
+ if strings.TrimSpace(cfg.Billing.Stripe.SuccessURL) == "" || strings.TrimSpace(cfg.Billing.Stripe.CancelURL) == "" {
+ return errors.New("billing.stripe.success_url and cancel_url are required")
+ }
+ for name, value := range map[string]string{"success_url": cfg.Billing.Stripe.SuccessURL, "cancel_url": cfg.Billing.Stripe.CancelURL} {
+ parsed, err := url.Parse(value)
+ if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
+ return fmt.Errorf("billing.stripe.%s must be an absolute http(s) URL", name)
+ }
+ }
+ }
+ }
providers := make(map[string]ProviderConfig, len(cfg.Providers))
for _, provider := range cfg.Providers {
@@ -274,6 +354,9 @@ func Validate(cfg Config) error {
return fmt.Errorf("duplicate model id %q", model.ID)
}
models[model.ID] = struct{}{}
+ if model.InputPriceMicrosPerMillion < 0 || model.OutputPriceMicrosPerMillion < 0 || model.CacheReadPriceMicrosPerMillion < 0 || model.CacheWritePriceMicrosPerMillion < 0 {
+ return fmt.Errorf("model %q: prices cannot be negative", model.ID)
+ }
if len(model.Routes) == 0 {
return fmt.Errorf("model %q: at least one route is required", model.ID)
}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index e681508..2331b0f 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -85,6 +85,31 @@ func TestLoadRejectsAdminWithoutControlPlane(t *testing.T) {
}
}
+func TestLoadResolvesStripeSecrets(t *testing.T) {
+ t.Setenv("AIGW_DATABASE_URL", "postgres://aigw:aigw@postgres/aigw")
+ t.Setenv("AIGW_CREDENTIAL_KEY", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
+ t.Setenv("TEST_STRIPE_KEY", "rk_test_example")
+ t.Setenv("TEST_STRIPE_WEBHOOK", "whsec_example")
+ path := writeConfig(t, `{
+ "control_plane":{"enabled":true},
+ "billing":{"enabled":true,"stripe":{"enabled":true,"api_key_env":"TEST_STRIPE_KEY","webhook_secret_env":"TEST_STRIPE_WEBHOOK","success_url":"http://localhost/admin/?topup=success","cancel_url":"http://localhost/admin/?topup=cancel"}}
+}`)
+ cfg, err := Load(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.Billing.Stripe.APIKey != "rk_test_example" || cfg.Billing.Stripe.WebhookSecret != "whsec_example" {
+ t.Fatal("Stripe secrets were not resolved")
+ }
+}
+
+func TestLoadRejectsBillingWithoutControlPlane(t *testing.T) {
+ path := writeConfig(t, `{"billing":{"enabled":true}}`)
+ if _, err := Load(path); err == nil {
+ t.Fatal("expected billing without control plane to be rejected")
+ }
+}
+
func writeConfig(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.json")
diff --git a/internal/controlplane/access.go b/internal/controlplane/access.go
new file mode 100644
index 0000000..ec2d177
--- /dev/null
+++ b/internal/controlplane/access.go
@@ -0,0 +1,156 @@
+package controlplane
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "net/mail"
+ "strings"
+
+ "github.com/jackc/pgx/v5"
+)
+
+var ErrConsoleUnauthorized = errors.New("invalid console token")
+
+const (
+ RolePlatformAdmin = "platform_admin"
+ RolePlatformViewer = "platform_viewer"
+ RoleTenantAdmin = "tenant_admin"
+ RoleTenantBilling = "tenant_billing"
+ RoleTenantDeveloper = "tenant_developer"
+ RoleTenantViewer = "tenant_viewer"
+)
+
+func (a ConsoleActor) IsPlatform() bool { return strings.HasPrefix(a.Role, "platform_") }
+
+func (a ConsoleActor) Can(permission string) bool {
+ if a.Role == RolePlatformAdmin {
+ return true
+ }
+ read := strings.HasSuffix(permission, ".read")
+ switch a.Role {
+ case RolePlatformViewer:
+ return read
+ case RoleTenantAdmin:
+ switch permission {
+ case "overview.read", "tenants.read", "projects.read", "projects.write", "keys.read", "keys.write",
+ "billing.read", "billing.topup", "usage.read", "audit.read", "limits.read", "users.read", "users.write":
+ return true
+ }
+ return false
+ case RoleTenantBilling:
+ return permission == "overview.read" || permission == "billing.read" || permission == "billing.topup" || permission == "usage.read" || permission == "audit.read"
+ case RoleTenantDeveloper:
+ return permission == "overview.read" || permission == "tenants.read" || permission == "projects.read" || permission == "keys.read" || permission == "keys.write" || permission == "usage.read" || permission == "limits.read"
+ case RoleTenantViewer:
+ return permission == "overview.read" || permission == "tenants.read" || permission == "projects.read" || permission == "keys.read" || permission == "billing.read" || permission == "usage.read" || permission == "limits.read" || permission == "audit.read"
+ default:
+ return false
+ }
+}
+
+func (a ConsoleActor) Permissions() []string {
+ all := []string{"overview.read", "tenants.read", "tenants.write", "projects.read", "projects.write", "keys.read", "keys.write", "platform.read", "platform.write", "billing.read", "billing.topup", "billing.adjust", "usage.read", "limits.read", "limits.write", "users.read", "users.write", "audit.read"}
+ result := make([]string, 0, len(all))
+ for _, permission := range all {
+ if a.Can(permission) {
+ result = append(result, permission)
+ }
+ }
+ return result
+}
+
+func (s *Store) AuthenticateConsoleToken(ctx context.Context, raw string) (ConsoleActor, error) {
+ hash := sha256.Sum256([]byte(raw))
+ var actor ConsoleActor
+ err := s.db.QueryRow(ctx, `
+ UPDATE console_users SET last_used_at = now()
+ WHERE token_hash = $1 AND status = 'active'
+ RETURNING id::text, COALESCE(tenant_id::text, ''), email, display_name, role`, hash[:],
+ ).Scan(&actor.ID, &actor.TenantID, &actor.Email, &actor.DisplayName, &actor.Role)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return ConsoleActor{}, ErrConsoleUnauthorized
+ }
+ if err != nil {
+ return ConsoleActor{}, fmt.Errorf("authenticate console token: %w", err)
+ }
+ return actor, nil
+}
+
+func (s *Store) ListConsoleUsers(ctx context.Context, tenantID string) ([]ConsoleUser, error) {
+ query := `SELECT id::text, COALESCE(tenant_id::text, ''), email, display_name, role, token_prefix, status, last_used_at, created_at FROM console_users`
+ args := []any{}
+ if tenantID != "" {
+ query += ` WHERE tenant_id = $1`
+ args = append(args, tenantID)
+ }
+ query += ` ORDER BY created_at DESC`
+ rows, err := s.db.Query(ctx, query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("query console users: %w", err)
+ }
+ defer rows.Close()
+ result := make([]ConsoleUser, 0)
+ for rows.Next() {
+ var item ConsoleUser
+ if err := rows.Scan(&item.ID, &item.TenantID, &item.Email, &item.DisplayName, &item.Role, &item.TokenPrefix, &item.Status, &item.LastUsedAt, &item.CreatedAt); err != nil {
+ return nil, fmt.Errorf("scan console user: %w", err)
+ }
+ result = append(result, item)
+ }
+ return result, rows.Err()
+}
+
+func (s *Store) CreateConsoleUser(ctx context.Context, input CreateConsoleUserInput) (CreatedConsoleUser, error) {
+ input.Email = strings.ToLower(strings.TrimSpace(input.Email))
+ input.DisplayName = strings.TrimSpace(input.DisplayName)
+ input.TenantID = strings.TrimSpace(input.TenantID)
+ address, addressErr := mail.ParseAddress(input.Email)
+ if addressErr != nil || address.Address != input.Email || input.DisplayName == "" {
+ return CreatedConsoleUser{}, errors.New("console user requires a valid email and display_name")
+ }
+ platform := input.Role == RolePlatformAdmin || input.Role == RolePlatformViewer
+ tenant := input.Role == RoleTenantAdmin || input.Role == RoleTenantBilling || input.Role == RoleTenantDeveloper || input.Role == RoleTenantViewer
+ if (!platform && !tenant) || (platform && input.TenantID != "") || (tenant && input.TenantID == "") {
+ return CreatedConsoleUser{}, errors.New("console user role and tenant_id are inconsistent")
+ }
+ random := make([]byte, 32)
+ if _, err := rand.Read(random); err != nil {
+ return CreatedConsoleUser{}, fmt.Errorf("generate console token: %w", err)
+ }
+ raw := "cu-aigw-" + base64.RawURLEncoding.EncodeToString(random)
+ hash := sha256.Sum256([]byte(raw))
+ prefix := raw[:min(18, len(raw))] + "..."
+ var result CreatedConsoleUser
+ err := s.db.QueryRow(ctx, `
+ INSERT INTO console_users (tenant_id, email, display_name, role, token_prefix, token_hash)
+ VALUES (NULLIF($1,'')::uuid,$2,$3,$4,$5,$6)
+ RETURNING id::text, COALESCE(tenant_id::text, ''), email, display_name, role, token_prefix, status, last_used_at, created_at`,
+ input.TenantID, input.Email, input.DisplayName, input.Role, prefix, hash[:],
+ ).Scan(&result.ID, &result.TenantID, &result.Email, &result.DisplayName, &result.Role, &result.TokenPrefix, &result.Status, &result.LastUsedAt, &result.CreatedAt)
+ if err != nil {
+ return CreatedConsoleUser{}, fmt.Errorf("create console user: %w", err)
+ }
+ result.Token = raw
+ return result, nil
+}
+
+func (s *Store) RevokeConsoleUser(ctx context.Context, id, tenantID string) error {
+ query := `UPDATE console_users SET status='revoked', revoked_at=now() WHERE id=$1 AND status='active'`
+ args := []any{id}
+ if tenantID != "" {
+ query += ` AND tenant_id=$2`
+ args = append(args, tenantID)
+ }
+ result, err := s.db.Exec(ctx, query, args...)
+ if err != nil {
+ return err
+ }
+ if result.RowsAffected() == 0 {
+ return ErrNotFound
+ }
+ return nil
+}
diff --git a/internal/controlplane/access_test.go b/internal/controlplane/access_test.go
new file mode 100644
index 0000000..96e3869
--- /dev/null
+++ b/internal/controlplane/access_test.go
@@ -0,0 +1,39 @@
+package controlplane
+
+import "testing"
+
+func TestConsoleRolePermissions(t *testing.T) {
+ tests := []struct {
+ role, permission string
+ want bool
+ }{
+ {RolePlatformAdmin, "platform.write", true},
+ {RolePlatformViewer, "platform.read", true},
+ {RolePlatformViewer, "platform.write", false},
+ {RoleTenantAdmin, "keys.write", true},
+ {RoleTenantAdmin, "limits.write", false},
+ {RoleTenantBilling, "billing.topup", true},
+ {RoleTenantBilling, "keys.read", false},
+ {RoleTenantDeveloper, "keys.write", true},
+ {RoleTenantDeveloper, "billing.read", false},
+ {RoleTenantViewer, "usage.read", true},
+ {RoleTenantViewer, "users.read", false},
+ }
+ for _, test := range tests {
+ t.Run(test.role+"/"+test.permission, func(t *testing.T) {
+ if got := (ConsoleActor{Role: test.role}).Can(test.permission); got != test.want {
+ t.Fatalf("Can(%q) = %v, want %v", test.permission, got, test.want)
+ }
+ })
+ }
+}
+
+func TestTenantRoleNeverGetsPlatformPermissions(t *testing.T) {
+ roles := []string{RoleTenantAdmin, RoleTenantBilling, RoleTenantDeveloper, RoleTenantViewer}
+ for _, role := range roles {
+ actor := ConsoleActor{Role: role}
+ if actor.Can("platform.read") || actor.Can("platform.write") {
+ t.Fatalf("role %s received platform access", role)
+ }
+ }
+}
diff --git a/internal/controlplane/audit.go b/internal/controlplane/audit.go
new file mode 100644
index 0000000..93a4f58
--- /dev/null
+++ b/internal/controlplane/audit.go
@@ -0,0 +1,52 @@
+package controlplane
+
+import (
+ "context"
+ "fmt"
+ "strings"
+)
+
+func (s *Store) WriteAudit(ctx context.Context, input AuditInput) error {
+ actorType := "console_user"
+ if input.Actor.Bootstrap {
+ actorType = "bootstrap"
+ }
+ _, err := s.db.Exec(ctx, `INSERT INTO audit_logs
+ (actor_id, actor_type, actor_role, tenant_id, request_id, method, path, action, status_code, remote_ip, user_agent)
+ VALUES (NULLIF($1,'')::uuid,$2,$3,NULLIF($4,'')::uuid,$5,$6,$7,$8,$9,NULLIF($10,'')::inet,$11)`,
+ input.Actor.ID, actorType, input.Actor.Role, input.Actor.TenantID, input.RequestID, input.Method,
+ input.Path, input.Action, input.StatusCode, input.RemoteIP, input.UserAgent)
+ if err != nil {
+ return fmt.Errorf("write audit log: %w", err)
+ }
+ return nil
+}
+
+func (s *Store) ListAudit(ctx context.Context, tenantID string, limit int) ([]AuditLog, error) {
+ if limit < 1 || limit > 1000 {
+ limit = 200
+ }
+ query := `SELECT id, COALESCE(actor_id::text,''), actor_type, actor_role, COALESCE(tenant_id::text,''),
+ request_id, method, path, action, status_code, COALESCE(host(remote_ip),''), user_agent, created_at FROM audit_logs`
+ args := []any{}
+ if strings.TrimSpace(tenantID) != "" {
+ query += ` WHERE tenant_id=$1`
+ args = append(args, tenantID)
+ }
+ args = append(args, limit)
+ query += fmt.Sprintf(` ORDER BY created_at DESC LIMIT $%d`, len(args))
+ rows, err := s.db.Query(ctx, query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("query audit logs: %w", err)
+ }
+ defer rows.Close()
+ result := make([]AuditLog, 0)
+ for rows.Next() {
+ var item AuditLog
+ if err := rows.Scan(&item.ID, &item.ActorID, &item.ActorType, &item.ActorRole, &item.TenantID, &item.RequestID, &item.Method, &item.Path, &item.Action, &item.StatusCode, &item.RemoteIP, &item.UserAgent, &item.CreatedAt); err != nil {
+ return nil, fmt.Errorf("scan audit log: %w", err)
+ }
+ result = append(result, item)
+ }
+ return result, rows.Err()
+}
diff --git a/internal/controlplane/limits.go b/internal/controlplane/limits.go
new file mode 100644
index 0000000..5b5825e
--- /dev/null
+++ b/internal/controlplane/limits.go
@@ -0,0 +1,81 @@
+package controlplane
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/jackc/pgx/v5"
+)
+
+func (s *Store) ListProjectLimits(ctx context.Context, tenantID string) ([]ProjectLimit, error) {
+ query := `SELECT l.tenant_id::text, l.project_id::text, p.name, t.name,
+ l.requests_per_minute, l.tokens_per_minute, l.concurrent_requests, l.monthly_spend_micros, l.updated_at
+ FROM project_limits l JOIN projects p ON p.id=l.project_id JOIN tenants t ON t.id=l.tenant_id`
+ args := []any{}
+ if strings.TrimSpace(tenantID) != "" {
+ query += ` WHERE l.tenant_id=$1`
+ args = append(args, tenantID)
+ }
+ query += ` ORDER BY t.name, p.name`
+ rows, err := s.db.Query(ctx, query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("query project limits: %w", err)
+ }
+ defer rows.Close()
+ result := make([]ProjectLimit, 0)
+ for rows.Next() {
+ var item ProjectLimit
+ if err := rows.Scan(&item.TenantID, &item.ProjectID, &item.ProjectName, &item.TenantName,
+ &item.RequestsPerMinute, &item.TokensPerMinute, &item.Concurrent, &item.MonthlySpendMicros, &item.UpdatedAt); err != nil {
+ return nil, fmt.Errorf("scan project limit: %w", err)
+ }
+ result = append(result, item)
+ }
+ return result, rows.Err()
+}
+
+func (s *Store) SetProjectLimit(ctx context.Context, projectID string, input SetProjectLimitInput) (ProjectLimit, int64, error) {
+ if strings.TrimSpace(projectID) == "" || input.RequestsPerMinute < 0 || input.TokensPerMinute < 0 || input.ConcurrentRequests < 0 || input.MonthlySpendMicros < 0 {
+ return ProjectLimit{}, 0, errors.New("limit values cannot be negative and project_id is required")
+ }
+ tx, err := s.db.Begin(ctx)
+ if err != nil {
+ return ProjectLimit{}, 0, err
+ }
+ defer tx.Rollback(ctx)
+ var result ProjectLimit
+ err = tx.QueryRow(ctx, `
+ WITH project AS (
+ SELECT p.id, p.tenant_id, p.name AS project_name, t.name AS tenant_name
+ FROM projects p JOIN tenants t ON t.id=p.tenant_id WHERE p.id=$1
+ ), updated AS (
+ INSERT INTO project_limits (project_id, tenant_id, requests_per_minute, tokens_per_minute, concurrent_requests, monthly_spend_micros)
+ SELECT p.id, p.tenant_id, $2, $3, $4, $5 FROM project p
+ ON CONFLICT (project_id) DO UPDATE SET requests_per_minute=EXCLUDED.requests_per_minute,
+ tokens_per_minute=EXCLUDED.tokens_per_minute, concurrent_requests=EXCLUDED.concurrent_requests,
+ monthly_spend_micros=EXCLUDED.monthly_spend_micros, updated_at=now()
+ RETURNING tenant_id, project_id, requests_per_minute, tokens_per_minute, concurrent_requests, monthly_spend_micros, updated_at
+ )
+ SELECT u.tenant_id::text, u.project_id::text, p.project_name, p.tenant_name,
+ u.requests_per_minute, u.tokens_per_minute, u.concurrent_requests, u.monthly_spend_micros, u.updated_at
+ FROM updated u JOIN project p ON p.id=u.project_id`,
+ projectID, input.RequestsPerMinute, input.TokensPerMinute, input.ConcurrentRequests, input.MonthlySpendMicros,
+ ).Scan(&result.TenantID, &result.ProjectID, &result.ProjectName, &result.TenantName,
+ &result.RequestsPerMinute, &result.TokensPerMinute, &result.Concurrent, &result.MonthlySpendMicros, &result.UpdatedAt)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return ProjectLimit{}, 0, ErrNotFound
+ }
+ if err != nil {
+ return ProjectLimit{}, 0, fmt.Errorf("set project limit: %w", err)
+ }
+ generation, err := bumpGeneration(ctx, tx)
+ if err != nil {
+ return ProjectLimit{}, 0, err
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return ProjectLimit{}, 0, err
+ }
+ return result, generation, nil
+}
diff --git a/internal/controlplane/limits_integration_test.go b/internal/controlplane/limits_integration_test.go
new file mode 100644
index 0000000..8087bdd
--- /dev/null
+++ b/internal/controlplane/limits_integration_test.go
@@ -0,0 +1,62 @@
+package controlplane
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "testing"
+ "time"
+)
+
+func TestSetProjectLimitPostgres(t *testing.T) {
+ databaseURL := os.Getenv("AIGW_TEST_DATABASE_URL")
+ if databaseURL == "" {
+ t.Skip("AIGW_TEST_DATABASE_URL is not set")
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ if err := MigrateDatabase(ctx, databaseURL); err != nil {
+ t.Fatal(err)
+ }
+ store, err := NewStore(ctx, Options{
+ DatabaseURL: databaseURL,
+ CredentialKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ if err := store.Close(); err != nil {
+ t.Errorf("close control-plane store: %v", err)
+ }
+ })
+
+ suffix := time.Now().UnixNano()
+ tenant, _, err := store.CreateTenant(ctx, CreateTenantInput{Slug: fmt.Sprintf("limits-%d", suffix), Name: "Limits tenant"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ if _, cleanupErr := store.db.Exec(context.Background(), `DELETE FROM tenants WHERE id=$1`, tenant.ID); cleanupErr != nil {
+ t.Errorf("cleanup limit integration data: %v", cleanupErr)
+ }
+ })
+ project, _, err := store.CreateProject(ctx, CreateProjectInput{TenantID: tenant.ID, Slug: "limits-project", Name: "Limits project"})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ got, _, err := store.SetProjectLimit(ctx, project.ID, SetProjectLimitInput{
+ RequestsPerMinute: 10, TokensPerMinute: 20, ConcurrentRequests: 3, MonthlySpendMicros: 40,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.TenantID != tenant.ID || got.ProjectID != project.ID || got.TenantName != tenant.Name || got.ProjectName != project.Name {
+ t.Fatalf("unexpected limit identity: %+v", got)
+ }
+ if got.RequestsPerMinute != 10 || got.TokensPerMinute != 20 || got.Concurrent != 3 || got.MonthlySpendMicros != 40 {
+ t.Fatalf("unexpected limit values: %+v", got)
+ }
+}
diff --git a/internal/controlplane/manager.go b/internal/controlplane/manager.go
index 42f5efe..cdafc36 100644
--- a/internal/controlplane/manager.go
+++ b/internal/controlplane/manager.go
@@ -10,6 +10,7 @@ import (
"aigw/internal/auth"
"aigw/internal/catalog"
+ "aigw/internal/domain"
)
const broadcastQueueSize = 128
@@ -22,6 +23,10 @@ type managerStore interface {
RedisEnabled() bool
}
+type policyReplacer interface {
+ ReplacePolicies([]domain.LimitPolicy)
+}
+
type Manager struct {
store managerStore
catalog *catalog.Catalog
@@ -32,15 +37,20 @@ type Manager struct {
redisConnected atomic.Bool
reloadMu sync.Mutex
broadcasts chan ChangeEvent
+ policyTarget policyReplacer
}
-func NewManager(store managerStore, modelCatalog *catalog.Catalog, authenticator *auth.StaticAuthenticator, logger *slog.Logger, pollInterval time.Duration) *Manager {
+func NewManager(store managerStore, modelCatalog *catalog.Catalog, authenticator *auth.StaticAuthenticator, logger *slog.Logger, pollInterval time.Duration, policyTargets ...policyReplacer) *Manager {
if pollInterval <= 0 {
pollInterval = 30 * time.Second
}
+ var policyTarget policyReplacer
+ if len(policyTargets) > 0 {
+ policyTarget = policyTargets[0]
+ }
return &Manager{
store: store, catalog: modelCatalog, authenticator: authenticator,
- logger: logger, pollInterval: pollInterval, broadcasts: make(chan ChangeEvent, broadcastQueueSize),
+ logger: logger, pollInterval: pollInterval, broadcasts: make(chan ChangeEvent, broadcastQueueSize), policyTarget: policyTarget,
}
}
@@ -53,6 +63,9 @@ func (m *Manager) Reload(ctx context.Context) (int64, error) {
}
m.catalog.Replace(snapshot.Models)
m.authenticator.ReplaceHashed(snapshot.APIKeys)
+ if m.policyTarget != nil {
+ m.policyTarget.ReplacePolicies(snapshot.Limits)
+ }
m.generation.Store(snapshot.Generation)
m.logger.Info("control_plane_reloaded", "generation", snapshot.Generation, "models", len(snapshot.Models), "api_keys", len(snapshot.APIKeys))
return snapshot.Generation, nil
@@ -195,7 +208,10 @@ func (m *Manager) runBroadcasts(ctx context.Context) {
err := m.store.PublishChange(publishContext, event)
cancel()
if err != nil {
+ m.redisConnected.Store(false)
m.logger.Warn("control_plane_publish_failed", "generation", event.Generation, "resource", event.Resource, "id", event.ID, "error", err)
+ } else {
+ m.redisConnected.Store(true)
}
}
}
diff --git a/internal/controlplane/manager_test.go b/internal/controlplane/manager_test.go
index 8dd4012..ee78a00 100644
--- a/internal/controlplane/manager_test.go
+++ b/internal/controlplane/manager_test.go
@@ -13,6 +13,7 @@ import (
"aigw/internal/auth"
"aigw/internal/catalog"
+ "aigw/internal/domain"
)
type fakeManagerStore struct {
@@ -26,6 +27,12 @@ type fakeManagerStore struct {
subscribe func(context.Context, int64) (<-chan ChangeMessage, func() error, error)
}
+type capturePolicies struct{ values []domain.LimitPolicy }
+
+func (c *capturePolicies) ReplacePolicies(values []domain.LimitPolicy) {
+ c.values = append([]domain.LimitPolicy(nil), values...)
+}
+
type safeLogBuffer struct {
mu sync.Mutex
buf bytes.Buffer
@@ -199,6 +206,19 @@ func TestRedisCanBeDisabled(t *testing.T) {
}
}
+func TestReloadReplacesLimitPolicySnapshot(t *testing.T) {
+ store := newFakeManagerStore(4)
+ store.snapshot.Store(Snapshot{Generation: 4, Limits: []domain.LimitPolicy{{ProjectID: "project-1", Concurrent: 3}}})
+ target := &capturePolicies{}
+ manager := NewManager(store, catalog.NewModels(nil), auth.NewDynamic(nil, false), slog.Default(), time.Second, target)
+ if _, err := manager.Reload(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if len(target.values) != 1 || target.values[0].Concurrent != 3 {
+ t.Fatalf("unexpected policies: %+v", target.values)
+ }
+}
+
func waitUntil(t *testing.T, timeout time.Duration, condition func() bool) {
t.Helper()
deadline := time.Now().Add(timeout)
diff --git a/internal/controlplane/mutations.go b/internal/controlplane/mutations.go
index a781994..3cedf70 100644
--- a/internal/controlplane/mutations.go
+++ b/internal/controlplane/mutations.go
@@ -176,6 +176,9 @@ func (s *Store) CreateModel(ctx context.Context, input CreateModelInput) (Model,
if input.PublicID == "" || len(input.Routes) == 0 {
return Model{}, 0, errors.New("model requires public_id and at least one route")
}
+ if input.InputPriceMicrosPerMillion < 0 || input.OutputPriceMicrosPerMillion < 0 || input.CacheReadPriceMicrosPerMillion < 0 || input.CacheWritePriceMicrosPerMillion < 0 {
+ return Model{}, 0, errors.New("model prices cannot be negative")
+ }
for i := range input.Routes {
input.Routes[i].ProviderID = strings.TrimSpace(input.Routes[i].ProviderID)
input.Routes[i].UpstreamModel = strings.TrimSpace(input.Routes[i].UpstreamModel)
@@ -193,9 +196,16 @@ func (s *Store) CreateModel(ctx context.Context, input CreateModelInput) (Model,
defer tx.Rollback(ctx)
var result Model
err = tx.QueryRow(ctx, `
- INSERT INTO models (public_id, owned_by) VALUES ($1, $2)
- RETURNING id::text, public_id, owned_by, enabled, created_at`, input.PublicID, input.OwnedBy,
- ).Scan(&result.ID, &result.PublicID, &result.OwnedBy, &result.Enabled, &result.CreatedAt)
+ INSERT INTO models (public_id, owned_by, input_price_micros_per_million, output_price_micros_per_million,
+ cache_read_price_micros_per_million, cache_write_price_micros_per_million)
+ VALUES ($1, $2, $3, $4, $5, $6)
+ RETURNING id::text, public_id, owned_by, input_price_micros_per_million, output_price_micros_per_million,
+ cache_read_price_micros_per_million, cache_write_price_micros_per_million, enabled, created_at`,
+ input.PublicID, input.OwnedBy, input.InputPriceMicrosPerMillion, input.OutputPriceMicrosPerMillion,
+ input.CacheReadPriceMicrosPerMillion, input.CacheWritePriceMicrosPerMillion,
+ ).Scan(&result.ID, &result.PublicID, &result.OwnedBy, &result.InputPriceMicrosPerMillion,
+ &result.OutputPriceMicrosPerMillion, &result.CacheReadPriceMicrosPerMillion,
+ &result.CacheWritePriceMicrosPerMillion, &result.Enabled, &result.CreatedAt)
if err != nil {
return Model{}, 0, fmt.Errorf("create model: %w", err)
}
diff --git a/internal/controlplane/queries.go b/internal/controlplane/queries.go
index 732cb46..9b76fad 100644
--- a/internal/controlplane/queries.go
+++ b/internal/controlplane/queries.go
@@ -24,7 +24,18 @@ func (s *Store) Overview(ctx context.Context) (Overview, error) {
}
func (s *Store) ListTenants(ctx context.Context) ([]Tenant, error) {
- rows, err := s.db.Query(ctx, `SELECT id::text, slug, name, status, created_at FROM tenants ORDER BY created_at DESC`)
+ return s.ListTenantsFor(ctx, "")
+}
+
+func (s *Store) ListTenantsFor(ctx context.Context, tenantID string) ([]Tenant, error) {
+ query := `SELECT id::text, slug, name, status, created_at FROM tenants`
+ args := []any{}
+ if tenantID != "" {
+ query += ` WHERE id=$1`
+ args = append(args, tenantID)
+ }
+ query += ` ORDER BY created_at DESC`
+ rows, err := s.db.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("query tenants: %w", err)
}
@@ -41,7 +52,18 @@ func (s *Store) ListTenants(ctx context.Context) ([]Tenant, error) {
}
func (s *Store) ListProjects(ctx context.Context) ([]Project, error) {
- rows, err := s.db.Query(ctx, `SELECT id::text, tenant_id::text, slug, name, status, created_at FROM projects ORDER BY created_at DESC`)
+ return s.ListProjectsFor(ctx, "")
+}
+
+func (s *Store) ListProjectsFor(ctx context.Context, tenantID string) ([]Project, error) {
+ query := `SELECT id::text, tenant_id::text, slug, name, status, created_at FROM projects`
+ args := []any{}
+ if tenantID != "" {
+ query += ` WHERE tenant_id=$1`
+ args = append(args, tenantID)
+ }
+ query += ` ORDER BY created_at DESC`
+ rows, err := s.db.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("query projects: %w", err)
}
@@ -58,9 +80,20 @@ func (s *Store) ListProjects(ctx context.Context) ([]Project, error) {
}
func (s *Store) ListAPIKeys(ctx context.Context) ([]APIKey, error) {
- rows, err := s.db.Query(ctx, `
+ return s.ListAPIKeysFor(ctx, "")
+}
+
+func (s *Store) ListAPIKeysFor(ctx context.Context, tenantID string) ([]APIKey, error) {
+ query := `
SELECT id::text, tenant_id::text, project_id::text, name, key_prefix, scopes, status, created_at
- FROM api_keys ORDER BY created_at DESC`)
+ FROM api_keys`
+ args := []any{}
+ if tenantID != "" {
+ query += ` WHERE tenant_id=$1`
+ args = append(args, tenantID)
+ }
+ query += ` ORDER BY created_at DESC`
+ rows, err := s.db.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("query API keys: %w", err)
}
@@ -80,6 +113,44 @@ func (s *Store) ListAPIKeys(ctx context.Context) ([]APIKey, error) {
return result, rows.Err()
}
+func (s *Store) OverviewFor(ctx context.Context, tenantID string) (Overview, error) {
+ if tenantID == "" {
+ return s.Overview(ctx)
+ }
+ var result Overview
+ err := s.db.QueryRow(ctx, `SELECT
+ (SELECT generation FROM control_state WHERE singleton=TRUE),
+ (SELECT count(*) FROM tenants WHERE id=$1 AND status='active'),
+ (SELECT count(*) FROM projects WHERE tenant_id=$1 AND status='active'),
+ (SELECT count(*) FROM api_keys WHERE tenant_id=$1 AND status='active'),
+ 0,
+ (SELECT count(*) FROM models WHERE enabled=TRUE)`, tenantID,
+ ).Scan(&result.Generation, &result.Tenants, &result.Projects, &result.APIKeys, &result.Providers, &result.Models)
+ if err != nil {
+ return Overview{}, fmt.Errorf("query tenant overview: %w", err)
+ }
+ return result, nil
+}
+
+func (s *Store) ResourceTenantID(ctx context.Context, resource, id string) (string, error) {
+ var query string
+ switch resource {
+ case "project":
+ query = `SELECT tenant_id::text FROM projects WHERE id=$1`
+ case "api_key":
+ query = `SELECT tenant_id::text FROM api_keys WHERE id=$1`
+ case "console_user":
+ query = `SELECT COALESCE(tenant_id::text,'') FROM console_users WHERE id=$1`
+ default:
+ return "", ErrNotFound
+ }
+ var tenantID string
+ if err := s.db.QueryRow(ctx, query, id).Scan(&tenantID); err != nil {
+ return "", ErrNotFound
+ }
+ return tenantID, nil
+}
+
func (s *Store) ListProviders(ctx context.Context) ([]Provider, error) {
rows, err := s.db.Query(ctx, `
SELECT p.id::text, p.name, p.protocol, p.base_url, p.enabled, count(r.id), p.created_at
@@ -101,7 +172,11 @@ func (s *Store) ListProviders(ctx context.Context) ([]Provider, error) {
}
func (s *Store) ListModels(ctx context.Context) ([]Model, error) {
- rows, err := s.db.Query(ctx, `SELECT id::text, public_id, owned_by, enabled, created_at FROM models ORDER BY public_id`)
+ rows, err := s.db.Query(ctx, `
+ SELECT id::text, public_id, owned_by, input_price_micros_per_million,
+ output_price_micros_per_million, cache_read_price_micros_per_million,
+ cache_write_price_micros_per_million, enabled, created_at
+ FROM models ORDER BY public_id`)
if err != nil {
return nil, fmt.Errorf("query models: %w", err)
}
@@ -109,7 +184,9 @@ func (s *Store) ListModels(ctx context.Context) ([]Model, error) {
positions := make(map[string]int)
for rows.Next() {
var item Model
- if err := rows.Scan(&item.ID, &item.PublicID, &item.OwnedBy, &item.Enabled, &item.CreatedAt); err != nil {
+ if err := rows.Scan(&item.ID, &item.PublicID, &item.OwnedBy, &item.InputPriceMicrosPerMillion,
+ &item.OutputPriceMicrosPerMillion, &item.CacheReadPriceMicrosPerMillion,
+ &item.CacheWritePriceMicrosPerMillion, &item.Enabled, &item.CreatedAt); err != nil {
rows.Close()
return nil, fmt.Errorf("scan model: %w", err)
}
diff --git a/internal/controlplane/schema.sql b/internal/controlplane/schema.sql
index 25af7c9..a518b1d 100644
--- a/internal/controlplane/schema.sql
+++ b/internal/controlplane/schema.sql
@@ -58,11 +58,20 @@ CREATE TABLE IF NOT EXISTS models (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
public_id TEXT NOT NULL UNIQUE,
owned_by TEXT NOT NULL DEFAULT '',
+ input_price_micros_per_million BIGINT NOT NULL DEFAULT 0 CHECK (input_price_micros_per_million >= 0),
+ output_price_micros_per_million BIGINT NOT NULL DEFAULT 0 CHECK (output_price_micros_per_million >= 0),
+ cache_read_price_micros_per_million BIGINT NOT NULL DEFAULT 0 CHECK (cache_read_price_micros_per_million >= 0),
+ cache_write_price_micros_per_million BIGINT NOT NULL DEFAULT 0 CHECK (cache_write_price_micros_per_million >= 0),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
+ALTER TABLE models ADD COLUMN IF NOT EXISTS input_price_micros_per_million BIGINT NOT NULL DEFAULT 0 CHECK (input_price_micros_per_million >= 0);
+ALTER TABLE models ADD COLUMN IF NOT EXISTS output_price_micros_per_million BIGINT NOT NULL DEFAULT 0 CHECK (output_price_micros_per_million >= 0);
+ALTER TABLE models ADD COLUMN IF NOT EXISTS cache_read_price_micros_per_million BIGINT NOT NULL DEFAULT 0 CHECK (cache_read_price_micros_per_million >= 0);
+ALTER TABLE models ADD COLUMN IF NOT EXISTS cache_write_price_micros_per_million BIGINT NOT NULL DEFAULT 0 CHECK (cache_write_price_micros_per_million >= 0);
+
CREATE TABLE IF NOT EXISTS model_routes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
model_id UUID NOT NULL REFERENCES models(id) ON DELETE CASCADE,
@@ -80,3 +89,173 @@ CREATE INDEX IF NOT EXISTS api_keys_active_hash_idx ON api_keys (key_hash) WHERE
CREATE INDEX IF NOT EXISTS projects_tenant_idx ON projects (tenant_id);
CREATE INDEX IF NOT EXISTS model_routes_model_idx ON model_routes (model_id) WHERE enabled;
CREATE INDEX IF NOT EXISTS model_routes_provider_idx ON model_routes (provider_id) WHERE enabled;
+
+CREATE TABLE IF NOT EXISTS tenant_wallets (
+ tenant_id UUID PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE,
+ currency TEXT NOT NULL CHECK (currency = lower(currency) AND length(currency) = 3),
+ balance_micros BIGINT NOT NULL DEFAULT 0 CHECK (balance_micros >= 0),
+ reserved_micros BIGINT NOT NULL DEFAULT 0 CHECK (reserved_micros >= 0 AND reserved_micros <= balance_micros),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS billing_reservations (
+ request_id TEXT PRIMARY KEY,
+ tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
+ project_id UUID NOT NULL,
+ key_id UUID NOT NULL,
+ public_model TEXT NOT NULL,
+ currency TEXT NOT NULL,
+ reserved_micros BIGINT NOT NULL CHECK (reserved_micros >= 0),
+ status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'settled', 'released')),
+ input_price_micros_per_million BIGINT NOT NULL,
+ output_price_micros_per_million BIGINT NOT NULL,
+ cache_read_price_micros_per_million BIGINT NOT NULL,
+ cache_write_price_micros_per_million BIGINT NOT NULL,
+ actual_cost_micros BIGINT NOT NULL DEFAULT 0 CHECK (actual_cost_micros >= 0),
+ charged_micros BIGINT NOT NULL DEFAULT 0 CHECK (charged_micros >= 0),
+ uncollected_micros BIGINT NOT NULL DEFAULT 0 CHECK (uncollected_micros >= 0),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ settled_at TIMESTAMPTZ,
+ FOREIGN KEY (project_id, tenant_id) REFERENCES projects(id, tenant_id) ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS usage_events (
+ request_id TEXT PRIMARY KEY,
+ tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
+ project_id UUID NOT NULL,
+ key_id UUID NOT NULL,
+ public_model TEXT NOT NULL,
+ provider_id TEXT,
+ upstream_model TEXT,
+ protocol TEXT NOT NULL,
+ stream BOOLEAN NOT NULL DEFAULT FALSE,
+ status_code INTEGER NOT NULL,
+ success BOOLEAN NOT NULL,
+ error_type TEXT NOT NULL DEFAULT '',
+ attempts INTEGER NOT NULL DEFAULT 0,
+ started_at TIMESTAMPTZ NOT NULL,
+ duration_ms BIGINT NOT NULL DEFAULT 0,
+ input_tokens BIGINT NOT NULL DEFAULT 0,
+ output_tokens BIGINT NOT NULL DEFAULT 0,
+ total_tokens BIGINT NOT NULL DEFAULT 0,
+ cache_creation_input_tokens BIGINT NOT NULL DEFAULT 0,
+ cache_read_input_tokens BIGINT NOT NULL DEFAULT 0,
+ cost_micros BIGINT NOT NULL DEFAULT 0,
+ charged_micros BIGINT NOT NULL DEFAULT 0,
+ uncollected_micros BIGINT NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ FOREIGN KEY (project_id, tenant_id) REFERENCES projects(id, tenant_id) ON DELETE RESTRICT
+);
+
+-- Usage persistence is independent from billing. Older installations created this
+-- foreign key, which prevented recording requests when prepaid billing was disabled.
+ALTER TABLE usage_events DROP CONSTRAINT IF EXISTS usage_events_request_id_fkey;
+
+CREATE TABLE IF NOT EXISTS billing_ledger (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
+ project_id UUID,
+ currency TEXT NOT NULL,
+ amount_micros BIGINT NOT NULL,
+ balance_after_micros BIGINT NOT NULL CHECK (balance_after_micros >= 0),
+ kind TEXT NOT NULL CHECK (kind IN ('topup', 'usage', 'adjustment', 'refund', 'release')),
+ source_type TEXT NOT NULL,
+ source_id TEXT NOT NULL,
+ description TEXT NOT NULL DEFAULT '',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ UNIQUE (source_type, source_id)
+);
+
+CREATE TABLE IF NOT EXISTS topup_orders (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
+ amount_minor BIGINT NOT NULL CHECK (amount_minor > 0),
+ amount_micros BIGINT NOT NULL CHECK (amount_micros > 0),
+ currency TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'paid', 'failed', 'expired')),
+ stripe_session_id TEXT UNIQUE,
+ checkout_url TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ paid_at TIMESTAMPTZ
+);
+
+CREATE TABLE IF NOT EXISTS stripe_webhook_events (
+ event_id TEXT PRIMARY KEY,
+ event_type TEXT NOT NULL,
+ processed_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS console_users (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
+ email TEXT NOT NULL,
+ display_name TEXT NOT NULL,
+ role TEXT NOT NULL CHECK (role IN (
+ 'platform_admin', 'platform_viewer', 'tenant_admin',
+ 'tenant_billing', 'tenant_developer', 'tenant_viewer'
+ )),
+ token_prefix TEXT NOT NULL,
+ token_hash BYTEA NOT NULL UNIQUE CHECK (octet_length(token_hash) = 32),
+ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked')),
+ last_used_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ revoked_at TIMESTAMPTZ,
+ CHECK ((role LIKE 'platform_%' AND tenant_id IS NULL) OR (role LIKE 'tenant_%' AND tenant_id IS NOT NULL))
+);
+CREATE UNIQUE INDEX IF NOT EXISTS console_users_email_tenant_idx
+ ON console_users (lower(email), COALESCE(tenant_id, '00000000-0000-0000-0000-000000000000'::uuid));
+
+CREATE TABLE IF NOT EXISTS project_limits (
+ project_id UUID PRIMARY KEY,
+ tenant_id UUID NOT NULL,
+ requests_per_minute BIGINT NOT NULL DEFAULT 0 CHECK (requests_per_minute >= 0),
+ tokens_per_minute BIGINT NOT NULL DEFAULT 0 CHECK (tokens_per_minute >= 0),
+ concurrent_requests BIGINT NOT NULL DEFAULT 0 CHECK (concurrent_requests >= 0),
+ monthly_spend_micros BIGINT NOT NULL DEFAULT 0 CHECK (monthly_spend_micros >= 0),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ FOREIGN KEY (project_id, tenant_id) REFERENCES projects(id, tenant_id) ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS usage_monthly_rollups (
+ period_start DATE NOT NULL,
+ tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
+ project_id UUID NOT NULL,
+ request_count BIGINT NOT NULL DEFAULT 0,
+ successful_requests BIGINT NOT NULL DEFAULT 0,
+ input_tokens BIGINT NOT NULL DEFAULT 0,
+ output_tokens BIGINT NOT NULL DEFAULT 0,
+ total_tokens BIGINT NOT NULL DEFAULT 0,
+ cost_micros BIGINT NOT NULL DEFAULT 0,
+ charged_micros BIGINT NOT NULL DEFAULT 0,
+ uncollected_micros BIGINT NOT NULL DEFAULT 0,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ PRIMARY KEY (project_id, period_start),
+ FOREIGN KEY (project_id, tenant_id) REFERENCES projects(id, tenant_id) ON DELETE RESTRICT
+);
+
+CREATE TABLE IF NOT EXISTS audit_logs (
+ id BIGSERIAL PRIMARY KEY,
+ actor_id UUID REFERENCES console_users(id) ON DELETE SET NULL,
+ actor_type TEXT NOT NULL CHECK (actor_type IN ('bootstrap', 'console_user')),
+ actor_role TEXT NOT NULL,
+ tenant_id UUID REFERENCES tenants(id) ON DELETE SET NULL,
+ request_id TEXT NOT NULL,
+ method TEXT NOT NULL,
+ path TEXT NOT NULL,
+ action TEXT NOT NULL,
+ status_code INTEGER NOT NULL,
+ remote_ip INET,
+ user_agent TEXT NOT NULL DEFAULT '',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS billing_ledger_tenant_idx ON billing_ledger (tenant_id, created_at DESC);
+CREATE INDEX IF NOT EXISTS usage_events_tenant_idx ON usage_events (tenant_id, created_at DESC);
+CREATE INDEX IF NOT EXISTS usage_events_project_idx ON usage_events (project_id, created_at DESC);
+CREATE INDEX IF NOT EXISTS usage_events_model_idx ON usage_events (public_model, created_at DESC);
+CREATE INDEX IF NOT EXISTS billing_reservations_pending_idx ON billing_reservations (status, created_at) WHERE status = 'pending';
+CREATE INDEX IF NOT EXISTS billing_reservations_project_pending_idx ON billing_reservations (project_id, created_at) WHERE status = 'pending';
+CREATE INDEX IF NOT EXISTS console_users_tenant_idx ON console_users (tenant_id, created_at DESC);
+CREATE INDEX IF NOT EXISTS audit_logs_created_idx ON audit_logs (created_at DESC);
+CREATE INDEX IF NOT EXISTS audit_logs_tenant_idx ON audit_logs (tenant_id, created_at DESC);
diff --git a/internal/controlplane/snapshot.go b/internal/controlplane/snapshot.go
index c8931ab..bc04e7c 100644
--- a/internal/controlplane/snapshot.go
+++ b/internal/controlplane/snapshot.go
@@ -37,12 +37,40 @@ func (s *Store) LoadSnapshot(ctx context.Context) (Snapshot, error) {
if err != nil {
return Snapshot{}, err
}
+ result.Limits, err = loadLimitPolicies(ctx, tx)
+ if err != nil {
+ return Snapshot{}, err
+ }
if err := tx.Commit(ctx); err != nil {
return Snapshot{}, fmt.Errorf("commit snapshot transaction: %w", err)
}
return result, nil
}
+func loadLimitPolicies(ctx context.Context, tx pgx.Tx) ([]domain.LimitPolicy, error) {
+ rows, err := tx.Query(ctx, `
+ SELECT tenant_id::text, project_id::text, requests_per_minute, tokens_per_minute,
+ concurrent_requests, monthly_spend_micros
+ FROM project_limits`)
+ if err != nil {
+ return nil, fmt.Errorf("query project limits: %w", err)
+ }
+ defer rows.Close()
+ result := make([]domain.LimitPolicy, 0)
+ for rows.Next() {
+ var policy domain.LimitPolicy
+ if err := rows.Scan(&policy.TenantID, &policy.ProjectID, &policy.RequestsPerMinute,
+ &policy.TokensPerMinute, &policy.Concurrent, &policy.MonthlySpendMicros); err != nil {
+ return nil, fmt.Errorf("scan project limit: %w", err)
+ }
+ result = append(result, policy)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("read project limits: %w", err)
+ }
+ return result, nil
+}
+
func (s *Store) loadProviders(ctx context.Context, tx pgx.Tx) (map[string]domain.Provider, error) {
rows, err := tx.Query(ctx, `
SELECT id::text, name, protocol, base_url, api_key_ciphertext
@@ -74,7 +102,9 @@ func (s *Store) loadProviders(ctx context.Context, tx pgx.Tx) (map[string]domain
func loadModels(ctx context.Context, tx pgx.Tx, providers map[string]domain.Provider) ([]domain.Model, error) {
rows, err := tx.Query(ctx, `
- SELECT m.public_id, m.owned_by, r.provider_id::text, r.upstream_model, r.priority, r.weight
+ SELECT m.public_id, m.owned_by, m.input_price_micros_per_million, m.output_price_micros_per_million,
+ m.cache_read_price_micros_per_million, m.cache_write_price_micros_per_million,
+ r.provider_id::text, r.upstream_model, r.priority, r.weight
FROM models m
JOIN model_routes r ON r.model_id = m.id AND r.enabled = TRUE
JOIN providers p ON p.id = r.provider_id AND p.enabled = TRUE
@@ -88,8 +118,9 @@ func loadModels(ctx context.Context, tx pgx.Tx, providers map[string]domain.Prov
index := make(map[string]int)
for rows.Next() {
var publicID, ownedBy, providerID, upstreamModel string
+ var inputPrice, outputPrice, cacheReadPrice, cacheWritePrice int64
var priority, weight int
- if err := rows.Scan(&publicID, &ownedBy, &providerID, &upstreamModel, &priority, &weight); err != nil {
+ if err := rows.Scan(&publicID, &ownedBy, &inputPrice, &outputPrice, &cacheReadPrice, &cacheWritePrice, &providerID, &upstreamModel, &priority, &weight); err != nil {
return nil, fmt.Errorf("scan model route: %w", err)
}
provider, ok := providers[providerID]
@@ -100,7 +131,11 @@ func loadModels(ctx context.Context, tx pgx.Tx, providers map[string]domain.Prov
if !exists {
position = len(models)
index[publicID] = position
- models = append(models, domain.Model{ID: publicID, OwnedBy: ownedBy})
+ models = append(models, domain.Model{
+ ID: publicID, OwnedBy: ownedBy,
+ InputPriceMicrosPerMillion: inputPrice, OutputPriceMicrosPerMillion: outputPrice,
+ CacheReadPriceMicrosPerMillion: cacheReadPrice, CacheWritePriceMicrosPerMillion: cacheWritePrice,
+ })
}
models[position].Routes = append(models[position].Routes, domain.Route{
Provider: provider, UpstreamModel: upstreamModel, Priority: priority, Weight: weight,
diff --git a/internal/controlplane/types.go b/internal/controlplane/types.go
index 24f2843..7dfb734 100644
--- a/internal/controlplane/types.go
+++ b/internal/controlplane/types.go
@@ -62,30 +62,38 @@ type Route struct {
}
type Model struct {
- ID string `json:"id"`
- PublicID string `json:"public_id"`
- OwnedBy string `json:"owned_by"`
- Enabled bool `json:"enabled"`
- Routes []Route `json:"routes"`
- CreatedAt time.Time `json:"created_at"`
+ ID string `json:"id"`
+ PublicID string `json:"public_id"`
+ OwnedBy string `json:"owned_by"`
+ InputPriceMicrosPerMillion int64 `json:"input_price_micros_per_million"`
+ OutputPriceMicrosPerMillion int64 `json:"output_price_micros_per_million"`
+ CacheReadPriceMicrosPerMillion int64 `json:"cache_read_price_micros_per_million"`
+ CacheWritePriceMicrosPerMillion int64 `json:"cache_write_price_micros_per_million"`
+ Enabled bool `json:"enabled"`
+ Routes []Route `json:"routes"`
+ CreatedAt time.Time `json:"created_at"`
}
type Overview struct {
- Generation int64 `json:"generation"`
- RuntimeGeneration int64 `json:"runtime_generation"`
- RedisConfigured bool `json:"redis_configured"`
- RedisConnected bool `json:"redis_connected"`
- Tenants int64 `json:"tenants"`
- Projects int64 `json:"projects"`
- APIKeys int64 `json:"api_keys"`
- Providers int64 `json:"providers"`
- Models int64 `json:"models"`
+ Generation int64 `json:"generation"`
+ RuntimeGeneration int64 `json:"runtime_generation"`
+ RedisConfigured bool `json:"redis_configured"`
+ RedisConnected bool `json:"redis_connected"`
+ Tenants int64 `json:"tenants"`
+ Projects int64 `json:"projects"`
+ APIKeys int64 `json:"api_keys"`
+ Providers int64 `json:"providers"`
+ Models int64 `json:"models"`
+ BillingEnabled bool `json:"billing_enabled"`
+ StripeEnabled bool `json:"stripe_enabled"`
+ BillingCurrency string `json:"billing_currency,omitempty"`
}
type Snapshot struct {
Generation int64
Models []domain.Model
APIKeys []auth.HashedKeyRecord
+ Limits []domain.LimitPolicy
}
type ChangeEvent struct {
@@ -132,7 +140,126 @@ type RouteInput struct {
}
type CreateModelInput struct {
- PublicID string `json:"public_id"`
- OwnedBy string `json:"owned_by"`
- Routes []RouteInput `json:"routes"`
+ PublicID string `json:"public_id"`
+ OwnedBy string `json:"owned_by"`
+ InputPriceMicrosPerMillion int64 `json:"input_price_micros_per_million"`
+ OutputPriceMicrosPerMillion int64 `json:"output_price_micros_per_million"`
+ CacheReadPriceMicrosPerMillion int64 `json:"cache_read_price_micros_per_million"`
+ CacheWritePriceMicrosPerMillion int64 `json:"cache_write_price_micros_per_million"`
+ Routes []RouteInput `json:"routes"`
+}
+
+type ConsoleActor struct {
+ ID string `json:"id,omitempty"`
+ TenantID string `json:"tenant_id,omitempty"`
+ Email string `json:"email"`
+ DisplayName string `json:"display_name"`
+ Role string `json:"role"`
+ Bootstrap bool `json:"bootstrap"`
+}
+
+type ConsoleUser struct {
+ ID string `json:"id"`
+ TenantID string `json:"tenant_id,omitempty"`
+ Email string `json:"email"`
+ DisplayName string `json:"display_name"`
+ Role string `json:"role"`
+ TokenPrefix string `json:"token_prefix"`
+ Status string `json:"status"`
+ LastUsedAt *time.Time `json:"last_used_at,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+type CreatedConsoleUser struct {
+ ConsoleUser
+ Token string `json:"token"`
+}
+
+type CreateConsoleUserInput struct {
+ TenantID string `json:"tenant_id"`
+ Email string `json:"email"`
+ DisplayName string `json:"display_name"`
+ Role string `json:"role"`
+}
+
+type ProjectLimit struct {
+ domain.LimitPolicy
+ ProjectName string `json:"project_name"`
+ TenantName string `json:"tenant_name"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+type SetProjectLimitInput struct {
+ RequestsPerMinute int64 `json:"requests_per_minute"`
+ TokensPerMinute int64 `json:"tokens_per_minute"`
+ ConcurrentRequests int64 `json:"concurrent_requests"`
+ MonthlySpendMicros int64 `json:"monthly_spend_micros"`
+}
+
+type UsageRecord struct {
+ RequestID string `json:"request_id"`
+ TenantID string `json:"tenant_id"`
+ ProjectID string `json:"project_id"`
+ KeyID string `json:"key_id"`
+ PublicModel string `json:"public_model"`
+ ProviderID string `json:"provider_id,omitempty"`
+ UpstreamModel string `json:"upstream_model,omitempty"`
+ Protocol string `json:"protocol"`
+ Stream bool `json:"stream"`
+ StatusCode int `json:"status_code"`
+ Success bool `json:"success"`
+ ErrorType string `json:"error_type,omitempty"`
+ Attempts int `json:"attempts"`
+ StartedAt time.Time `json:"started_at"`
+ DurationMS int64 `json:"duration_ms"`
+ InputTokens int64 `json:"input_tokens"`
+ OutputTokens int64 `json:"output_tokens"`
+ TotalTokens int64 `json:"total_tokens"`
+ CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"`
+ CacheReadInputTokens int64 `json:"cache_read_input_tokens"`
+ CostMicros int64 `json:"cost_micros"`
+ ChargedMicros int64 `json:"charged_micros"`
+ UncollectedMicros int64 `json:"uncollected_micros"`
+}
+
+type UsageSummary struct {
+ PeriodStart time.Time `json:"period_start"`
+ TenantID string `json:"tenant_id"`
+ ProjectID string `json:"project_id"`
+ ProjectName string `json:"project_name"`
+ RequestCount int64 `json:"request_count"`
+ SuccessfulRequests int64 `json:"successful_requests"`
+ InputTokens int64 `json:"input_tokens"`
+ OutputTokens int64 `json:"output_tokens"`
+ TotalTokens int64 `json:"total_tokens"`
+ CostMicros int64 `json:"cost_micros"`
+ ChargedMicros int64 `json:"charged_micros"`
+ UncollectedMicros int64 `json:"uncollected_micros"`
+}
+
+type AuditLog struct {
+ ID int64 `json:"id"`
+ ActorID string `json:"actor_id,omitempty"`
+ ActorType string `json:"actor_type"`
+ ActorRole string `json:"actor_role"`
+ TenantID string `json:"tenant_id,omitempty"`
+ RequestID string `json:"request_id"`
+ Method string `json:"method"`
+ Path string `json:"path"`
+ Action string `json:"action"`
+ StatusCode int `json:"status_code"`
+ RemoteIP string `json:"remote_ip,omitempty"`
+ UserAgent string `json:"user_agent,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+type AuditInput struct {
+ Actor ConsoleActor
+ RequestID string
+ Method string
+ Path string
+ Action string
+ StatusCode int
+ RemoteIP string
+ UserAgent string
}
diff --git a/internal/controlplane/usage.go b/internal/controlplane/usage.go
new file mode 100644
index 0000000..6c69a9f
--- /dev/null
+++ b/internal/controlplane/usage.go
@@ -0,0 +1,151 @@
+package controlplane
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "time"
+
+ "aigw/internal/domain"
+
+ "github.com/jackc/pgx/v5"
+)
+
+type UsageQuery struct {
+ TenantID string
+ ProjectID string
+ Model string
+ Limit int
+}
+
+func (s *Store) RecordUsage(ctx context.Context, event domain.UsageEvent) error {
+ tx, err := s.db.Begin(ctx)
+ if err != nil {
+ return fmt.Errorf("begin usage record: %w", err)
+ }
+ defer tx.Rollback(ctx)
+ command, err := tx.Exec(ctx, `
+ INSERT INTO usage_events (
+ request_id, tenant_id, project_id, key_id, public_model, provider_id, upstream_model,
+ protocol, stream, status_code, success, error_type, attempts, started_at, duration_ms,
+ input_tokens, output_tokens, total_tokens, cache_creation_input_tokens, cache_read_input_tokens,
+ cost_micros, charged_micros, uncollected_micros)
+ VALUES ($1,$2,$3,$4,$5,NULLIF($6,''),NULLIF($7,''),$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,0,0,0)
+ ON CONFLICT (request_id) DO NOTHING`, event.RequestID, event.TenantID, event.ProjectID, event.KeyID,
+ event.PublicModel, event.ProviderID, event.UpstreamModel, string(event.Protocol), event.Stream,
+ event.StatusCode, event.Success, event.ErrorType, event.Attempts, event.StartedAt, event.DurationMS,
+ event.Usage.InputTokens, event.Usage.OutputTokens, event.Usage.TotalTokens,
+ event.Usage.CacheCreationInputTokens, event.Usage.CacheReadInputTokens)
+ if err != nil {
+ return fmt.Errorf("persist usage event: %w", err)
+ }
+ if command.RowsAffected() == 0 {
+ return tx.Commit(ctx)
+ }
+ if err := upsertUsageRollup(ctx, tx, event, 0, 0, 0); err != nil {
+ return err
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return fmt.Errorf("commit usage record: %w", err)
+ }
+ return nil
+}
+
+func upsertUsageRollup(ctx context.Context, tx pgx.Tx, event domain.UsageEvent, cost, charged, uncollected int64) error {
+ period := time.Date(event.StartedAt.UTC().Year(), event.StartedAt.UTC().Month(), 1, 0, 0, 0, 0, time.UTC)
+ _, err := tx.Exec(ctx, `
+ INSERT INTO usage_monthly_rollups
+ (period_start, tenant_id, project_id, request_count, successful_requests, input_tokens, output_tokens, total_tokens, cost_micros, charged_micros, uncollected_micros)
+ VALUES ($1,$2,$3,1,$4,$5,$6,$7,$8,$9,$10)
+ ON CONFLICT (project_id, period_start) DO UPDATE SET request_count=usage_monthly_rollups.request_count+1,
+ successful_requests=usage_monthly_rollups.successful_requests+EXCLUDED.successful_requests,
+ input_tokens=usage_monthly_rollups.input_tokens+EXCLUDED.input_tokens,
+ output_tokens=usage_monthly_rollups.output_tokens+EXCLUDED.output_tokens,
+ total_tokens=usage_monthly_rollups.total_tokens+EXCLUDED.total_tokens,
+ cost_micros=usage_monthly_rollups.cost_micros+EXCLUDED.cost_micros,
+ charged_micros=usage_monthly_rollups.charged_micros+EXCLUDED.charged_micros,
+ uncollected_micros=usage_monthly_rollups.uncollected_micros+EXCLUDED.uncollected_micros,
+ updated_at=now()`, period, event.TenantID, event.ProjectID, boolInt(event.Success),
+ event.Usage.InputTokens, event.Usage.OutputTokens, event.Usage.TotalTokens, cost, charged, uncollected)
+ if err != nil {
+ return fmt.Errorf("update usage monthly rollup: %w", err)
+ }
+ return nil
+}
+
+func boolInt(value bool) int {
+ if value {
+ return 1
+ }
+ return 0
+}
+
+func (s *Store) ListUsage(ctx context.Context, query UsageQuery) ([]UsageRecord, error) {
+ limit := query.Limit
+ if limit < 1 || limit > 1000 {
+ limit = 200
+ }
+ where := []string{"1=1"}
+ args := make([]any, 0, 5)
+ index := 1
+ for _, item := range []struct{ value, clause string }{{query.TenantID, "tenant_id=$"}, {query.ProjectID, "project_id=$"}, {query.Model, "public_model=$"}} {
+ if strings.TrimSpace(item.value) != "" {
+ where = append(where, item.clause+fmt.Sprint(index))
+ args = append(args, item.value)
+ index++
+ }
+ }
+ args = append(args, limit)
+ rows, err := s.db.Query(ctx, `SELECT request_id, tenant_id::text, project_id::text, key_id::text, public_model,
+ COALESCE(provider_id,''), COALESCE(upstream_model,''), protocol, stream, status_code, success, error_type,
+ attempts, started_at, duration_ms, input_tokens, output_tokens, total_tokens, cache_creation_input_tokens,
+ cache_read_input_tokens, cost_micros, charged_micros, uncollected_micros FROM usage_events WHERE `+
+ strings.Join(where, " AND ")+` ORDER BY created_at DESC LIMIT $`+fmt.Sprint(index), args...)
+ if err != nil {
+ return nil, fmt.Errorf("query usage events: %w", err)
+ }
+ defer rows.Close()
+ result := make([]UsageRecord, 0)
+ for rows.Next() {
+ var item UsageRecord
+ if err := rows.Scan(&item.RequestID, &item.TenantID, &item.ProjectID, &item.KeyID, &item.PublicModel, &item.ProviderID, &item.UpstreamModel,
+ &item.Protocol, &item.Stream, &item.StatusCode, &item.Success, &item.ErrorType, &item.Attempts, &item.StartedAt, &item.DurationMS,
+ &item.InputTokens, &item.OutputTokens, &item.TotalTokens, &item.CacheCreationInputTokens, &item.CacheReadInputTokens,
+ &item.CostMicros, &item.ChargedMicros, &item.UncollectedMicros); err != nil {
+ return nil, fmt.Errorf("scan usage event: %w", err)
+ }
+ result = append(result, item)
+ }
+ return result, rows.Err()
+}
+
+func (s *Store) UsageSummary(ctx context.Context, tenantID, projectID string) ([]UsageSummary, error) {
+ where := []string{"1=1"}
+ args := make([]any, 0, 2)
+ index := 1
+ for _, item := range []struct{ value, clause string }{{tenantID, "r.tenant_id=$"}, {projectID, "r.project_id=$"}} {
+ if strings.TrimSpace(item.value) != "" {
+ where = append(where, item.clause+fmt.Sprint(index))
+ args = append(args, item.value)
+ index++
+ }
+ }
+ rows, err := s.db.Query(ctx, `SELECT r.period_start, r.tenant_id::text, r.project_id::text, p.name,
+ r.request_count, r.successful_requests, r.input_tokens, r.output_tokens, r.total_tokens,
+ r.cost_micros, r.charged_micros, r.uncollected_micros FROM usage_monthly_rollups r JOIN projects p ON p.id=r.project_id WHERE `+
+ strings.Join(where, " AND ")+` ORDER BY r.period_start DESC, p.name`, args...)
+ if err != nil {
+ return nil, fmt.Errorf("query usage summary: %w", err)
+ }
+ defer rows.Close()
+ result := make([]UsageSummary, 0)
+ for rows.Next() {
+ var item UsageSummary
+ if err := rows.Scan(&item.PeriodStart, &item.TenantID, &item.ProjectID, &item.ProjectName, &item.RequestCount, &item.SuccessfulRequests,
+ &item.InputTokens, &item.OutputTokens, &item.TotalTokens, &item.CostMicros, &item.ChargedMicros, &item.UncollectedMicros); err != nil {
+ return nil, fmt.Errorf("scan usage summary: %w", err)
+ }
+ result = append(result, item)
+ }
+ return result, rows.Err()
+}
diff --git a/internal/domain/types.go b/internal/domain/types.go
index e2586ea..b1decc9 100644
--- a/internal/domain/types.go
+++ b/internal/domain/types.go
@@ -31,9 +31,13 @@ type Route struct {
}
type Model struct {
- ID string
- OwnedBy string
- Routes []Route
+ ID string
+ OwnedBy string
+ InputPriceMicrosPerMillion int64
+ OutputPriceMicrosPerMillion int64
+ CacheReadPriceMicrosPerMillion int64
+ CacheWritePriceMicrosPerMillion int64
+ Routes []Route
}
type Usage struct {
@@ -62,3 +66,14 @@ type UsageEvent struct {
DurationMS int64 `json:"duration_ms"`
Usage Usage `json:"usage"`
}
+
+// LimitPolicy is the immutable runtime view of a project's commercial limits.
+// Zero values disable the corresponding limit.
+type LimitPolicy struct {
+ TenantID string `json:"tenant_id"`
+ ProjectID string `json:"project_id"`
+ RequestsPerMinute int64 `json:"requests_per_minute"`
+ TokensPerMinute int64 `json:"tokens_per_minute"`
+ Concurrent int64 `json:"concurrent_requests"`
+ MonthlySpendMicros int64 `json:"monthly_spend_micros"`
+}
diff --git a/internal/httpapi/api.go b/internal/httpapi/api.go
index ed7939f..becfbf3 100644
--- a/internal/httpapi/api.go
+++ b/internal/httpapi/api.go
@@ -10,13 +10,16 @@ import (
"io"
"log/slog"
"net/http"
+ "runtime/debug"
"strings"
"time"
"aigw/internal/apierror"
"aigw/internal/auth"
+ "aigw/internal/billing"
"aigw/internal/catalog"
"aigw/internal/domain"
+ "aigw/internal/limits"
"aigw/internal/provider"
"aigw/internal/routing"
"aigw/internal/telemetry"
@@ -31,6 +34,11 @@ type API struct {
router *routing.Router
forwarder *provider.Forwarder
usageSink telemetry.UsageSink
+ billingMeter billing.Meter
+ limiter *limits.Limiter
+ usageRecorder interface {
+ RecordUsage(context.Context, domain.UsageEvent) error
+ }
metrics *telemetry.Metrics
logger *slog.Logger
maxBodyBytes int64
@@ -43,6 +51,11 @@ type Options struct {
Router *routing.Router
Forwarder *provider.Forwarder
UsageSink telemetry.UsageSink
+ BillingMeter billing.Meter
+ Limiter *limits.Limiter
+ UsageRecorder interface {
+ RecordUsage(context.Context, domain.UsageEvent) error
+ }
Metrics *telemetry.Metrics
Logger *slog.Logger
MaxBodyBytes int64
@@ -56,6 +69,9 @@ func New(options Options) *API {
router: options.Router,
forwarder: options.Forwarder,
usageSink: options.UsageSink,
+ billingMeter: options.BillingMeter,
+ limiter: options.Limiter,
+ usageRecorder: options.UsageRecorder,
metrics: options.Metrics,
logger: options.Logger,
maxBodyBytes: options.MaxBodyBytes,
@@ -134,6 +150,25 @@ func (a *API) serveInference(w http.ResponseWriter, r *http.Request, protocol do
apierror.Write(w, apierror.Error{Status: http.StatusBadRequest, Type: "invalid_params", Message: "Parameter model is required and the body must be valid JSON"}, requestID)
return
}
+ if a.limiter != nil {
+ lease, limitErr := a.limiter.Acquire(r.Context(), principal, body)
+ if limitErr != nil {
+ w.Header().Set("Retry-After", "60")
+ typeName, message := "rate_limit_exceeded", "Project request rate limit exceeded"
+ switch {
+ case errors.Is(limitErr, limits.ErrTokensExceeded):
+ typeName, message = "token_rate_limit_exceeded", "Project token rate limit exceeded"
+ case errors.Is(limitErr, limits.ErrConcurrencyLimit):
+ typeName, message = "concurrency_limit_exceeded", "Project concurrent request limit exceeded"
+ }
+ apierror.Write(w, apierror.Error{Status: http.StatusTooManyRequests, Type: typeName, Message: message}, requestID)
+ a.recordUsageOnly(r, domain.UsageEvent{RequestID: requestID, KeyID: principal.KeyID, TenantID: principal.TenantID, ProjectID: principal.ProjectID,
+ PublicModel: envelope.Model, Protocol: protocol, Stream: envelope.Stream, StatusCode: http.StatusTooManyRequests, Success: false,
+ ErrorType: typeName, StartedAt: startedAt, DurationMS: time.Since(startedAt).Milliseconds()})
+ return
+ }
+ defer lease.Release()
+ }
routes, err := a.router.Plan(envelope.Model, protocol)
if err != nil {
@@ -144,6 +179,41 @@ func (a *API) serveInference(w http.ResponseWriter, r *http.Request, protocol do
}
return
}
+ if a.billingMeter != nil {
+ model, modelErr := a.catalog.Model(envelope.Model)
+ if modelErr != nil {
+ apierror.Write(w, apierror.Error{Status: http.StatusNotFound, Type: "invalid_model", Message: "Model does not exist"}, requestID)
+ return
+ }
+ policy := domain.LimitPolicy{}
+ if a.limiter != nil {
+ policy, _ = a.limiter.Policy(principal.ProjectID)
+ }
+ if err := a.billingMeter.Authorize(r.Context(), billing.Authorization{
+ RequestID: requestID, Principal: principal, Model: model, Body: body, Policy: policy,
+ }); err != nil {
+ if errors.Is(err, billing.ErrInsufficientBalance) {
+ apierror.Write(w, apierror.Error{Status: http.StatusPaymentRequired, Type: "insufficient_balance", Message: "Account balance is insufficient"}, requestID)
+ a.recordUsageOnly(r, domain.UsageEvent{RequestID: requestID, KeyID: principal.KeyID, TenantID: principal.TenantID, ProjectID: principal.ProjectID,
+ PublicModel: envelope.Model, Protocol: protocol, Stream: envelope.Stream, StatusCode: http.StatusPaymentRequired, Success: false,
+ ErrorType: "insufficient_balance", StartedAt: startedAt, DurationMS: time.Since(startedAt).Milliseconds()})
+ return
+ }
+ if errors.Is(err, billing.ErrQuotaExceeded) {
+ apierror.Write(w, apierror.Error{Status: http.StatusTooManyRequests, Type: "monthly_quota_exceeded", Message: "Project monthly spend quota exceeded"}, requestID)
+ a.recordUsageOnly(r, domain.UsageEvent{RequestID: requestID, KeyID: principal.KeyID, TenantID: principal.TenantID, ProjectID: principal.ProjectID,
+ PublicModel: envelope.Model, Protocol: protocol, Stream: envelope.Stream, StatusCode: http.StatusTooManyRequests, Success: false,
+ ErrorType: "monthly_quota_exceeded", StartedAt: startedAt, DurationMS: time.Since(startedAt).Milliseconds()})
+ return
+ }
+ a.logger.Error("billing_authorization_failed", "request_id", requestID, "tenant_id", principal.TenantID, "error", err)
+ apierror.Write(w, apierror.Error{Status: http.StatusServiceUnavailable, Type: "billing_unavailable", Message: "Billing service is temporarily unavailable"}, requestID)
+ a.recordUsageOnly(r, domain.UsageEvent{RequestID: requestID, KeyID: principal.KeyID, TenantID: principal.TenantID, ProjectID: principal.ProjectID,
+ PublicModel: envelope.Model, Protocol: protocol, Stream: envelope.Stream, StatusCode: http.StatusServiceUnavailable, Success: false,
+ ErrorType: "billing_unavailable", StartedAt: startedAt, DurationMS: time.Since(startedAt).Milliseconds()})
+ return
+ }
+ }
result, err := a.forwarder.Forward(r.Context(), protocol, requestID, body, r.Header, routes)
if err != nil {
@@ -155,7 +225,7 @@ func (a *API) serveInference(w http.ResponseWriter, r *http.Request, protocol do
} else {
apierror.Write(w, apierror.Error{Status: status, Type: errorType, Message: "No upstream provider is currently available"}, requestID)
}
- a.publishUsage(domain.UsageEvent{
+ a.finishUsage(r, domain.UsageEvent{
RequestID: requestID, KeyID: principal.KeyID, TenantID: principal.TenantID, ProjectID: principal.ProjectID,
PublicModel: envelope.Model, Protocol: protocol, Stream: envelope.Stream, StatusCode: status,
Success: false, ErrorType: errorType, Attempts: result.Attempts, StartedAt: startedAt, DurationMS: time.Since(startedAt).Milliseconds(),
@@ -167,7 +237,7 @@ func (a *API) serveInference(w http.ResponseWriter, r *http.Request, protocol do
if result.Response.StatusCode >= 400 {
gatewayError := normalizeProviderError(result.Response)
apierror.Write(w, gatewayError, requestID)
- a.publishUsage(domain.UsageEvent{
+ a.finishUsage(r, domain.UsageEvent{
RequestID: requestID, KeyID: principal.KeyID, TenantID: principal.TenantID, ProjectID: principal.ProjectID,
PublicModel: envelope.Model, ProviderID: result.Route.Provider.ID, UpstreamModel: result.Route.UpstreamModel,
Protocol: protocol, Stream: envelope.Stream, StatusCode: gatewayError.Status, Success: false,
@@ -192,7 +262,7 @@ func (a *API) serveInference(w http.ResponseWriter, r *http.Request, protocol do
if copyErr != nil {
errorType = "stream_interrupted"
}
- a.publishUsage(domain.UsageEvent{
+ a.finishUsage(r, domain.UsageEvent{
RequestID: requestID, KeyID: principal.KeyID, TenantID: principal.TenantID, ProjectID: principal.ProjectID,
PublicModel: envelope.Model, ProviderID: result.Route.Provider.ID, UpstreamModel: result.Route.UpstreamModel,
Protocol: protocol, Stream: stream, StatusCode: result.Response.StatusCode, Success: success,
@@ -254,6 +324,41 @@ func (a *API) publishUsage(event domain.UsageEvent) {
}
}
+func (a *API) finishUsage(r *http.Request, event domain.UsageEvent) {
+ settled := false
+ if a.billingMeter != nil {
+ ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 5*time.Second)
+ err := a.billingMeter.Settle(ctx, event)
+ cancel()
+ if err != nil {
+ a.logger.Error("billing_settlement_failed", "request_id", event.RequestID, "tenant_id", event.TenantID, "error", err)
+ } else {
+ settled = true
+ }
+ }
+ if a.usageRecorder != nil && !settled {
+ ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 5*time.Second)
+ err := a.usageRecorder.RecordUsage(ctx, event)
+ cancel()
+ if err != nil {
+ a.logger.Error("usage_persistence_failed", "request_id", event.RequestID, "tenant_id", event.TenantID, "error", err)
+ }
+ }
+ a.publishUsage(event)
+}
+
+func (a *API) recordUsageOnly(r *http.Request, event domain.UsageEvent) {
+ if a.usageRecorder != nil {
+ ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 5*time.Second)
+ err := a.usageRecorder.RecordUsage(ctx, event)
+ cancel()
+ if err != nil {
+ a.logger.Error("usage_persistence_failed", "request_id", event.RequestID, "tenant_id", event.TenantID, "error", err)
+ }
+ }
+ a.publishUsage(event)
+}
+
func (a *API) withRequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := newRequestID()
@@ -266,7 +371,7 @@ func (a *API) recoverPanics(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if recovered := recover(); recovered != nil {
- a.logger.Error("request_panic", "request_id", requestIDFrom(r.Context()), "error", recovered)
+ a.logger.Error("request_panic", "request_id", requestIDFrom(r.Context()), "error", recovered, "stack", string(debug.Stack()))
apierror.Write(w, apierror.Error{Status: http.StatusInternalServerError, Type: "internal_server_error", Message: "Internal server error"}, requestIDFrom(r.Context()))
}
}()
diff --git a/internal/httpapi/api_test.go b/internal/httpapi/api_test.go
index 66a344b..014b99b 100644
--- a/internal/httpapi/api_test.go
+++ b/internal/httpapi/api_test.go
@@ -3,6 +3,7 @@ package httpapi
import (
"bufio"
"bytes"
+ "context"
"encoding/json"
"io"
"log/slog"
@@ -14,6 +15,7 @@ import (
"time"
"aigw/internal/auth"
+ "aigw/internal/billing"
"aigw/internal/catalog"
"aigw/internal/config"
"aigw/internal/domain"
@@ -26,6 +28,20 @@ type captureUsageSink struct {
events chan domain.UsageEvent
}
+type fakeBillingMeter struct {
+ authorizeErr error
+ settled chan domain.UsageEvent
+}
+
+func (m *fakeBillingMeter) Authorize(context.Context, billing.Authorization) error {
+ return m.authorizeErr
+}
+
+func (m *fakeBillingMeter) Settle(_ context.Context, event domain.UsageEvent) error {
+ m.settled <- event
+ return nil
+}
+
func (s *captureUsageSink) Publish(event domain.UsageEvent) {
s.events <- event
}
@@ -178,7 +194,73 @@ func TestAnthropicHeadersAndPath(t *testing.T) {
}
}
+func TestInsufficientBalanceRejectsBeforeCallingUpstream(t *testing.T) {
+ var calls atomic.Int64
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ calls.Add(1)
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer upstream.Close()
+ meter := &fakeBillingMeter{authorizeErr: billing.ErrInsufficientBalance, settled: make(chan domain.UsageEvent, 1)}
+ gateway, sink := newTestGatewayWithBilling(t, []config.ProviderConfig{{
+ ID: "primary", Protocol: domain.ProtocolOpenAI, BaseURL: upstream.URL + "/v1", APIKey: "secret",
+ }}, []config.RouteConfig{{Provider: "primary", UpstreamModel: "model", Weight: 1}}, meter)
+ defer gateway.Close()
+
+ response := postOpenAI(t, gateway.URL, false)
+ defer response.Body.Close()
+ if response.StatusCode != http.StatusPaymentRequired {
+ t.Fatalf("status = %d, want 402", response.StatusCode)
+ }
+ if calls.Load() != 0 {
+ t.Fatalf("upstream calls = %d, want 0", calls.Load())
+ }
+ select {
+ case <-meter.settled:
+ t.Fatal("rejected request was settled")
+ default:
+ }
+ select {
+ case event := <-sink.events:
+ if event.StatusCode != http.StatusPaymentRequired || event.ErrorType != "insufficient_balance" {
+ t.Fatalf("unexpected rejection usage: %+v", event)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("rejected request did not emit usage")
+ }
+}
+
+func TestSuccessfulRequestIsSettled(t *testing.T) {
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = io.WriteString(w, `{"choices":[],"usage":{"prompt_tokens":2,"completion_tokens":3,"total_tokens":5}}`)
+ }))
+ defer upstream.Close()
+ meter := &fakeBillingMeter{settled: make(chan domain.UsageEvent, 1)}
+ gateway, _ := newTestGatewayWithBilling(t, []config.ProviderConfig{{
+ ID: "primary", Protocol: domain.ProtocolOpenAI, BaseURL: upstream.URL + "/v1", APIKey: "secret",
+ }}, []config.RouteConfig{{Provider: "primary", UpstreamModel: "model", Weight: 1}}, meter)
+ defer gateway.Close()
+
+ response := postOpenAI(t, gateway.URL, false)
+ defer response.Body.Close()
+ if response.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200", response.StatusCode)
+ }
+ select {
+ case event := <-meter.settled:
+ if event.Usage.TotalTokens != 5 {
+ t.Fatalf("settled usage = %+v", event.Usage)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("request was not settled")
+ }
+}
+
func newTestGateway(t *testing.T, providers []config.ProviderConfig, routes []config.RouteConfig) (*httptest.Server, *captureUsageSink) {
+ return newTestGatewayWithBilling(t, providers, routes, nil)
+}
+
+func newTestGatewayWithBilling(t *testing.T, providers []config.ProviderConfig, routes []config.RouteConfig, meter billing.Meter) (*httptest.Server, *captureUsageSink) {
t.Helper()
authenticator, err := auth.NewStatic(`[{"key":"client-secret","key_id":"key-1","tenant_id":"tenant-1","project_id":"project-1","scopes":["inference"]}]`, false)
if err != nil {
@@ -201,6 +283,7 @@ func newTestGateway(t *testing.T, providers []config.ProviderConfig, routes []co
Router: routing.New(modelCatalog),
Forwarder: provider.New(cfg.UpstreamHTTP, metrics),
UsageSink: sink,
+ BillingMeter: meter,
Metrics: metrics,
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
MaxBodyBytes: 1 << 20,
diff --git a/internal/limits/limits.go b/internal/limits/limits.go
new file mode 100644
index 0000000..6e0bf74
--- /dev/null
+++ b/internal/limits/limits.go
@@ -0,0 +1,316 @@
+package limits
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "log/slog"
+ "math"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "aigw/internal/domain"
+
+ "github.com/redis/go-redis/v9"
+)
+
+var (
+ ErrRequestsExceeded = errors.New("requests per minute limit exceeded")
+ ErrTokensExceeded = errors.New("tokens per minute limit exceeded")
+ ErrConcurrencyLimit = errors.New("concurrent request limit exceeded")
+)
+
+type Lease interface{ Release() }
+
+type Limiter struct {
+ redis *redis.Client
+ prefix string
+ defaultMaxOutput int64
+ logger *slog.Logger
+ policies atomic.Pointer[policySnapshot]
+ localMu sync.Mutex
+ local map[string]*localWindow
+ redisUp atomic.Bool
+ redisFailureSeen atomic.Bool
+ redisRetryAt atomic.Int64
+}
+
+type policySnapshot struct{ policies map[string]domain.LimitPolicy }
+
+type localWindow struct {
+ minute int64
+ requests int64
+ tokens int64
+ concurrent int64
+}
+
+type localLease struct {
+ limiter *Limiter
+ project string
+ released atomic.Bool
+}
+type redisLease struct {
+ limiter *Limiter
+ key string
+ released atomic.Bool
+}
+
+const (
+ redisCommandTimeout = 250 * time.Millisecond
+ redisRetryCooldown = 5 * time.Second
+)
+
+const acquireScript = `
+local req = tonumber(ARGV[1])
+local tok = tonumber(ARGV[2])
+local conc = tonumber(ARGV[3])
+local estimate = tonumber(ARGV[4])
+local ttl = tonumber(ARGV[5])
+local r = 0
+local t = 0
+local c = 0
+if req > 0 then r = redis.call('INCR', KEYS[1]); if r == 1 then redis.call('PEXPIRE', KEYS[1], ttl) end end
+if tok > 0 then t = redis.call('INCRBY', KEYS[2], estimate); if t == estimate then redis.call('PEXPIRE', KEYS[2], ttl) end end
+if conc > 0 then c = redis.call('INCR', KEYS[3]); redis.call('PEXPIRE', KEYS[3], 3600000) end
+if (req > 0 and r > req) then if req > 0 then redis.call('DECR', KEYS[1]) end; if tok > 0 then redis.call('DECRBY', KEYS[2], estimate) end; if conc > 0 then redis.call('DECR', KEYS[3]) end; return {0,1} end
+if (tok > 0 and t > tok) then if req > 0 then redis.call('DECR', KEYS[1]) end; if tok > 0 then redis.call('DECRBY', KEYS[2], estimate) end; if conc > 0 then redis.call('DECR', KEYS[3]) end; return {0,2} end
+if (conc > 0 and c > conc) then if req > 0 then redis.call('DECR', KEYS[1]) end; if tok > 0 then redis.call('DECRBY', KEYS[2], estimate) end; if conc > 0 then redis.call('DECR', KEYS[3]) end; return {0,3} end
+return {1,0}
+`
+
+const releaseScript = `
+local value = tonumber(redis.call('GET', KEYS[1]) or '0')
+if value > 0 then redis.call('DECR', KEYS[1]) end
+return value
+`
+
+func New(redisURL, prefix string, defaultMaxOutput int64, logger *slog.Logger) *Limiter {
+ if logger == nil {
+ logger = slog.Default()
+ }
+ if prefix == "" {
+ prefix = "aigw:limits"
+ }
+ l := &Limiter{prefix: strings.TrimRight(prefix, ":"), defaultMaxOutput: defaultMaxOutput, logger: logger, local: make(map[string]*localWindow)}
+ if strings.TrimSpace(redisURL) != "" {
+ if options, err := redis.ParseURL(redisURL); err == nil {
+ options.MaxRetries = -1
+ options.DialerRetries = 1
+ options.DialTimeout = redisCommandTimeout
+ options.ReadTimeout = redisCommandTimeout
+ options.WriteTimeout = redisCommandTimeout
+ options.PoolTimeout = redisCommandTimeout
+ l.redis = redis.NewClient(options)
+ } else {
+ logger.Warn("limits_redis_config_invalid", "error", err, "fallback", "local")
+ }
+ }
+ l.ReplacePolicies(nil)
+ return l
+}
+
+func (l *Limiter) Close() error {
+ if l.redis == nil {
+ return nil
+ }
+ return l.redis.Close()
+}
+
+func (l *Limiter) ReplacePolicies(policies []domain.LimitPolicy) {
+ copyMap := make(map[string]domain.LimitPolicy, len(policies))
+ for _, policy := range policies {
+ if policy.ProjectID != "" {
+ copyMap[policy.ProjectID] = policy
+ }
+ }
+ l.policies.Store(&policySnapshot{policies: copyMap})
+}
+
+func (l *Limiter) Policy(projectID string) (domain.LimitPolicy, bool) {
+ snapshot := l.policies.Load()
+ if snapshot == nil {
+ return domain.LimitPolicy{}, false
+ }
+ policy, ok := snapshot.policies[projectID]
+ return policy, ok
+}
+
+func (l *Limiter) Acquire(ctx context.Context, principal domain.Principal, body []byte) (Lease, error) {
+ policy, ok := l.Policy(principal.ProjectID)
+ if !ok || (policy.RequestsPerMinute == 0 && policy.TokensPerMinute == 0 && policy.Concurrent == 0) {
+ return noopLease{}, nil
+ }
+ estimate := EstimateTokens(body, l.defaultMaxOutput)
+ minute := time.Now().Unix() / 60
+ if l.redis != nil && time.Now().UnixNano() >= l.redisRetryAt.Load() {
+ redisContext, cancel := context.WithTimeout(ctx, redisCommandTimeout)
+ lease, err := l.acquireRedis(redisContext, principal.ProjectID, minute, policy, estimate)
+ cancel()
+ if err == nil {
+ return lease, nil
+ }
+ if errors.Is(err, ErrRequestsExceeded) || errors.Is(err, ErrTokensExceeded) || errors.Is(err, ErrConcurrencyLimit) {
+ l.markRedis(true, nil)
+ return nil, err
+ }
+ l.markRedis(false, err)
+ }
+ return l.acquireLocal(principal.ProjectID, minute, policy, estimate)
+}
+
+func EstimateTokens(body []byte, defaultMaxOutput int64) int64 {
+ input := int64((len(body) + 3) / 4)
+ if input < 1 {
+ input = 1
+ }
+ maxOutput := defaultMaxOutput
+ var limits struct {
+ MaxTokens int64 `json:"max_tokens"`
+ MaxCompletionTokens int64 `json:"max_completion_tokens"`
+ MaxOutputTokens int64 `json:"max_output_tokens"`
+ }
+ if json.Unmarshal(body, &limits) == nil {
+ explicit := int64(0)
+ for _, value := range []int64{limits.MaxTokens, limits.MaxCompletionTokens, limits.MaxOutputTokens} {
+ if value > explicit {
+ explicit = value
+ }
+ }
+ if explicit > 0 {
+ maxOutput = explicit
+ }
+ }
+ if maxOutput < 0 {
+ maxOutput = 0
+ }
+ if input > math.MaxInt64-maxOutput {
+ return math.MaxInt64
+ }
+ return input + maxOutput
+}
+
+func (l *Limiter) acquireRedis(ctx context.Context, project string, minute int64, policy domain.LimitPolicy, estimate int64) (Lease, error) {
+ base := l.prefix + ":" + project + ":" + strconv.FormatInt(minute, 10)
+ keys := []string{base + ":requests", base + ":tokens", l.prefix + ":" + project + ":concurrent"}
+ values, err := l.redis.Eval(ctx, acquireScript, keys, policy.RequestsPerMinute, policy.TokensPerMinute, policy.Concurrent, estimate, 125000).Result()
+ if err != nil {
+ return nil, err
+ }
+ items, ok := values.([]any)
+ if !ok || len(items) < 2 {
+ return nil, errors.New("invalid limits Redis response")
+ }
+ allowed, _ := toInt64(items[0])
+ reason, _ := toInt64(items[1])
+ if allowed == 0 {
+ switch reason {
+ case 1:
+ return nil, ErrRequestsExceeded
+ case 2:
+ return nil, ErrTokensExceeded
+ default:
+ return nil, ErrConcurrencyLimit
+ }
+ }
+ l.markRedis(true, nil)
+ return &redisLease{limiter: l, key: keys[2]}, nil
+}
+
+func (l *Limiter) acquireLocal(project string, minute int64, policy domain.LimitPolicy, estimate int64) (Lease, error) {
+ l.localMu.Lock()
+ defer l.localMu.Unlock()
+ for key, value := range l.local {
+ if value.minute < minute-2 && value.concurrent == 0 {
+ delete(l.local, key)
+ }
+ }
+ window := l.local[project]
+ if window == nil {
+ window = &localWindow{minute: minute}
+ l.local[project] = window
+ } else if window.minute != minute {
+ window.minute = minute
+ window.requests = 0
+ window.tokens = 0
+ }
+ if policy.RequestsPerMinute > 0 && window.requests >= policy.RequestsPerMinute {
+ return nil, ErrRequestsExceeded
+ }
+ if policy.TokensPerMinute > 0 && (estimate > policy.TokensPerMinute || window.tokens > policy.TokensPerMinute-estimate) {
+ return nil, ErrTokensExceeded
+ }
+ if policy.Concurrent > 0 && window.concurrent >= policy.Concurrent {
+ return nil, ErrConcurrencyLimit
+ }
+ window.requests++
+ window.tokens += estimate
+ window.concurrent++
+ return &localLease{limiter: l, project: project}, nil
+}
+
+func (l *Limiter) releaseLocal(project string) {
+ l.localMu.Lock()
+ defer l.localMu.Unlock()
+ if value := l.local[project]; value != nil && value.concurrent > 0 {
+ value.concurrent--
+ }
+}
+
+func (l *Limiter) releaseRedis(ctx context.Context, key string) {
+ if l.redis == nil {
+ return
+ }
+ if _, err := l.redis.Eval(ctx, releaseScript, []string{key}).Result(); err != nil {
+ l.markRedis(false, err)
+ }
+}
+
+func (x *localLease) Release() {
+ if x.released.CompareAndSwap(false, true) {
+ x.limiter.releaseLocal(x.project)
+ }
+}
+func (x *redisLease) Release() {
+ if x.released.CompareAndSwap(false, true) {
+ ctx, cancel := context.WithTimeout(context.Background(), redisCommandTimeout)
+ defer cancel()
+ x.limiter.releaseRedis(ctx, x.key)
+ }
+}
+
+type noopLease struct{}
+
+func (noopLease) Release() {}
+
+func (l *Limiter) markRedis(healthy bool, err error) {
+ l.redisUp.Store(healthy)
+ if healthy {
+ l.redisRetryAt.Store(0)
+ if l.redisFailureSeen.Swap(false) {
+ l.logger.Info("limits_redis_recovered")
+ }
+ return
+ }
+ l.redisRetryAt.Store(time.Now().Add(redisRetryCooldown).UnixNano())
+ if l.redisFailureSeen.CompareAndSwap(false, true) {
+ l.logger.Warn("limits_redis_unavailable", "error", err, "fallback", "local", "retry_after", redisRetryCooldown)
+ }
+}
+
+func toInt64(value any) (int64, bool) {
+ switch v := value.(type) {
+ case int64:
+ return v, true
+ case string:
+ n, e := strconv.ParseInt(v, 10, 64)
+ return n, e == nil
+ case []byte:
+ n, e := strconv.ParseInt(string(v), 10, 64)
+ return n, e == nil
+ default:
+ return 0, false
+ }
+}
diff --git a/internal/limits/limits_test.go b/internal/limits/limits_test.go
new file mode 100644
index 0000000..78b346e
--- /dev/null
+++ b/internal/limits/limits_test.go
@@ -0,0 +1,103 @@
+package limits
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "aigw/internal/domain"
+)
+
+func TestLocalRequestLimit(t *testing.T) {
+ limiter := New("", "test", 0, nil)
+ limiter.ReplacePolicies([]domain.LimitPolicy{{ProjectID: "project-1", RequestsPerMinute: 2}})
+ principal := domain.Principal{ProjectID: "project-1"}
+ for i := 0; i < 2; i++ {
+ lease, err := limiter.Acquire(context.Background(), principal, []byte(`{}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ lease.Release()
+ }
+ if _, err := limiter.Acquire(context.Background(), principal, []byte(`{}`)); !errors.Is(err, ErrRequestsExceeded) {
+ t.Fatalf("error = %v, want request limit", err)
+ }
+}
+
+func TestLocalTokenAndConcurrencyLimits(t *testing.T) {
+ limiter := New("", "test", 0, nil)
+ body := []byte(`{"max_tokens":4}`)
+ estimate := EstimateTokens(body, 0)
+ limiter.ReplacePolicies([]domain.LimitPolicy{
+ {ProjectID: "tokens", TokensPerMinute: estimate},
+ {ProjectID: "concurrency", Concurrent: 1},
+ })
+ lease, err := limiter.Acquire(context.Background(), domain.Principal{ProjectID: "tokens"}, body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ lease.Release()
+ if _, err := limiter.Acquire(context.Background(), domain.Principal{ProjectID: "tokens"}, body); !errors.Is(err, ErrTokensExceeded) {
+ t.Fatalf("error = %v, want token limit", err)
+ }
+ held, err := limiter.Acquire(context.Background(), domain.Principal{ProjectID: "concurrency"}, body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := limiter.Acquire(context.Background(), domain.Principal{ProjectID: "concurrency"}, body); !errors.Is(err, ErrConcurrencyLimit) {
+ t.Fatalf("error = %v, want concurrency limit", err)
+ }
+ held.Release()
+ retry, err := limiter.Acquire(context.Background(), domain.Principal{ProjectID: "concurrency"}, body)
+ if err != nil {
+ t.Fatalf("acquire after release: %v", err)
+ }
+ retry.Release()
+}
+
+func TestEstimateTokensUsesLargestExplicitOutputLimit(t *testing.T) {
+ body := []byte(`{"max_tokens":10,"max_completion_tokens":25}`)
+ want := int64((len(body)+3)/4 + 25)
+ if got := EstimateTokens(body, 4); got != want {
+ t.Fatalf("EstimateTokens = %d, want %d", got, want)
+ }
+}
+
+func TestEstimateTokensHonorsExplicitLimitBelowDefault(t *testing.T) {
+ body := []byte(`{"max_tokens":10}`)
+ want := int64((len(body)+3)/4 + 10)
+ if got := EstimateTokens(body, 4096); got != want {
+ t.Fatalf("EstimateTokens = %d, want %d", got, want)
+ }
+}
+
+func TestRedisFailureFallsBackQuicklyAndOpensCircuit(t *testing.T) {
+ limiter := New("redis://127.0.0.1:1/0", "test", 0, nil)
+ defer limiter.Close()
+ limiter.ReplacePolicies([]domain.LimitPolicy{{ProjectID: "project-1", RequestsPerMinute: 2}})
+ principal := domain.Principal{ProjectID: "project-1"}
+
+ started := time.Now()
+ lease, err := limiter.Acquire(context.Background(), principal, []byte(`{}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ lease.Release()
+ if elapsed := time.Since(started); elapsed > time.Second {
+ t.Fatalf("Redis fallback took %v, want under 1s", elapsed)
+ }
+ if limiter.redisRetryAt.Load() <= time.Now().UnixNano() {
+ t.Fatal("Redis failure did not open the retry circuit")
+ }
+
+ started = time.Now()
+ lease, err = limiter.Acquire(context.Background(), principal, []byte(`{}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ lease.Release()
+ if elapsed := time.Since(started); elapsed > 100*time.Millisecond {
+ t.Fatalf("open-circuit local fallback took %v, want under 100ms", elapsed)
+ }
+}