Files
localmodelroutex/backend/internal/api/api.go
LovroG05 abecc4b888
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
feat: export request statistics to InfluxDB 3
Add an optional exporter that writes one point per proxied request to
the InfluxDB 3 write endpoint. Points are batched in memory and flushed
by size or interval. The exporter is off until GATEWAY_INFLUX_URL is set.
2026-10-11 17:20:14 +02:00

175 lines
5.8 KiB
Go

// Package api implements the gateway HTTP surface: the OpenAI-compatible proxy
// (/v1/*) and the admin API (/api/admin/*).
package api
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"git.govekar.net/LovroG05/localmodelroutex/internal/catalog"
"git.govekar.net/LovroG05/localmodelroutex/internal/crypto"
"git.govekar.net/LovroG05/localmodelroutex/internal/oidcauth"
"git.govekar.net/LovroG05/localmodelroutex/internal/providermgr"
"git.govekar.net/LovroG05/localmodelroutex/internal/ratelimit"
"git.govekar.net/LovroG05/localmodelroutex/internal/store"
)
// oidcVerifier validates OIDC tokens.
// Satisfied by *oidcauth.Verifier.
type oidcVerifier interface {
Verify(ctx context.Context, rawToken string) (*oidcauth.Claims, error)
IsAdmin(c *oidcauth.Claims) bool
VerifyAdmin(ctx context.Context, rawToken string) error
AuthURL() string
TokenURL() string
}
// ctxKeyUser is the context key for the authenticated user.
type ctxKeyUser struct{}
// authedUser holds the resolved user and their admin status for the current request.
type authedUser struct {
User store.User
IsAdmin bool
}
// userFrom retrieves the authenticated user from the request context.
// Returns nil when called outside of requireAuth.
func userFrom(ctx context.Context) *authedUser {
u, _ := ctx.Value(ctxKeyUser{}).(*authedUser)
return u
}
// RequestLogSink receives the per-request accounting rows written by the
// proxy handlers. *store.Store satisfies it; tests supply a fake.
type RequestLogSink interface {
InsertRequestLog(ctx context.Context, e store.RequestLog) (uuid.UUID, error)
InsertLogContent(ctx context.Context, logID uuid.UUID, messages json.RawMessage, completion string) error
TouchVirtualKey(ctx context.Context, id uuid.UUID) error
}
// RequestStats receives a copy of every request log row for export to a
// metrics backend. Record must not block. *influx.Exporter satisfies it.
type RequestStats interface {
Record(e store.RequestLog)
}
// Deps are the dependencies the API handlers need.
type Deps struct {
Store *store.Store
// Logs receives request log rows. Defaults to Store when nil.
Logs RequestLogSink
// Stats receives request log rows for the statistics exporter. May be nil.
Stats RequestStats
Cache *catalog.Cache
Manager *providermgr.Manager
Cipher *crypto.Cipher
OIDCVerifier oidcVerifier
OIDCClientID string
OIDCClientSecret string
OIDCAuthURL string // authorization_endpoint from discovery
OIDCTokenURL string // token_endpoint from discovery
// PublicBaseURL is the canonical external URL of this server (e.g.
// "https://lmr.example.com"). Required when OIDC is enabled — used to
// build the redirect_uri and the post-login fragment redirect so neither
// can be overridden by a forged Host/X-Forwarded-Host header.
PublicBaseURL string
Limiter *ratelimit.Limiter
// SyncNow triggers an immediate provider reload + catalog sync (used after
// admin changes). May be nil.
SyncNow func(context.Context) error
// LogLevel is the live logger level, updated when the app.log_level
// setting changes. May be nil.
LogLevel *slog.LevelVar
}
// API holds handler dependencies.
type API struct {
Deps
}
// New returns an API.
func New(d Deps) *API {
if d.Logs == nil && d.Store != nil {
d.Logs = d.Store
}
return &API{Deps: d}
}
// Mount registers the proxy and admin routes on r.
func (a *API) Mount(r chi.Router) {
r.Route("/v1", func(r chi.Router) {
r.Use(a.virtualKeyAuth)
r.Post("/chat/completions", a.chatCompletions)
r.Post("/embeddings", a.embeddings)
r.Post("/images/generations", a.imageGenerations)
r.Get("/models", a.listModels)
})
// Public auth endpoints — no auth middleware.
r.Get("/auth/login", a.oidcLogin)
r.Get("/auth/callback", a.oidcCallback)
// Authenticated user endpoints.
r.Group(func(r chi.Router) {
r.Use(a.requireAuth)
r.Get("/api/me", a.apiMe)
// Dashboard test chat — OIDC auth, synthetic allow-all key injected.
r.With(a.dashboardKeyAuth).Post("/api/chat/completions", a.chatCompletions)
r.With(a.dashboardKeyAuth).Get("/api/models", a.listModels)
r.Route("/api/admin", func(r chi.Router) {
// Keys and logs are user-scoped (admins see all via nil filter).
r.Get("/keys", a.adminListKeys)
r.Post("/keys", a.adminCreateKey)
r.Delete("/keys/{id}", a.adminRevokeKey)
r.Get("/logs", a.adminListLogs)
r.Get("/logs/{id}/content", a.adminGetLogContent)
r.Get("/stats/keys", a.adminKeyStats)
r.Get("/stats/summary", a.adminStatsSummary)
r.Get("/stats/models", a.adminStatsModels)
r.Get("/stats/series", a.adminStatsSeries)
// The following routes require admin group membership.
r.Group(func(r chi.Router) {
r.Use(a.requireAdmin)
r.Get("/providers", a.adminListProviders)
r.Post("/providers", a.adminCreateProvider)
r.Patch("/providers/{id}", a.adminUpdateProvider)
r.Delete("/providers/{id}", a.adminDeleteProvider)
r.Get("/models", a.adminListModels)
r.Patch("/models/{id}", a.adminUpdateModel)
r.Get("/settings", a.adminGetSettings)
r.Put("/settings/{key}", a.adminPutSetting)
r.Post("/catalog/refresh", a.adminRefreshCatalog)
})
})
})
// Legacy public endpoint — tells the dashboard OIDC is configured.
r.Get("/api/auth/config", a.authConfig)
}
// apiMe returns the current user's profile, admin status, and the configured
// app name so the dashboard can brand itself without admin access.
func (a *API) apiMe(w http.ResponseWriter, r *http.Request) {
u := userFrom(r.Context())
appName := "LMR"
var s string
if ok, _ := a.Store.GetSetting(r.Context(), "app.name", &s); ok && s != "" {
appName = s
}
writeJSON(w, http.StatusOK, map[string]any{
"id": u.User.ID,
"email": u.User.Email,
"name": u.User.Name,
"is_admin": u.IsAdmin,
"app_name": appName,
})
}