- 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
314 lines
8.8 KiB
Go
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
|
|
}
|