Files
localmodelroutex/backend/internal/api/admin.go
LovroG05 d7bd1cced0 feat: content logging and per-key analytics
- Migration 000003: request_log_content table (messages jsonb, completion text)
- InsertRequestLog now returns the new row's ID so content can be linked
- Chat handler captures prompt messages and completion text (streaming accumulates deltas)
- ListRequestLogs left-joins content; logs API passes key filter through
- KeyStats query aggregates requests/tokens/cost/errors per key prefix
- GET /api/admin/stats/keys endpoint
- Logs page: expandable rows show prompt + completion inline
- Analytics page: per-key usage table below the chart
2026-06-29 00:01:25 +02:00

314 lines
8.8 KiB
Go

package api
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"git.govekar.net/LovroG05/localmodelroutex/internal/store"
)
// --- providers ---
func (a *API) adminListProviders(w http.ResponseWriter, r *http.Request) {
rows, err := a.Store.ListProviders(r.Context(), false)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
out := make([]map[string]any, 0, len(rows))
for _, p := range rows {
out = append(out, map[string]any{
"id": p.ID, "name": p.Name, "type": p.Type, "base_url": p.BaseURL,
"enabled": p.Enabled, "has_credentials": len(p.Credentials) > 0,
})
}
writeJSON(w, http.StatusOK, out)
}
func (a *API) adminCreateProvider(w http.ResponseWriter, r *http.Request) {
var in struct {
Name string `json:"name"`
Type string `json:"type"`
BaseURL string `json:"base_url"`
Credentials map[string]any `json:"credentials"`
Enabled *bool `json:"enabled"`
Config map[string]any `json:"config"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if in.Name == "" || in.Type == "" {
writeError(w, http.StatusBadRequest, "name and type are required")
return
}
var creds []byte
if len(in.Credentials) > 0 {
plain, _ := json.Marshal(in.Credentials)
sealed, err := a.Cipher.Seal(plain)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to encrypt credentials")
return
}
creds = sealed
}
var cfg []byte
if len(in.Config) > 0 {
cfg, _ = json.Marshal(in.Config)
}
enabled := true
if in.Enabled != nil {
enabled = *in.Enabled
}
id, err := a.Store.CreateProvider(r.Context(), store.CreateProviderParams{
Name: in.Name, Type: in.Type, BaseURL: in.BaseURL,
Credentials: creds, Enabled: enabled, Config: cfg,
})
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
a.syncNow(r) // bring the provider + its models live immediately
writeJSON(w, http.StatusCreated, map[string]any{"id": id})
}
func (a *API) adminDeleteProvider(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
if err := a.Store.DeleteProvider(r.Context(), id); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
a.syncNow(r)
w.WriteHeader(http.StatusNoContent)
}
// --- virtual keys ---
func (a *API) adminCreateKey(w http.ResponseWriter, r *http.Request) {
var in struct {
Name string `json:"name"`
AllowAllModels bool `json:"allow_all_models"`
Rules []string `json:"rules"`
ExpiresInDays int `json:"expires_in_days"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
raw, err := generateKey()
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to generate key")
return
}
sum := sha256.Sum256([]byte(raw))
prefix := raw[:11] // "sk-" + 8 hex chars
var expires *time.Time
if in.ExpiresInDays > 0 {
t := time.Now().AddDate(0, 0, in.ExpiresInDays)
expires = &t
}
id, err := a.Store.CreateVirtualKey(r.Context(), store.CreateVirtualKeyParams{
Name: in.Name, KeyHash: sum[:], KeyPrefix: prefix,
AllowAllModels: in.AllowAllModels, ExpiresAt: expires, Rules: in.Rules,
})
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
// The raw key is shown exactly once.
writeJSON(w, http.StatusCreated, map[string]any{"id": id, "key": raw, "prefix": prefix})
}
func (a *API) adminListKeys(w http.ResponseWriter, r *http.Request) {
keys, err := a.Store.ListVirtualKeys(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
out := make([]map[string]any, 0, len(keys))
for _, k := range keys {
out = append(out, map[string]any{
"id": k.ID, "name": k.Name, "prefix": k.KeyPrefix, "type": k.Type,
"allow_all_models": k.AllowAllModels, "expires_at": k.ExpiresAt,
"revoked_at": k.RevokedAt, "last_used_at": k.LastUsedAt,
})
}
writeJSON(w, http.StatusOK, out)
}
func (a *API) adminRevokeKey(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
if err := a.Store.RevokeVirtualKey(r.Context(), id); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- model overrides ---
func (a *API) adminListModels(w http.ResponseWriter, r *http.Request) {
rows, err := a.Store.ListModelsAdmin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, rows)
}
func (a *API) adminUpdateModel(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
var in struct {
Enabled *bool `json:"enabled"`
Blocked *bool `json:"blocked"`
ParamOverrides map[string]any `json:"param_overrides"`
InputPrice *float64 `json:"input_price_per_mtok"`
OutputPrice *float64 `json:"output_price_per_mtok"`
CachedPrice *float64 `json:"cached_price_per_mtok"`
RPMLimit *int `json:"rpm_limit"`
TPMLimit *int `json:"tpm_limit"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
var params []byte
if in.ParamOverrides != nil {
params, _ = json.Marshal(in.ParamOverrides)
}
if err := a.Store.UpdateModelOverride(r.Context(), id, store.ModelOverride{
Enabled: in.Enabled, Blocked: in.Blocked, ParamOverrides: params,
InputPrice: in.InputPrice, OutputPrice: in.OutputPrice, CachedPrice: in.CachedPrice,
RPMLimit: in.RPMLimit, TPMLimit: in.TPMLimit,
}); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
_ = a.Cache.Reload(r.Context())
w.WriteHeader(http.StatusNoContent)
}
// --- settings ---
func (a *API) adminGetSettings(w http.ResponseWriter, r *http.Request) {
var strategy string
_, _ = a.Store.GetSetting(r.Context(), "routing.strategy", &strategy)
writeJSON(w, http.StatusOK, map[string]any{"routing.strategy": strategy})
}
func (a *API) adminPutSetting(w http.ResponseWriter, r *http.Request) {
key := chi.URLParam(r, "key")
var value any
if err := json.NewDecoder(r.Body).Decode(&value); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if err := a.Store.SetSetting(r.Context(), key, value); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
_ = a.Cache.Reload(r.Context())
w.WriteHeader(http.StatusNoContent)
}
// --- request logs ---
func (a *API) adminListLogs(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
p := store.ListLogsParams{
Provider: q.Get("provider"),
KeyPrefix: q.Get("key"),
Limit: 100,
}
if s := q.Get("status"); s == "error" {
p.StatusGte = 400
} else if s == "5xx" {
p.StatusGte = 500
}
if since := q.Get("since"); since != "" {
if t, err := time.Parse(time.RFC3339, since); err == nil {
p.Since = t
}
}
rows, err := a.Store.ListRequestLogs(r.Context(), p)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if rows == nil {
rows = []store.RequestLogListRow{}
}
writeJSON(w, http.StatusOK, rows)
}
func (a *API) adminKeyStats(w http.ResponseWriter, r *http.Request) {
since := time.Now().Add(-24 * time.Hour)
if s := r.URL.Query().Get("since"); s != "" {
if t, err := time.Parse(time.RFC3339, s); err == nil {
since = t
}
}
stats, err := a.Store.KeyStats(r.Context(), since)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if stats == nil {
stats = []store.KeyStat{}
}
writeJSON(w, http.StatusOK, stats)
}
// --- catalog ---
func (a *API) adminRefreshCatalog(w http.ResponseWriter, r *http.Request) {
if a.SyncNow == nil {
writeError(w, http.StatusServiceUnavailable, "sync unavailable")
return
}
if err := a.SyncNow(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "models": len(a.Cache.List())})
}
// syncNow triggers an immediate provider + catalog refresh if wired.
func (a *API) syncNow(r *http.Request) {
if a.SyncNow != nil {
_ = a.SyncNow(r.Context())
}
}
func generateKey() (string, error) {
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return "", err
}
return "sk-" + hex.EncodeToString(b), nil
}