All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Any model can name another model as its backup. The gateway tries the backup when the model is offline, rate limited, or its upstream fails before the client has received anything. The response reports the requested model, so the client sees no switch. The request log records each failed attempt and the address that served the request, and the cost uses the backup's prices.
729 lines
21 KiB
Go
729 lines
21 KiB
Go
package api
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
|
|
"git.govekar.net/LovroG05/localmodelroutex/internal/catalog"
|
|
"git.govekar.net/LovroG05/localmodelroutex/internal/providers"
|
|
"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
|
|
}
|
|
counts, err := a.Store.ProviderModelCounts(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
out := make([]map[string]any, 0, len(rows))
|
|
for _, p := range rows {
|
|
var cfg map[string]any
|
|
if len(p.Config) > 0 {
|
|
_ = json.Unmarshal(p.Config, &cfg)
|
|
}
|
|
if cfg == nil {
|
|
cfg = map[string]any{}
|
|
}
|
|
c := counts[p.ID]
|
|
// A provider that failed to build (bad credentials) is enabled in the DB
|
|
// but absent from the live manager.
|
|
_, live := a.Manager.Get(p.Name)
|
|
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,
|
|
"config": cfg, "created_at": p.CreatedAt,
|
|
"live": live,
|
|
"model_count": c.Models,
|
|
"available_count": c.Available,
|
|
"last_synced_at": c.LastSyncedAt,
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
// validateProviderName rejects names that cannot serve as an address prefix.
|
|
func validateProviderName(name string) error {
|
|
if name == "" {
|
|
return fmt.Errorf("name is required")
|
|
}
|
|
if len(name) > 64 {
|
|
return fmt.Errorf("name must be 64 characters or fewer")
|
|
}
|
|
if strings.EqualFold(name, catalog.AnyProvider) {
|
|
return fmt.Errorf("%q is reserved for cross-provider routing", name)
|
|
}
|
|
for _, c := range name {
|
|
ok := (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.'
|
|
if !ok {
|
|
return fmt.Errorf("name may only contain letters, digits, '-', '_' and '.'")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// sealCredentials encrypts a non-empty credential map. Empty maps return nil.
|
|
func (a *API) sealCredentials(creds map[string]any) ([]byte, error) {
|
|
clean := map[string]any{}
|
|
for k, v := range creds {
|
|
if s, ok := v.(string); ok && strings.TrimSpace(s) == "" {
|
|
continue
|
|
}
|
|
clean[k] = v
|
|
}
|
|
if len(clean) == 0 {
|
|
return nil, nil
|
|
}
|
|
plain, _ := json.Marshal(clean)
|
|
return a.Cipher.Seal(plain)
|
|
}
|
|
|
|
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
|
|
}
|
|
in.Name = strings.TrimSpace(in.Name)
|
|
in.BaseURL = strings.TrimSpace(in.BaseURL)
|
|
if err := validateProviderName(in.Name); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
if !slices.Contains(providers.Types(), in.Type) {
|
|
writeError(w, http.StatusBadRequest, fmt.Sprintf("unknown provider type %q (want one of %s)", in.Type, strings.Join(providers.Types(), ", ")))
|
|
return
|
|
}
|
|
if in.BaseURL != "" && !strings.HasPrefix(in.BaseURL, "http://") && !strings.HasPrefix(in.BaseURL, "https://") {
|
|
writeError(w, http.StatusBadRequest, "base_url must start with http:// or https://")
|
|
return
|
|
}
|
|
|
|
creds, err := a.sealCredentials(in.Credentials)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to encrypt credentials")
|
|
return
|
|
}
|
|
var cfg []byte
|
|
if len(in.Config) > 0 {
|
|
cfg, _ = json.Marshal(in.Config)
|
|
}
|
|
enabled := true
|
|
if in.Enabled != nil {
|
|
enabled = *in.Enabled
|
|
}
|
|
|
|
// Build once up front so a bad credential shape is reported to the caller
|
|
// instead of being logged on the next reload.
|
|
var plain []byte
|
|
if creds != nil {
|
|
plain, _ = json.Marshal(in.Credentials)
|
|
}
|
|
if _, err := providers.New(in.Type, providers.Config{Name: in.Name, BaseURL: in.BaseURL, Credentials: plain, Settings: in.Config}); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
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 {
|
|
if strings.Contains(err.Error(), "providers_name_key") {
|
|
writeError(w, http.StatusConflict, fmt.Sprintf("a provider named %q already exists", in.Name))
|
|
return
|
|
}
|
|
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) adminUpdateProvider(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 {
|
|
BaseURL *string `json:"base_url"`
|
|
Enabled *bool `json:"enabled"`
|
|
Credentials map[string]any `json:"credentials"`
|
|
Config map[string]any `json:"config"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
|
return
|
|
}
|
|
p := store.UpdateProviderParams{BaseURL: in.BaseURL, Enabled: in.Enabled}
|
|
if in.BaseURL != nil {
|
|
u := strings.TrimSpace(*in.BaseURL)
|
|
if u != "" && !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
|
|
writeError(w, http.StatusBadRequest, "base_url must start with http:// or https://")
|
|
return
|
|
}
|
|
p.BaseURL = &u
|
|
}
|
|
if len(in.Credentials) > 0 {
|
|
creds, err := a.sealCredentials(in.Credentials)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to encrypt credentials")
|
|
return
|
|
}
|
|
p.Credentials = creds
|
|
}
|
|
if in.Config != nil {
|
|
p.Config, _ = json.Marshal(in.Config)
|
|
}
|
|
if err := a.Store.UpdateProvider(r.Context(), id, p); err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
a.syncNow(r)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
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
|
|
}
|
|
in.Name = strings.TrimSpace(in.Name)
|
|
if in.Name == "" {
|
|
writeError(w, http.StatusBadRequest, "name is required")
|
|
return
|
|
}
|
|
if in.ExpiresInDays < 0 {
|
|
writeError(w, http.StatusBadRequest, "expires_in_days must be 0 or more")
|
|
return
|
|
}
|
|
rules := make([]string, 0, len(in.Rules))
|
|
for _, rule := range in.Rules {
|
|
rule = strings.TrimSpace(rule)
|
|
if rule == "" {
|
|
continue
|
|
}
|
|
if rule != "*" && !strings.Contains(rule, "/") {
|
|
writeError(w, http.StatusBadRequest, fmt.Sprintf("rule %q must be '*', '<provider>/*' or '<provider>/<slug>'", rule))
|
|
return
|
|
}
|
|
rules = append(rules, rule)
|
|
}
|
|
if !in.AllowAllModels && len(rules) == 0 {
|
|
writeError(w, http.StatusBadRequest, "a restricted key needs at least one model rule")
|
|
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
|
|
}
|
|
|
|
// Bind the key to the authenticated user.
|
|
au := userFrom(r.Context())
|
|
var userID *uuid.UUID
|
|
if au != nil {
|
|
userID = &au.User.ID
|
|
}
|
|
|
|
id, err := a.Store.CreateVirtualKey(r.Context(), store.CreateVirtualKeyParams{
|
|
Name: in.Name, KeyHash: sum[:], KeyPrefix: prefix, UserID: userID,
|
|
AllowAllModels: in.AllowAllModels, ExpiresAt: expires, Rules: 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(), scopedUserID(r))
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
out := make([]map[string]any, 0, len(keys))
|
|
for _, k := range keys {
|
|
rules := k.Rules
|
|
if rules == nil {
|
|
rules = []string{}
|
|
}
|
|
out = append(out, map[string]any{
|
|
"id": k.ID, "name": k.Name, "prefix": k.KeyPrefix, "type": k.Type,
|
|
"allow_all_models": k.AllowAllModels, "rules": rules,
|
|
"created_at": k.CreatedAt, "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
|
|
}
|
|
|
|
au := userFrom(r.Context())
|
|
if au != nil && !au.IsAdmin {
|
|
// Non-admins may only revoke their own keys.
|
|
key, err := a.Store.GetVirtualKeyByID(r.Context(), id)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if key == nil || key.UserID == nil || *key.UserID != au.User.ID {
|
|
writeError(w, http.StatusNotFound, "key not found")
|
|
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
|
|
}
|
|
if rows == nil {
|
|
rows = []store.AdminModelRow{}
|
|
}
|
|
writeJSON(w, http.StatusOK, rows)
|
|
}
|
|
|
|
// patchField decodes an optional JSON field. A present null clears the column.
|
|
func patchField[T any](fields map[string]json.RawMessage, key string) (store.Patch[T], error) {
|
|
raw, ok := fields[key]
|
|
if !ok {
|
|
return store.Patch[T]{}, nil
|
|
}
|
|
if string(raw) == "null" {
|
|
return store.Patch[T]{Set: true}, nil
|
|
}
|
|
var v T
|
|
if err := json.Unmarshal(raw, &v); err != nil {
|
|
return store.Patch[T]{}, fmt.Errorf("invalid value for %s", key)
|
|
}
|
|
return store.Patch[T]{Set: true, Value: &v}, nil
|
|
}
|
|
|
|
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 fields map[string]json.RawMessage
|
|
if err := json.NewDecoder(r.Body).Decode(&fields); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
|
return
|
|
}
|
|
|
|
var o store.ModelOverride
|
|
if raw, ok := fields["enabled"]; ok {
|
|
if err := json.Unmarshal(raw, &o.Enabled); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid value for enabled")
|
|
return
|
|
}
|
|
}
|
|
if raw, ok := fields["blocked"]; ok {
|
|
if err := json.Unmarshal(raw, &o.Blocked); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid value for blocked")
|
|
return
|
|
}
|
|
}
|
|
if raw, ok := fields["param_overrides"]; ok && string(raw) != "null" {
|
|
var m map[string]any
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
|
writeError(w, http.StatusBadRequest, "param_overrides must be an object")
|
|
return
|
|
}
|
|
o.ParamOverrides, _ = json.Marshal(m)
|
|
}
|
|
if o.InputPrice, err = patchField[float64](fields, "input_price_per_mtok"); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
if o.OutputPrice, err = patchField[float64](fields, "output_price_per_mtok"); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
if o.CachedPrice, err = patchField[float64](fields, "cached_price_per_mtok"); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
if o.RPMLimit, err = patchField[int](fields, "rpm_limit"); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
if o.TPMLimit, err = patchField[int](fields, "tpm_limit"); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
if o.ContextLength, err = patchField[int](fields, "context_length"); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
if o.ContextLength.Value != nil && *o.ContextLength.Value < 1 {
|
|
writeError(w, http.StatusBadRequest, "context_length must be 1 or more")
|
|
return
|
|
}
|
|
if o.FallbackModelID, err = patchField[uuid.UUID](fields, "fallback_model_id"); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
if o.FallbackModelID.Value != nil && *o.FallbackModelID.Value == id {
|
|
writeError(w, http.StatusBadRequest, "a model cannot be its own backup")
|
|
return
|
|
}
|
|
for _, p := range []store.Patch[float64]{o.InputPrice, o.OutputPrice, o.CachedPrice} {
|
|
if p.Value != nil && *p.Value < 0 {
|
|
writeError(w, http.StatusBadRequest, "prices must be 0 or more")
|
|
return
|
|
}
|
|
}
|
|
for _, p := range []store.Patch[int]{o.RPMLimit, o.TPMLimit} {
|
|
if p.Value != nil && *p.Value < 0 {
|
|
writeError(w, http.StatusBadRequest, "limits must be 0 or more")
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := a.Store.UpdateModelOverride(r.Context(), id, o); err != nil {
|
|
var pgErr *pgconn.PgError
|
|
if errors.As(err, &pgErr) && pgErr.Code == "23503" /* foreign_key_violation */ {
|
|
writeError(w, http.StatusBadRequest, "backup model not found")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
_ = a.Cache.Reload(r.Context())
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// --- settings ---
|
|
|
|
// Settings the dashboard may read and write, with their validators.
|
|
var settingValidators = map[string]func(any) error{
|
|
"routing.strategy": oneOf(string(catalog.StrategyRoundRobin), string(catalog.StrategyLowestCost)),
|
|
"app.name": func(v any) error {
|
|
s, ok := v.(string)
|
|
if !ok || strings.TrimSpace(s) == "" || len(s) > 64 {
|
|
return fmt.Errorf("app.name must be a non-empty string of at most 64 characters")
|
|
}
|
|
return nil
|
|
},
|
|
"app.log_level": oneOf("debug", "info", "warn", "error"),
|
|
"log.retention_days": func(v any) error {
|
|
f, ok := v.(float64)
|
|
if !ok || f < 0 || f != float64(int(f)) {
|
|
return fmt.Errorf("log.retention_days must be a whole number of 0 or more")
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
func oneOf(allowed ...string) func(any) error {
|
|
return func(v any) error {
|
|
s, _ := v.(string)
|
|
if !slices.Contains(allowed, s) {
|
|
return fmt.Errorf("value must be one of %s", strings.Join(allowed, ", "))
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (a *API) adminGetSettings(w http.ResponseWriter, r *http.Request) {
|
|
out := map[string]any{}
|
|
for key := range settingValidators {
|
|
var v any
|
|
if ok, _ := a.Store.GetSetting(r.Context(), key, &v); ok {
|
|
out[key] = v
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
func (a *API) adminPutSetting(w http.ResponseWriter, r *http.Request) {
|
|
key := chi.URLParam(r, "key")
|
|
validate, ok := settingValidators[key]
|
|
if !ok {
|
|
writeError(w, http.StatusNotFound, fmt.Sprintf("unknown setting %q", key))
|
|
return
|
|
}
|
|
var value any
|
|
if err := json.NewDecoder(r.Body).Decode(&value); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
|
return
|
|
}
|
|
if err := validate(value); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
if err := a.Store.SetSetting(r.Context(), key, value); err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
switch key {
|
|
case "routing.strategy":
|
|
_ = a.Cache.Reload(r.Context())
|
|
case "app.log_level":
|
|
if a.LogLevel != nil {
|
|
a.LogLevel.Set(parseLogLevel(value.(string)))
|
|
}
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// parseLogLevel maps a setting value to a slog level, defaulting to info.
|
|
func parseLogLevel(s string) slog.Level {
|
|
switch s {
|
|
case "debug":
|
|
return slog.LevelDebug
|
|
case "warn":
|
|
return slog.LevelWarn
|
|
case "error":
|
|
return slog.LevelError
|
|
default:
|
|
return slog.LevelInfo
|
|
}
|
|
}
|
|
|
|
// --- request logs ---
|
|
|
|
func (a *API) adminListLogs(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
limit := 100
|
|
if l := q.Get("limit"); l != "" {
|
|
if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 100 {
|
|
limit = n
|
|
}
|
|
}
|
|
p := store.ListLogsParams{
|
|
Provider: q.Get("provider"),
|
|
KeyPrefix: q.Get("key"),
|
|
UserID: scopedUserID(r),
|
|
Query: q.Get("q"),
|
|
Limit: limit,
|
|
}
|
|
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
|
|
}
|
|
}
|
|
// Cursor for "load more": the CreatedAt/ID of the last row already shown.
|
|
if before := q.Get("before"); before != "" {
|
|
if t, err := time.Parse(time.RFC3339Nano, before); err == nil {
|
|
if id, err := uuid.Parse(q.Get("before_id")); err == nil {
|
|
p.Before = t
|
|
p.BeforeID = id
|
|
}
|
|
}
|
|
}
|
|
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) adminGetLogContent(w http.ResponseWriter, r *http.Request) {
|
|
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid log id")
|
|
return
|
|
}
|
|
messages, completion, err := a.Store.GetLogContent(r.Context(), id, scopedUserID(r))
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"Messages": messages, "Completion": completion})
|
|
}
|
|
|
|
// sinceParam parses the "since" query param (RFC3339), defaulting to 24h ago.
|
|
func sinceParam(r *http.Request) time.Time {
|
|
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
|
|
}
|
|
}
|
|
return since
|
|
}
|
|
|
|
// scopedUserID returns the current user's ID for non-admins, or nil for
|
|
// admins (who see stats across all users).
|
|
func scopedUserID(r *http.Request) *uuid.UUID {
|
|
au := userFrom(r.Context())
|
|
if au != nil && !au.IsAdmin {
|
|
return &au.User.ID
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *API) adminKeyStats(w http.ResponseWriter, r *http.Request) {
|
|
stats, err := a.Store.KeyStats(r.Context(), sinceParam(r), scopedUserID(r))
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if stats == nil {
|
|
stats = []store.KeyStat{}
|
|
}
|
|
writeJSON(w, http.StatusOK, stats)
|
|
}
|
|
|
|
func (a *API) adminStatsSummary(w http.ResponseWriter, r *http.Request) {
|
|
summary, err := a.Store.GetLogsSummary(r.Context(), sinceParam(r), scopedUserID(r))
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, summary)
|
|
}
|
|
|
|
func (a *API) adminStatsModels(w http.ResponseWriter, r *http.Request) {
|
|
limit := 6
|
|
if l := r.URL.Query().Get("limit"); l != "" {
|
|
if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 50 {
|
|
limit = n
|
|
}
|
|
}
|
|
models, err := a.Store.TopModels(r.Context(), sinceParam(r), scopedUserID(r), limit)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if models == nil {
|
|
models = []store.ModelStat{}
|
|
}
|
|
writeJSON(w, http.StatusOK, models)
|
|
}
|
|
|
|
func (a *API) adminStatsSeries(w http.ResponseWriter, r *http.Request) {
|
|
buckets := 24
|
|
if b := r.URL.Query().Get("buckets"); b != "" {
|
|
if n, err := strconv.Atoi(b); err == nil && n > 0 && n <= 366 {
|
|
buckets = n
|
|
}
|
|
}
|
|
series, err := a.Store.RequestVolumeSeries(r.Context(), sinceParam(r), scopedUserID(r), buckets)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, series)
|
|
}
|
|
|
|
// --- 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 {
|
|
if err := a.SyncNow(r.Context()); err != nil {
|
|
slog.Warn("catalog sync after admin change failed", "err", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func generateKey() (string, error) {
|
|
b := make([]byte, 24)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
return "sk-" + hex.EncodeToString(b), nil
|
|
}
|