All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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.
175 lines
5.8 KiB
Go
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,
|
|
})
|
|
}
|