All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
The sync took meta.n_ctx_train from the model listing, which is the training context, not the -c the server runs with. The llama.cpp provider now asks the server for the live value: /props on a single server, the router's /models status and /props?model=, and /upstream/<model>/props for models llama-swap has loaded. Stopped models are never probed, because that would start them. Admins can also pin the context window per model from the Models page. The override wins over the discovered value and survives syncs.
714 lines
20 KiB
Go
714 lines
20 KiB
Go
package api
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
|
|
"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
|
|
}
|
|
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 {
|
|
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
|
|
}
|