feat(auth): add HTMX support for auth flows and improve error handling

This commit is contained in:
Ray Andrew 2026-02-15 05:20:41 -06:00
parent 291b8f4863
commit 9ea3274ade
Signed by: rayandrew
SSH key fingerprint: SHA256:EUCV+qCSqkap8rR+p+zGjxHfKI06G0GJKgo1DIOniQY
14 changed files with 538 additions and 273 deletions

View file

@ -141,6 +141,7 @@ func main() {
// Audit logger // Audit logger
auditLogger := storage.NewAuditLogger(db) auditLogger := storage.NewAuditLogger(db)
auditLogger.OnWrite = sseBroker.Notify
authHandlers.SetAuditLogger(auditLogger) authHandlers.SetAuditLogger(auditLogger)
// Debug logger // Debug logger
@ -149,6 +150,7 @@ func main() {
debugDataDir = filepath.Dir(cfg.Database.Path) debugDataDir = filepath.Dir(cfg.Database.Path)
} }
debugLogger := storage.NewDebugLogger(db, cfg.Debug.Enabled, debugDataDir) debugLogger := storage.NewDebugLogger(db, cfg.Debug.Enabled, debugDataDir)
debugLogger.OnWrite = sseBroker.Notify
// Seed default admin // Seed default admin
seedDefaultAdmin(cfg, authStore) seedDefaultAdmin(cfg, authStore)
@ -248,6 +250,11 @@ func main() {
r.Post("/api/auth/setup", authHandlers.Setup) r.Post("/api/auth/setup", authHandlers.Setup)
r.Post("/api/auth/login/totp", authHandlers.LoginTOTP) r.Post("/api/auth/login/totp", authHandlers.LoginTOTP)
// Favicon (prevent 401 noise in browser console)
r.Get("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
// Root redirect // Root redirect
r.Get("/", func(w http.ResponseWriter, r *http.Request) { r.Get("/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/dashboard", http.StatusFound) http.Redirect(w, r, "/dashboard", http.StatusFound)

View file

@ -6,6 +6,7 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"html/template"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@ -124,7 +125,13 @@ func (h *Handlers) Status(w http.ResponseWriter, r *http.Request) {
} }
func (h *Handlers) Setup(w http.ResponseWriter, r *http.Request) { func (h *Handlers) Setup(w http.ResponseWriter, r *http.Request) {
htmx := isHTMX(r)
if h.store.HasAnyUser() { if h.store.HasAnyUser() {
if htmx {
writeHTMXError(w, "already initialized")
return
}
writeError(w, http.StatusBadRequest, "already initialized") writeError(w, http.StatusBadRequest, "already initialized")
return return
} }
@ -134,27 +141,48 @@ func (h *Handlers) Setup(w http.ResponseWriter, r *http.Request) {
Password string `json:"password"` Password string `json:"password"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
if htmx {
writeHTMXError(w, "invalid request")
return
}
writeError(w, http.StatusBadRequest, "invalid JSON") writeError(w, http.StatusBadRequest, "invalid JSON")
return return
} }
if req.Username == "" || req.Password == "" { if req.Username == "" || req.Password == "" {
if htmx {
writeHTMXError(w, "username and password required")
return
}
writeError(w, http.StatusBadRequest, "username and password required") writeError(w, http.StatusBadRequest, "username and password required")
return return
} }
if len(req.Password) < 8 { if len(req.Password) < 8 {
if htmx {
writeHTMXError(w, "password must be at least 8 characters")
return
}
writeError(w, http.StatusBadRequest, "password must be at least 8 characters") writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
return return
} }
user, err := h.store.CreateUser(req.Username, req.Password, true) user, err := h.store.CreateUser(req.Username, req.Password, true)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create user: "+err.Error()) msg := "failed to create user: " + err.Error()
if htmx {
writeHTMXError(w, msg)
return
}
writeError(w, http.StatusInternalServerError, msg)
return return
} }
// Auto-login // Auto-login
sessionID, err := h.store.CreateSession(user.ID, 7*24*time.Hour) sessionID, err := h.store.CreateSession(user.ID, 7*24*time.Hour)
if err != nil { if err != nil {
if htmx {
writeHTMXError(w, "failed to create session")
return
}
writeError(w, http.StatusInternalServerError, "failed to create session") writeError(w, http.StatusInternalServerError, "failed to create session")
return return
} }
@ -162,6 +190,10 @@ func (h *Handlers) Setup(w http.ResponseWriter, r *http.Request) {
h.audit(r, "auth.setup", "user", fmt.Sprintf("%d", user.ID), "initial setup") h.audit(r, "auth.setup", "user", fmt.Sprintf("%d", user.ID), "initial setup")
h.setSessionCookie(w, sessionID) h.setSessionCookie(w, sessionID)
if htmx {
writeHTMXRedirect(w, "/dashboard")
return
}
writeJSON(w, map[string]any{ writeJSON(w, map[string]any{
"user": map[string]any{ "user": map[string]any{
"id": user.ID, "id": user.ID,
@ -172,11 +204,17 @@ func (h *Handlers) Setup(w http.ResponseWriter, r *http.Request) {
} }
func (h *Handlers) Login(w http.ResponseWriter, r *http.Request) { func (h *Handlers) Login(w http.ResponseWriter, r *http.Request) {
htmx := isHTMX(r)
ip := r.RemoteAddr ip := r.RemoteAddr
if fwd := r.Header.Get("X-Real-IP"); fwd != "" { if fwd := r.Header.Get("X-Real-IP"); fwd != "" {
ip = fwd ip = fwd
} }
if !h.loginLimiter.allow(ip) { if !h.loginLimiter.allow(ip) {
if htmx {
writeHTMXError(w, "too many login attempts, try again in a minute")
return
}
writeError(w, http.StatusTooManyRequests, "too many login attempts, try again in a minute") writeError(w, http.StatusTooManyRequests, "too many login attempts, try again in a minute")
return return
} }
@ -186,17 +224,29 @@ func (h *Handlers) Login(w http.ResponseWriter, r *http.Request) {
Password string `json:"password"` Password string `json:"password"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
if htmx {
writeHTMXError(w, "invalid request")
return
}
writeError(w, http.StatusBadRequest, "invalid JSON") writeError(w, http.StatusBadRequest, "invalid JSON")
return return
} }
user, err := h.store.GetUserByUsername(req.Username) user, err := h.store.GetUserByUsername(req.Username)
if err != nil { if err != nil {
if htmx {
writeHTMXError(w, "invalid credentials")
return
}
writeError(w, http.StatusUnauthorized, "invalid credentials") writeError(w, http.StatusUnauthorized, "invalid credentials")
return return
} }
if !h.store.CheckPassword(user, req.Password) { if !h.store.CheckPassword(user, req.Password) {
if htmx {
writeHTMXError(w, "invalid credentials")
return
}
writeError(w, http.StatusUnauthorized, "invalid credentials") writeError(w, http.StatusUnauthorized, "invalid credentials")
return return
} }
@ -212,12 +262,27 @@ func (h *Handlers) Login(w http.ResponseWriter, r *http.Request) {
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
MaxAge: 300, // 5 minutes MaxAge: 300, // 5 minutes
}) })
if htmx {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, `<form id="totp-form" hx-post="/api/auth/login/totp" hx-target="#login-form-area" hx-swap="innerHTML" hx-ext="json-enc">
<div class="form-group">
<label>Enter your 6-digit authenticator code</label>
<input type="text" name="code" required pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code" inputmode="numeric" style="text-align:center; font-size:1.5rem; letter-spacing:0.3em;" autofocus>
</div>
<button type="submit" class="btn-primary">Verify</button>
</form>`)
return
}
writeJSON(w, map[string]any{"require_totp": true}) writeJSON(w, map[string]any{"require_totp": true})
return return
} }
sessionID, err := h.store.CreateSession(user.ID, 7*24*time.Hour) sessionID, err := h.store.CreateSession(user.ID, 7*24*time.Hour)
if err != nil { if err != nil {
if htmx {
writeHTMXError(w, "failed to create session")
return
}
writeError(w, http.StatusInternalServerError, "failed to create session") writeError(w, http.StatusInternalServerError, "failed to create session")
return return
} }
@ -225,6 +290,10 @@ func (h *Handlers) Login(w http.ResponseWriter, r *http.Request) {
h.audit(r, "auth.login", "user", fmt.Sprintf("%d", user.ID), user.Username) h.audit(r, "auth.login", "user", fmt.Sprintf("%d", user.ID), user.Username)
h.setSessionCookie(w, sessionID) h.setSessionCookie(w, sessionID)
if htmx {
writeHTMXRedirect(w, "/dashboard")
return
}
writeJSON(w, map[string]any{ writeJSON(w, map[string]any{
"require_totp": false, "require_totp": false,
"user": map[string]any{ "user": map[string]any{
@ -236,14 +305,24 @@ func (h *Handlers) Login(w http.ResponseWriter, r *http.Request) {
} }
func (h *Handlers) LoginTOTP(w http.ResponseWriter, r *http.Request) { func (h *Handlers) LoginTOTP(w http.ResponseWriter, r *http.Request) {
htmx := isHTMX(r)
cookie, err := r.Cookie("llmgw_pending") cookie, err := r.Cookie("llmgw_pending")
if err != nil || cookie.Value == "" { if err != nil || cookie.Value == "" {
if htmx {
writeHTMXError(w, "no pending login")
return
}
writeError(w, http.StatusBadRequest, "no pending login") writeError(w, http.StatusBadRequest, "no pending login")
return return
} }
userID, err := h.verifyPendingToken(cookie.Value) userID, err := h.verifyPendingToken(cookie.Value)
if err != nil { if err != nil {
if htmx {
writeHTMXError(w, "invalid or expired pending login")
return
}
writeError(w, http.StatusBadRequest, "invalid or expired pending login") writeError(w, http.StatusBadRequest, "invalid or expired pending login")
return return
} }
@ -252,17 +331,29 @@ func (h *Handlers) LoginTOTP(w http.ResponseWriter, r *http.Request) {
Code string `json:"code"` Code string `json:"code"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
if htmx {
writeHTMXError(w, "invalid request")
return
}
writeError(w, http.StatusBadRequest, "invalid JSON") writeError(w, http.StatusBadRequest, "invalid JSON")
return return
} }
user, err := h.store.GetUserByID(userID) user, err := h.store.GetUserByID(userID)
if err != nil { if err != nil {
if htmx {
writeHTMXError(w, "user not found")
return
}
writeError(w, http.StatusBadRequest, "user not found") writeError(w, http.StatusBadRequest, "user not found")
return return
} }
if !ValidateTOTPCode(user.TOTPSecret, req.Code) { if !ValidateTOTPCode(user.TOTPSecret, req.Code) {
if htmx {
writeHTMXError(w, "invalid TOTP code")
return
}
writeError(w, http.StatusUnauthorized, "invalid TOTP code") writeError(w, http.StatusUnauthorized, "invalid TOTP code")
return return
} }
@ -278,11 +369,19 @@ func (h *Handlers) LoginTOTP(w http.ResponseWriter, r *http.Request) {
sessionID, err := h.store.CreateSession(user.ID, 7*24*time.Hour) sessionID, err := h.store.CreateSession(user.ID, 7*24*time.Hour)
if err != nil { if err != nil {
if htmx {
writeHTMXError(w, "failed to create session")
return
}
writeError(w, http.StatusInternalServerError, "failed to create session") writeError(w, http.StatusInternalServerError, "failed to create session")
return return
} }
h.setSessionCookie(w, sessionID) h.setSessionCookie(w, sessionID)
if htmx {
writeHTMXRedirect(w, "/dashboard")
return
}
writeJSON(w, map[string]any{ writeJSON(w, map[string]any{
"user": map[string]any{ "user": map[string]any{
"id": user.ID, "id": user.ID,
@ -308,6 +407,10 @@ func (h *Handlers) Logout(w http.ResponseWriter, r *http.Request) {
MaxAge: -1, MaxAge: -1,
}) })
if isHTMX(r) {
writeHTMXRedirect(w, "/login")
return
}
writeJSON(w, map[string]string{"status": "ok"}) writeJSON(w, map[string]string{"status": "ok"})
} }
@ -350,6 +453,7 @@ func (h *Handlers) TOTPSetup(w http.ResponseWriter, r *http.Request) {
} }
func (h *Handlers) TOTPVerify(w http.ResponseWriter, r *http.Request) { func (h *Handlers) TOTPVerify(w http.ResponseWriter, r *http.Request) {
htmx := isHTMX(r)
user := UserFromContext(r.Context()) user := UserFromContext(r.Context())
if user == nil { if user == nil {
writeError(w, http.StatusUnauthorized, "not authenticated") writeError(w, http.StatusUnauthorized, "not authenticated")
@ -360,6 +464,10 @@ func (h *Handlers) TOTPVerify(w http.ResponseWriter, r *http.Request) {
Code string `json:"code"` Code string `json:"code"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
if htmx {
writeHTMXError(w, "invalid request")
return
}
writeError(w, http.StatusBadRequest, "invalid JSON") writeError(w, http.StatusBadRequest, "invalid JSON")
return return
} }
@ -367,30 +475,53 @@ func (h *Handlers) TOTPVerify(w http.ResponseWriter, r *http.Request) {
// Re-fetch user to get latest TOTP secret // Re-fetch user to get latest TOTP secret
user, err := h.store.GetUserByID(user.ID) user, err := h.store.GetUserByID(user.ID)
if err != nil { if err != nil {
if htmx {
writeHTMXError(w, "failed to fetch user")
return
}
writeError(w, http.StatusInternalServerError, "failed to fetch user") writeError(w, http.StatusInternalServerError, "failed to fetch user")
return return
} }
if user.TOTPSecret == "" { if user.TOTPSecret == "" {
if htmx {
writeHTMXError(w, "TOTP not set up yet")
return
}
writeError(w, http.StatusBadRequest, "TOTP not set up, call /api/auth/totp/setup first") writeError(w, http.StatusBadRequest, "TOTP not set up, call /api/auth/totp/setup first")
return return
} }
if !ValidateTOTPCode(user.TOTPSecret, req.Code) { if !ValidateTOTPCode(user.TOTPSecret, req.Code) {
if htmx {
writeHTMXError(w, "invalid TOTP code")
return
}
writeError(w, http.StatusBadRequest, "invalid TOTP code") writeError(w, http.StatusBadRequest, "invalid TOTP code")
return return
} }
if err := h.store.EnableTOTP(user.ID); err != nil { if err := h.store.EnableTOTP(user.ID); err != nil {
if htmx {
writeHTMXError(w, "failed to enable TOTP")
return
}
writeError(w, http.StatusInternalServerError, "failed to enable TOTP") writeError(w, http.StatusInternalServerError, "failed to enable TOTP")
return return
} }
h.audit(r, "totp.enable", "user", fmt.Sprintf("%d", user.ID), "") h.audit(r, "totp.enable", "user", fmt.Sprintf("%d", user.ID), "")
if htmx {
// Trigger settings page reload to show updated TOTP status
w.Header().Set("HX-Trigger", "settingsRefresh")
writeHTMXSuccess(w, "Two-factor authentication enabled")
return
}
writeJSON(w, map[string]string{"status": "totp_enabled"}) writeJSON(w, map[string]string{"status": "totp_enabled"})
} }
func (h *Handlers) TOTPDisable(w http.ResponseWriter, r *http.Request) { func (h *Handlers) TOTPDisable(w http.ResponseWriter, r *http.Request) {
htmx := isHTMX(r)
user := UserFromContext(r.Context()) user := UserFromContext(r.Context())
if user == nil { if user == nil {
writeError(w, http.StatusUnauthorized, "not authenticated") writeError(w, http.StatusUnauthorized, "not authenticated")
@ -398,11 +529,20 @@ func (h *Handlers) TOTPDisable(w http.ResponseWriter, r *http.Request) {
} }
if err := h.store.DisableTOTP(user.ID); err != nil { if err := h.store.DisableTOTP(user.ID); err != nil {
if htmx {
writeHTMXError(w, "failed to disable TOTP")
return
}
writeError(w, http.StatusInternalServerError, "failed to disable TOTP") writeError(w, http.StatusInternalServerError, "failed to disable TOTP")
return return
} }
h.audit(r, "totp.disable", "user", fmt.Sprintf("%d", user.ID), "") h.audit(r, "totp.disable", "user", fmt.Sprintf("%d", user.ID), "")
if htmx {
w.Header().Set("HX-Trigger", "settingsRefresh")
writeHTMXSuccess(w, "Two-factor authentication disabled")
return
}
writeJSON(w, map[string]string{"status": "totp_disabled"}) writeJSON(w, map[string]string{"status": "totp_disabled"})
} }
@ -437,31 +577,53 @@ func (h *Handlers) ListUsers(w http.ResponseWriter, r *http.Request) {
} }
func (h *Handlers) CreateUser(w http.ResponseWriter, r *http.Request) { func (h *Handlers) CreateUser(w http.ResponseWriter, r *http.Request) {
htmx := isHTMX(r)
var req struct { var req struct {
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
IsAdmin bool `json:"is_admin"` IsAdmin bool `json:"is_admin"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
if htmx {
writeHTMXError(w, "invalid request")
return
}
writeError(w, http.StatusBadRequest, "invalid JSON") writeError(w, http.StatusBadRequest, "invalid JSON")
return return
} }
if req.Username == "" || req.Password == "" { if req.Username == "" || req.Password == "" {
if htmx {
writeHTMXError(w, "username and password required")
return
}
writeError(w, http.StatusBadRequest, "username and password required") writeError(w, http.StatusBadRequest, "username and password required")
return return
} }
if len(req.Password) < 8 { if len(req.Password) < 8 {
if htmx {
writeHTMXError(w, "password must be at least 8 characters")
return
}
writeError(w, http.StatusBadRequest, "password must be at least 8 characters") writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
return return
} }
user, err := h.store.CreateUser(req.Username, req.Password, req.IsAdmin) user, err := h.store.CreateUser(req.Username, req.Password, req.IsAdmin)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create user: "+err.Error()) msg := "failed to create user: " + err.Error()
if htmx {
writeHTMXError(w, msg)
return
}
writeError(w, http.StatusInternalServerError, msg)
return return
} }
h.audit(r, "user.create", "user", fmt.Sprintf("%d", user.ID), user.Username) h.audit(r, "user.create", "user", fmt.Sprintf("%d", user.ID), user.Username)
if htmx {
writeHTMXRefresh(w)
return
}
writeJSON(w, map[string]any{ writeJSON(w, map[string]any{
"id": user.ID, "id": user.ID,
"username": user.Username, "username": user.Username,
@ -470,9 +632,14 @@ func (h *Handlers) CreateUser(w http.ResponseWriter, r *http.Request) {
} }
func (h *Handlers) DeleteUser(w http.ResponseWriter, r *http.Request) { func (h *Handlers) DeleteUser(w http.ResponseWriter, r *http.Request) {
htmx := isHTMX(r)
idStr := chi.URLParam(r, "id") idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64) id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil { if err != nil {
if htmx {
writeHTMXError(w, "invalid user ID")
return
}
writeError(w, http.StatusBadRequest, "invalid user ID") writeError(w, http.StatusBadRequest, "invalid user ID")
return return
} }
@ -480,16 +647,28 @@ func (h *Handlers) DeleteUser(w http.ResponseWriter, r *http.Request) {
// Prevent deleting yourself // Prevent deleting yourself
user := UserFromContext(r.Context()) user := UserFromContext(r.Context())
if user != nil && user.ID == id { if user != nil && user.ID == id {
if htmx {
writeHTMXError(w, "cannot delete yourself")
return
}
writeError(w, http.StatusBadRequest, "cannot delete yourself") writeError(w, http.StatusBadRequest, "cannot delete yourself")
return return
} }
if err := h.store.DeleteUser(id); err != nil { if err := h.store.DeleteUser(id); err != nil {
if htmx {
writeHTMXError(w, err.Error())
return
}
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, err.Error())
return return
} }
h.audit(r, "user.delete", "user", idStr, "") h.audit(r, "user.delete", "user", idStr, "")
if htmx {
writeHTMXRefresh(w)
return
}
writeJSON(w, map[string]string{"status": "deleted"}) writeJSON(w, map[string]string{"status": "deleted"})
} }
@ -520,6 +699,7 @@ func (h *Handlers) ListTokens(w http.ResponseWriter, r *http.Request) {
} }
func (h *Handlers) CreateToken(w http.ResponseWriter, r *http.Request) { func (h *Handlers) CreateToken(w http.ResponseWriter, r *http.Request) {
htmx := isHTMX(r)
user := UserFromContext(r.Context()) user := UserFromContext(r.Context())
if user == nil { if user == nil {
writeError(w, http.StatusUnauthorized, "not authenticated") writeError(w, http.StatusUnauthorized, "not authenticated")
@ -532,10 +712,18 @@ func (h *Handlers) CreateToken(w http.ResponseWriter, r *http.Request) {
DailyBudgetUSD float64 `json:"daily_budget_usd"` DailyBudgetUSD float64 `json:"daily_budget_usd"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
if htmx {
writeHTMXError(w, "invalid request")
return
}
writeError(w, http.StatusBadRequest, "invalid JSON") writeError(w, http.StatusBadRequest, "invalid JSON")
return return
} }
if req.Name == "" { if req.Name == "" {
if htmx {
writeHTMXError(w, "name is required")
return
}
writeError(w, http.StatusBadRequest, "name is required") writeError(w, http.StatusBadRequest, "name is required")
return return
} }
@ -546,11 +734,25 @@ func (h *Handlers) CreateToken(w http.ResponseWriter, r *http.Request) {
plainKey, token, err := h.store.CreateAPIToken(user.ID, req.Name, req.RateLimitRPM, req.DailyBudgetUSD) plainKey, token, err := h.store.CreateAPIToken(user.ID, req.Name, req.RateLimitRPM, req.DailyBudgetUSD)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create token: "+err.Error()) msg := "failed to create token: " + err.Error()
if htmx {
writeHTMXError(w, msg)
return
}
writeError(w, http.StatusInternalServerError, msg)
return return
} }
h.audit(r, "token.create", "token", fmt.Sprintf("%d", token.ID), req.Name) h.audit(r, "token.create", "token", fmt.Sprintf("%d", token.ID), req.Name)
if htmx {
// Return HTML with the key display and trigger page refresh
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("HX-Trigger", "tokenCreated")
escaped := template.HTMLEscapeString(plainKey)
fmt.Fprintf(w, `<div class="success-msg" style="margin-bottom:12px;">Token created! Copy the key below it won't be shown again.</div>
<div class="token-key"><code id="new-token-key">%s</code><button class="copy-btn" onclick="navigator.clipboard.writeText(document.getElementById('new-token-key').textContent)">Copy</button></div>`, escaped)
return
}
writeJSON(w, map[string]any{ writeJSON(w, map[string]any{
"key": plainKey, "key": plainKey,
"token": token, "token": token,
@ -558,6 +760,7 @@ func (h *Handlers) CreateToken(w http.ResponseWriter, r *http.Request) {
} }
func (h *Handlers) DeleteToken(w http.ResponseWriter, r *http.Request) { func (h *Handlers) DeleteToken(w http.ResponseWriter, r *http.Request) {
htmx := isHTMX(r)
user := UserFromContext(r.Context()) user := UserFromContext(r.Context())
if user == nil { if user == nil {
writeError(w, http.StatusUnauthorized, "not authenticated") writeError(w, http.StatusUnauthorized, "not authenticated")
@ -567,6 +770,10 @@ func (h *Handlers) DeleteToken(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id") idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64) id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil { if err != nil {
if htmx {
writeHTMXError(w, "invalid token ID")
return
}
writeError(w, http.StatusBadRequest, "invalid token ID") writeError(w, http.StatusBadRequest, "invalid token ID")
return return
} }
@ -575,27 +782,44 @@ func (h *Handlers) DeleteToken(w http.ResponseWriter, r *http.Request) {
if !user.IsAdmin { if !user.IsAdmin {
token, err := h.store.GetAPIToken(id) token, err := h.store.GetAPIToken(id)
if err != nil { if err != nil {
if htmx {
writeHTMXError(w, "token not found")
return
}
writeError(w, http.StatusNotFound, "token not found") writeError(w, http.StatusNotFound, "token not found")
return return
} }
if token.UserID != user.ID { if token.UserID != user.ID {
if htmx {
writeHTMXError(w, "not your token")
return
}
writeError(w, http.StatusForbidden, "not your token") writeError(w, http.StatusForbidden, "not your token")
return return
} }
} }
if err := h.store.DeleteAPIToken(id); err != nil { if err := h.store.DeleteAPIToken(id); err != nil {
if htmx {
writeHTMXError(w, "failed to delete token")
return
}
writeError(w, http.StatusInternalServerError, "failed to delete token") writeError(w, http.StatusInternalServerError, "failed to delete token")
return return
} }
h.audit(r, "token.delete", "token", idStr, "") h.audit(r, "token.delete", "token", idStr, "")
if htmx {
writeHTMXRefresh(w)
return
}
writeJSON(w, map[string]string{"status": "deleted"}) writeJSON(w, map[string]string{"status": "deleted"})
} }
// Self-service endpoints // Self-service endpoints
func (h *Handlers) ChangePassword(w http.ResponseWriter, r *http.Request) { func (h *Handlers) ChangePassword(w http.ResponseWriter, r *http.Request) {
htmx := isHTMX(r)
user := UserFromContext(r.Context()) user := UserFromContext(r.Context())
if user == nil { if user == nil {
writeError(w, http.StatusUnauthorized, "not authenticated") writeError(w, http.StatusUnauthorized, "not authenticated")
@ -607,10 +831,18 @@ func (h *Handlers) ChangePassword(w http.ResponseWriter, r *http.Request) {
NewPassword string `json:"new_password"` NewPassword string `json:"new_password"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
if htmx {
writeHTMXError(w, "invalid request")
return
}
writeError(w, http.StatusBadRequest, "invalid JSON") writeError(w, http.StatusBadRequest, "invalid JSON")
return return
} }
if req.NewPassword == "" || len(req.NewPassword) < 8 { if req.NewPassword == "" || len(req.NewPassword) < 8 {
if htmx {
writeHTMXError(w, "new password must be at least 8 characters")
return
}
writeError(w, http.StatusBadRequest, "new password must be at least 8 characters") writeError(w, http.StatusBadRequest, "new password must be at least 8 characters")
return return
} }
@ -618,25 +850,42 @@ func (h *Handlers) ChangePassword(w http.ResponseWriter, r *http.Request) {
// Re-fetch user to get password hash // Re-fetch user to get password hash
user, err := h.store.GetUserByID(user.ID) user, err := h.store.GetUserByID(user.ID)
if err != nil { if err != nil {
if htmx {
writeHTMXError(w, "failed to fetch user")
return
}
writeError(w, http.StatusInternalServerError, "failed to fetch user") writeError(w, http.StatusInternalServerError, "failed to fetch user")
return return
} }
if !h.store.CheckPassword(user, req.CurrentPassword) { if !h.store.CheckPassword(user, req.CurrentPassword) {
if htmx {
writeHTMXError(w, "current password is incorrect")
return
}
writeError(w, http.StatusUnauthorized, "current password is incorrect") writeError(w, http.StatusUnauthorized, "current password is incorrect")
return return
} }
if err := h.store.UpdatePassword(user.ID, req.NewPassword); err != nil { if err := h.store.UpdatePassword(user.ID, req.NewPassword); err != nil {
if htmx {
writeHTMXError(w, "failed to update password")
return
}
writeError(w, http.StatusInternalServerError, "failed to update password") writeError(w, http.StatusInternalServerError, "failed to update password")
return return
} }
h.audit(r, "password.change", "user", fmt.Sprintf("%d", user.ID), "") h.audit(r, "password.change", "user", fmt.Sprintf("%d", user.ID), "")
if htmx {
writeHTMXSuccess(w, "Password updated")
return
}
writeJSON(w, map[string]string{"status": "password_updated"}) writeJSON(w, map[string]string{"status": "password_updated"})
} }
func (h *Handlers) ChangeUsername(w http.ResponseWriter, r *http.Request) { func (h *Handlers) ChangeUsername(w http.ResponseWriter, r *http.Request) {
htmx := isHTMX(r)
user := UserFromContext(r.Context()) user := UserFromContext(r.Context())
if user == nil { if user == nil {
writeError(w, http.StatusUnauthorized, "not authenticated") writeError(w, http.StatusUnauthorized, "not authenticated")
@ -647,10 +896,18 @@ func (h *Handlers) ChangeUsername(w http.ResponseWriter, r *http.Request) {
NewUsername string `json:"new_username"` NewUsername string `json:"new_username"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
if htmx {
writeHTMXError(w, "invalid request")
return
}
writeError(w, http.StatusBadRequest, "invalid JSON") writeError(w, http.StatusBadRequest, "invalid JSON")
return return
} }
if req.NewUsername == "" { if req.NewUsername == "" {
if htmx {
writeHTMXError(w, "username is required")
return
}
writeError(w, http.StatusBadRequest, "username is required") writeError(w, http.StatusBadRequest, "username is required")
return return
} }
@ -658,20 +915,33 @@ func (h *Handlers) ChangeUsername(w http.ResponseWriter, r *http.Request) {
// Check uniqueness // Check uniqueness
existing, err := h.store.GetUserByUsername(req.NewUsername) existing, err := h.store.GetUserByUsername(req.NewUsername)
if err == nil && existing.ID != user.ID { if err == nil && existing.ID != user.ID {
if htmx {
writeHTMXError(w, "username already taken")
return
}
writeError(w, http.StatusConflict, "username already taken") writeError(w, http.StatusConflict, "username already taken")
return return
} }
if err := h.store.UpdateUsername(user.ID, req.NewUsername); err != nil { if err := h.store.UpdateUsername(user.ID, req.NewUsername); err != nil {
if htmx {
writeHTMXError(w, "failed to update username")
return
}
writeError(w, http.StatusInternalServerError, "failed to update username") writeError(w, http.StatusInternalServerError, "failed to update username")
return return
} }
h.audit(r, "username.change", "user", fmt.Sprintf("%d", user.ID), req.NewUsername) h.audit(r, "username.change", "user", fmt.Sprintf("%d", user.ID), req.NewUsername)
if htmx {
writeHTMXSuccess(w, "Username updated")
return
}
writeJSON(w, map[string]string{"status": "username_updated"}) writeJSON(w, map[string]string{"status": "username_updated"})
} }
func (h *Handlers) ChangeEmail(w http.ResponseWriter, r *http.Request) { func (h *Handlers) ChangeEmail(w http.ResponseWriter, r *http.Request) {
htmx := isHTMX(r)
user := UserFromContext(r.Context()) user := UserFromContext(r.Context())
if user == nil { if user == nil {
writeError(w, http.StatusUnauthorized, "not authenticated") writeError(w, http.StatusUnauthorized, "not authenticated")
@ -682,15 +952,27 @@ func (h *Handlers) ChangeEmail(w http.ResponseWriter, r *http.Request) {
Email string `json:"email"` Email string `json:"email"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
if htmx {
writeHTMXError(w, "invalid request")
return
}
writeError(w, http.StatusBadRequest, "invalid JSON") writeError(w, http.StatusBadRequest, "invalid JSON")
return return
} }
if err := h.store.UpdateEmail(user.ID, req.Email); err != nil { if err := h.store.UpdateEmail(user.ID, req.Email); err != nil {
if htmx {
writeHTMXError(w, "failed to update email")
return
}
writeError(w, http.StatusInternalServerError, "failed to update email") writeError(w, http.StatusInternalServerError, "failed to update email")
return return
} }
if htmx {
writeHTMXSuccess(w, "Email updated")
return
}
writeJSON(w, map[string]string{"status": "email_updated"}) writeJSON(w, map[string]string{"status": "email_updated"})
} }
@ -749,6 +1031,30 @@ func (h *Handlers) verifyPendingToken(token string) (int64, error) {
return userID, nil return userID, nil
} }
func isHTMX(r *http.Request) bool {
return r.Header.Get("HX-Request") == "true"
}
func writeHTMXError(w http.ResponseWriter, msg string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div class="error-msg">%s</div>`, template.HTMLEscapeString(msg))
}
func writeHTMXSuccess(w http.ResponseWriter, msg string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div class="success-msg">%s</div>`, template.HTMLEscapeString(msg))
}
func writeHTMXRedirect(w http.ResponseWriter, url string) {
w.Header().Set("HX-Redirect", url)
w.WriteHeader(http.StatusOK)
}
func writeHTMXRefresh(w http.ResponseWriter) {
w.Header().Set("HX-Refresh", "true")
w.WriteHeader(http.StatusOK)
}
func writeJSON(w http.ResponseWriter, v any) { func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v) json.NewEncoder(w).Encode(v)

View file

@ -721,19 +721,47 @@ func (s *StatsAPI) DebugLogByRequestID(w http.ResponseWriter, r *http.Request) {
} }
// ValidateConfig validates the config file at the stored path. // ValidateConfig validates the config file at the stored path.
// Returns HTML for HTMX requests, JSON otherwise.
func (s *StatsAPI) ValidateConfig(w http.ResponseWriter, r *http.Request) { func (s *StatsAPI) ValidateConfig(w http.ResponseWriter, r *http.Request) {
if s.configPath == "" { if s.configPath == "" {
w.WriteHeader(http.StatusInternalServerError) if r.Header.Get("HX-Request") == "true" {
writeJSON(w, map[string]any{"valid": false, "errors": []string{"config path not set"}}) w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(`<div class="error-msg">Config path not set</div>`))
} else {
w.WriteHeader(http.StatusInternalServerError)
writeJSON(w, map[string]any{"valid": false, "errors": []string{"config path not set"}})
}
return return
} }
data, err := os.ReadFile(s.configPath) data, err := os.ReadFile(s.configPath)
if err != nil { if err != nil {
w.WriteHeader(http.StatusInternalServerError) msg := "failed to read config: " + err.Error()
writeJSON(w, map[string]any{"valid": false, "errors": []string{"failed to read config: " + err.Error()}}) if r.Header.Get("HX-Request") == "true" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(`<div class="error-msg">` + msg + `</div>`))
} else {
w.WriteHeader(http.StatusInternalServerError)
writeJSON(w, map[string]any{"valid": false, "errors": []string{msg}})
}
return return
} }
errs := config.ValidateBytes(data) errs := config.ValidateBytes(data)
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if len(errs) > 0 {
html := `<div class="error-msg">Configuration errors:<ul style="margin:4px 0 0 16px;">`
for _, e := range errs {
html += "<li>" + e + "</li>"
}
html += "</ul></div>"
w.Write([]byte(html))
} else {
w.Write([]byte(`<div class="success-msg">Configuration is valid.</div>`))
}
return
}
if len(errs) > 0 { if len(errs) > 0 {
writeJSON(w, map[string]any{"valid": false, "errors": errs}) writeJSON(w, map[string]any{"valid": false, "errors": errs})
return return

View file

@ -16,7 +16,6 @@
</script> </script>
<script src="https://unpkg.com/htmx.org@2.0.4"></script> <script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script src="https://unpkg.com/htmx-ext-json-enc@2.0.3/json-enc.js"></script> <script src="https://unpkg.com/htmx-ext-json-enc@2.0.3/json-enc.js"></script>
<script src="https://unpkg.com/htmx-ext-sse@2.2.2/sse.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<style> <style>
:root { :root {
@ -294,15 +293,50 @@ window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', fu
<div class="sidebar-footer"> <div class="sidebar-footer">
<button class="theme-toggle" onclick="toggleTheme()" id="theme-btn">Switch Theme</button> <button class="theme-toggle" onclick="toggleTheme()" id="theme-btn">Switch Theme</button>
<div class="user-info">{{.User.Username}}</div> <div class="user-info">{{.User.Username}}</div>
<a href="#" hx-post="/api/auth/logout" hx-swap="none" onclick="setTimeout(()=>window.location='/login',100)">Logout</a> <a href="#" hx-post="/api/auth/logout" hx-swap="none">Logout</a>
</div> </div>
</div> </div>
<div class="main"> <div class="main">
<div id="sse-source" style="display:none;"></div>
<div id="content"> <div id="content">
{{template "content" .}} {{template "content" .}}
</div> </div>
</div> </div>
<script> <script>
// SSE auto-refresh: reload current page content when server sends refresh event
(function() {
var refreshable = ['/dashboard', '/logs', '/models', '/debug', '/audit'];
var source = null;
var retryDelay = 1000;
function connect() {
if (source) { source.close(); source = null; }
source = new EventSource('/api/events');
source.addEventListener('refresh', function() {
var path = window.location.pathname;
for (var i = 0; i < refreshable.length; i++) {
if (path === refreshable[i]) {
htmx.ajax('GET', path + window.location.search, {target: '#content', swap: 'innerHTML'});
break;
}
}
});
source.onopen = function() { retryDelay = 1000; };
source.onerror = function() {
source.close();
source = null;
// Exponential backoff, max 30s
setTimeout(connect, retryDelay);
retryDelay = Math.min(retryDelay * 2, 30000);
};
}
connect();
// Reconnect on HTMX page navigation (in case connection was lost)
document.body.addEventListener('htmx:pushedIntoHistory', function() {
if (!source || source.readyState === EventSource.CLOSED) connect();
});
})();
// Update active sidebar link on HTMX navigation // Update active sidebar link on HTMX navigation
document.body.addEventListener('htmx:pushedIntoHistory', function(e) { document.body.addEventListener('htmx:pushedIntoHistory', function(e) {
document.querySelectorAll('.sidebar-nav a').forEach(function(a) { document.querySelectorAll('.sidebar-nav a').forEach(function(a) {

View file

@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - LLM Gateway</title> <title>Login - LLM Gateway</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script> <script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script src="https://unpkg.com/htmx-ext-json-enc@2.0.1/json-enc.js"></script>
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
@ -18,72 +19,26 @@
.btn-primary { display: block; width: 100%; padding: 10px 20px; border-radius: 6px; border: none; cursor: pointer; font-size: 0.9rem; font-weight: 500; background: #3b82f6; color: #fff; } .btn-primary { display: block; width: 100%; padding: 10px 20px; border-radius: 6px; border: none; cursor: pointer; font-size: 0.9rem; font-weight: 500; background: #3b82f6; color: #fff; }
.btn-primary:hover { background: #2563eb; } .btn-primary:hover { background: #2563eb; }
.error-msg { background: #7f1d1d40; border: 1px solid #991b1b; color: #fca5a5; padding: 10px; border-radius: 6px; margin-bottom: 16px; font-size: 0.85rem; } .error-msg { background: #7f1d1d40; border: 1px solid #991b1b; color: #fca5a5; padding: 10px; border-radius: 6px; margin-bottom: 16px; font-size: 0.85rem; }
.hidden { display: none !important; }
</style> </style>
</head> </head>
<body> <body>
<div class="auth-box"> <div class="auth-box">
<h1>LLM Gateway</h1> <h1>LLM Gateway</h1>
<div id="login-error"></div> <div id="login-form-area">
<form id="login-form" onsubmit="doLogin(event)"> <form hx-post="/api/auth/login" hx-target="#login-form-area" hx-swap="innerHTML" hx-ext="json-enc">
<div class="form-group"> <div id="login-error"></div>
<label>Username</label> <div class="form-group">
<input type="text" id="login-username" required autocomplete="username"> <label>Username</label>
</div> <input type="text" name="username" required autocomplete="username">
<div class="form-group"> </div>
<label>Password</label> <div class="form-group">
<input type="password" id="login-password" required autocomplete="current-password"> <label>Password</label>
</div> <input type="password" name="password" required autocomplete="current-password">
<button type="submit" class="btn-primary">Sign In</button> </div>
</form> <button type="submit" class="btn-primary">Sign In</button>
<form id="totp-form" class="hidden" onsubmit="doLoginTOTP(event)"> </form>
<div class="form-group"> </div>
<label>Enter your 6-digit authenticator code</label>
<input type="text" id="login-totp-code" required pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code" inputmode="numeric" style="text-align:center; font-size:1.5rem; letter-spacing:0.3em;">
</div>
<button type="submit" class="btn-primary">Verify</button>
</form>
</div> </div>
<script>
function showError(msg) {
document.getElementById('login-error').innerHTML = '<div class="error-msg">' + msg + '</div>';
}
async function doLogin(e) {
e.preventDefault();
try {
const resp = await fetch('/api/auth/login', {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
username: document.getElementById('login-username').value,
password: document.getElementById('login-password').value
})
});
const data = await resp.json();
if (!resp.ok) { showError(data.error || 'Login failed'); return; }
if (data.require_totp) {
document.getElementById('login-form').classList.add('hidden');
document.getElementById('totp-form').classList.remove('hidden');
document.getElementById('login-totp-code').focus();
return;
}
window.location.href = '/dashboard';
} catch (e) { showError(e.message); }
}
async function doLoginTOTP(e) {
e.preventDefault();
try {
const resp = await fetch('/api/auth/login/totp', {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ code: document.getElementById('login-totp-code').value })
});
const data = await resp.json();
if (!resp.ok) { showError(data.error || 'Invalid code'); return; }
window.location.href = '/dashboard';
} catch (e) { showError(e.message); }
}
</script>
</body> </body>
</html> </html>
{{end}} {{end}}

View file

@ -1,5 +1,4 @@
{{define "content"}} {{define "content"}}
<div hx-ext="sse" sse-connect="/api/events" hx-get="/dashboard" hx-trigger="sse:refresh" hx-target="#content" hx-swap="innerHTML">
<div class="page-header"> <div class="page-header">
<h1>Dashboard</h1> <h1>Dashboard</h1>
</div> </div>
@ -228,5 +227,4 @@ function loadCostBreakdown(groupBy, btn) {
} }
loadCostBreakdown('model'); loadCostBreakdown('model');
</script> </script>
</div>
{{end}} {{end}}

View file

@ -7,7 +7,11 @@
<div class="section" style="display:flex;align-items:center;gap:16px;padding:12px 16px;"> <div class="section" style="display:flex;align-items:center;gap:16px;padding:12px 16px;">
<span style="font-size:0.9rem;font-weight:600;">Debug Mode</span> <span style="font-size:0.9rem;font-weight:600;">Debug Mode</span>
<label class="toggle-switch"> <label class="toggle-switch">
<input type="checkbox" id="debug-toggle" {{if .DebugEnabled}}checked{{end}} onchange="toggleDebug(this.checked)"> <input type="checkbox" id="debug-toggle" {{if .DebugEnabled}}checked{{end}}
hx-post="/api/debug/toggle" hx-swap="none" hx-ext="json-enc"
hx-vals='js:{enabled: document.getElementById("debug-toggle").checked}'
hx-trigger="change"
hx-on::after-request="htmx.ajax('GET', '/debug', {target: '#content', swap: 'innerHTML'})">
<span class="toggle-slider"></span> <span class="toggle-slider"></span>
</label> </label>
<span id="debug-status" style="font-size:0.8rem;color:var(--text-muted)">{{if .DebugEnabled}}Enabled — requests are being logged{{else}}Disabled{{end}}</span> <span id="debug-status" style="font-size:0.8rem;color:var(--text-muted)">{{if .DebugEnabled}}Enabled — requests are being logged{{else}}Disabled{{end}}</span>
@ -77,16 +81,6 @@
</div> </div>
<script> <script>
function toggleDebug(enabled) {
fetch('/api/debug/toggle', {
method: 'POST',
credentials: 'same-origin',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({enabled: enabled})
}).then(function() {
htmx.ajax('GET', '/debug', {target: '#content', swap: 'innerHTML'});
});
}
function toggleDebugExpand(id) { function toggleDebugExpand(id) {
var el = document.getElementById(id); var el = document.getElementById(id);
if (el) el.classList.toggle('show'); if (el) el.classList.toggle('show');

View file

@ -6,20 +6,22 @@
<div class="section"> <div class="section">
<h2>Profile</h2> <h2>Profile</h2>
<div id="profile-msg"></div> <div id="profile-msg"></div>
<form onsubmit="changeUsername(event)" style="max-width:400px;margin-bottom:16px;"> <form hx-put="/api/auth/me/username" hx-target="#profile-msg" hx-swap="innerHTML" hx-ext="json-enc"
style="max-width:400px;margin-bottom:16px;">
<div class="form-group"> <div class="form-group">
<label>Username</label> <label>Username</label>
<div style="display:flex;gap:8px;"> <div style="display:flex;gap:8px;">
<input type="text" id="settings-username" value="{{.User.Username}}" required> <input type="text" name="new_username" value="{{.User.Username}}" required>
<button type="submit" class="btn btn-sm btn-primary" style="white-space:nowrap;">Update</button> <button type="submit" class="btn btn-sm btn-primary" style="white-space:nowrap;">Update</button>
</div> </div>
</div> </div>
</form> </form>
<form onsubmit="changeEmail(event)" style="max-width:400px;"> <form hx-put="/api/auth/me/email" hx-target="#profile-msg" hx-swap="innerHTML" hx-ext="json-enc"
style="max-width:400px;">
<div class="form-group"> <div class="form-group">
<label>Email</label> <label>Email</label>
<div style="display:flex;gap:8px;"> <div style="display:flex;gap:8px;">
<input type="email" id="settings-email" value="{{.User.Email}}" placeholder="optional"> <input type="email" name="email" value="{{.User.Email}}" placeholder="optional">
<button type="submit" class="btn btn-sm btn-primary" style="white-space:nowrap;">Update</button> <button type="submit" class="btn btn-sm btn-primary" style="white-space:nowrap;">Update</button>
</div> </div>
</div> </div>
@ -29,14 +31,15 @@
<div class="section"> <div class="section">
<h2>Change Password</h2> <h2>Change Password</h2>
<div id="password-msg"></div> <div id="password-msg"></div>
<form onsubmit="changePassword(event)" style="max-width:400px;"> <form id="password-form" hx-put="/api/auth/me/password" hx-target="#password-msg" hx-swap="innerHTML" hx-ext="json-enc"
style="max-width:400px;">
<div class="form-group"> <div class="form-group">
<label>Current Password</label> <label>Current Password</label>
<input type="password" id="current-password" required autocomplete="current-password"> <input type="password" name="current_password" required autocomplete="current-password">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>New Password (min 8 characters)</label> <label>New Password (min 8 characters)</label>
<input type="password" id="new-password" required minlength="8" autocomplete="new-password"> <input type="password" name="new_password" required minlength="8" autocomplete="new-password">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Confirm New Password</label> <label>Confirm New Password</label>
@ -51,7 +54,9 @@
<div id="totp-status"> <div id="totp-status">
{{if .User.TOTPEnabled}} {{if .User.TOTPEnabled}}
<p style="color:#4ade80;margin-bottom:12px;">Two-factor authentication is <strong>enabled</strong>.</p> <p style="color:#4ade80;margin-bottom:12px;">Two-factor authentication is <strong>enabled</strong>.</p>
<button class="btn btn-sm btn-danger" onclick="disableTOTP()">Disable 2FA</button> <button class="btn btn-sm btn-danger"
hx-delete="/api/auth/totp" hx-target="#totp-status" hx-swap="innerHTML"
hx-confirm="Disable two-factor authentication?">Disable 2FA</button>
{{else}} {{else}}
<p style="color:#94a3b8;margin-bottom:12px;">Two-factor authentication is <strong>not enabled</strong>.</p> <p style="color:#94a3b8;margin-bottom:12px;">Two-factor authentication is <strong>not enabled</strong>.</p>
<button class="btn btn-sm btn-primary" onclick="setupTOTP()">Enable 2FA</button> <button class="btn btn-sm btn-primary" onclick="setupTOTP()">Enable 2FA</button>
@ -61,9 +66,10 @@
<p style="color:#94a3b8;font-size:0.85rem;margin-bottom:12px;">Scan this QR code with your authenticator app, then enter the code below to verify.</p> <p style="color:#94a3b8;font-size:0.85rem;margin-bottom:12px;">Scan this QR code with your authenticator app, then enter the code below to verify.</p>
<div id="totp-qr" style="text-align:center;margin:16px 0;"></div> <div id="totp-qr" style="text-align:center;margin:16px 0;"></div>
<div id="totp-secret-display" style="text-align:center;margin:8px 0;font-family:monospace;color:#94a3b8;font-size:0.8rem;"></div> <div id="totp-secret-display" style="text-align:center;margin:8px 0;font-family:monospace;color:#94a3b8;font-size:0.8rem;"></div>
<form onsubmit="verifyTOTP(event)" style="max-width:300px;margin:0 auto;"> <form hx-post="/api/auth/totp/verify" hx-target="#totp-status" hx-swap="innerHTML" hx-ext="json-enc"
style="max-width:300px;margin:0 auto;">
<div class="form-group"> <div class="form-group">
<input type="text" id="totp-verify-code" required pattern="[0-9]{6}" maxlength="6" placeholder="Enter 6-digit code" autocomplete="one-time-code" inputmode="numeric" style="text-align:center;font-size:1.2rem;letter-spacing:0.2em;"> <input type="text" name="code" required pattern="[0-9]{6}" maxlength="6" placeholder="Enter 6-digit code" autocomplete="one-time-code" inputmode="numeric" style="text-align:center;font-size:1.2rem;letter-spacing:0.2em;">
</div> </div>
<button type="submit" class="btn btn-primary">Verify & Enable</button> <button type="submit" class="btn btn-primary">Verify & Enable</button>
</form> </form>
@ -74,71 +80,49 @@
<div class="section"> <div class="section">
<h2>Config Validation</h2> <h2>Config Validation</h2>
<p style="color:#94a3b8;font-size:0.85rem;margin-bottom:12px;">Validate the current gateway configuration file for errors.</p> <p style="color:#94a3b8;font-size:0.85rem;margin-bottom:12px;">Validate the current gateway configuration file for errors.</p>
<button class="btn btn-sm btn-primary" onclick="validateConfig()">Validate Config</button> <button class="btn btn-sm btn-primary"
hx-get="/api/config/validate"
hx-target="#config-validation-result"
hx-swap="innerHTML">Validate Config</button>
<div id="config-validation-result" style="margin-top:12px;"></div> <div id="config-validation-result" style="margin-top:12px;"></div>
</div> </div>
{{end}} {{end}}
<script src="https://cdn.jsdelivr.net/npm/qrious@4.0.2/dist/qrious.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/qrious@4.0.2/dist/qrious.min.js"></script>
<script> <script>
function showMsg(id, msg, isError) { // Password confirm validation
document.getElementById(id).innerHTML = '<div class="' + (isError ? 'error-msg' : 'success-msg') + '">' + msg + '</div>'; document.body.addEventListener('htmx:confirm', function(e) {
setTimeout(function() { document.getElementById(id).innerHTML = ''; }, 5000); var form = e.target;
} if (form.id !== 'password-form') return;
var np = form.querySelector('[name=new_password]').value;
async function changeUsername(e) {
e.preventDefault();
try {
var resp = await fetch('/api/auth/me/username', {
method: 'PUT', credentials: 'same-origin',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ new_username: document.getElementById('settings-username').value })
});
var data = await resp.json();
if (!resp.ok) { showMsg('profile-msg', data.error||'Failed', true); return; }
showMsg('profile-msg', 'Username updated', false);
} catch (e) { showMsg('profile-msg', e.message, true); }
}
async function changeEmail(e) {
e.preventDefault();
try {
var resp = await fetch('/api/auth/me/email', {
method: 'PUT', credentials: 'same-origin',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ email: document.getElementById('settings-email').value })
});
var data = await resp.json();
if (!resp.ok) { showMsg('profile-msg', data.error||'Failed', true); return; }
showMsg('profile-msg', 'Email updated', false);
} catch (e) { showMsg('profile-msg', e.message, true); }
}
async function changePassword(e) {
e.preventDefault();
var np = document.getElementById('new-password').value;
if (np !== document.getElementById('new-password2').value) { if (np !== document.getElementById('new-password2').value) {
showMsg('password-msg', 'Passwords do not match', true); e.preventDefault();
return; document.getElementById('password-msg').innerHTML = '<div class="error-msg">Passwords do not match</div>';
} }
try { });
var resp = await fetch('/api/auth/me/password', {
method: 'PUT', credentials: 'same-origin',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
current_password: document.getElementById('current-password').value,
new_password: np
})
});
var data = await resp.json();
if (!resp.ok) { showMsg('password-msg', data.error||'Failed', true); return; }
showMsg('password-msg', 'Password updated', false);
document.getElementById('current-password').value = '';
document.getElementById('new-password').value = '';
document.getElementById('new-password2').value = '';
} catch (e) { showMsg('password-msg', e.message, true); }
}
// Clear password fields after successful change
document.body.addEventListener('htmx:afterRequest', function(e) {
if (e.target.id === 'password-form' && e.detail.successful) {
e.target.querySelectorAll('input[type=password]').forEach(function(i) { i.value = ''; });
document.getElementById('new-password2').value = '';
}
});
// Auto-clear messages after 5 seconds
document.body.addEventListener('htmx:afterSwap', function(e) {
var target = e.detail.target;
if (target.id === 'profile-msg' || target.id === 'password-msg') {
setTimeout(function() { target.innerHTML = ''; }, 5000);
}
});
// Reload settings page when TOTP status changes
document.body.addEventListener('settingsRefresh', function() {
htmx.ajax('GET', '/settings', {target: '#content', swap: 'innerHTML'});
});
// TOTP setup - needs JS for QR code rendering
async function setupTOTP() { async function setupTOTP() {
try { try {
var resp = await fetch('/api/auth/totp/setup', { method: 'POST', credentials: 'same-origin' }); var resp = await fetch('/api/auth/totp/setup', { method: 'POST', credentials: 'same-origin' });
@ -153,43 +137,5 @@ async function setupTOTP() {
qrDiv.appendChild(canvas); qrDiv.appendChild(canvas);
} catch (e) { alert(e.message); } } catch (e) { alert(e.message); }
} }
async function verifyTOTP(e) {
e.preventDefault();
try {
var resp = await fetch('/api/auth/totp/verify', {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ code: document.getElementById('totp-verify-code').value })
});
var data = await resp.json();
if (!resp.ok) { alert(data.error||'Invalid code'); return; }
htmx.ajax('GET', '/settings', {target: '#content', swap: 'innerHTML'});
} catch (e) { alert(e.message); }
}
async function disableTOTP() {
if (!confirm('Disable two-factor authentication?')) return;
try {
var resp = await fetch('/api/auth/totp', { method: 'DELETE', credentials: 'same-origin' });
if (!resp.ok) { var d = await resp.json(); alert(d.error||'Failed'); return; }
htmx.ajax('GET', '/settings', {target: '#content', swap: 'innerHTML'});
} catch (e) { alert(e.message); }
}
async function validateConfig() {
var el = document.getElementById('config-validation-result');
el.innerHTML = '<span style="color:#94a3b8;">Validating...</span>';
try {
var resp = await fetch('/api/config/validate', { credentials: 'same-origin' });
var data = await resp.json();
if (data.valid) {
el.innerHTML = '<div class="success-msg">Configuration is valid.</div>';
} else {
var errs = (data.errors||[]).map(function(e) { return '<li>' + e + '</li>'; }).join('');
el.innerHTML = '<div class="error-msg">Configuration errors:<ul style="margin:4px 0 0 16px;">' + errs + '</ul></div>';
}
} catch (e) { el.innerHTML = '<div class="error-msg">' + e.message + '</div>'; }
}
</script> </script>
{{end}} {{end}}

View file

@ -4,10 +4,7 @@
<button class="btn btn-sm btn-primary" onclick="showCreateTokenModal()">Create Token</button> <button class="btn btn-sm btn-primary" onclick="showCreateTokenModal()">Create Token</button>
</div> </div>
<div id="new-token-display" style="display:none; margin-bottom:16px;"> <div id="new-token-display" style="display:none; margin-bottom:16px;"></div>
<div class="success-msg">Token created! Copy the key below - it won't be shown again.</div>
<div class="token-key"><code id="new-token-key"></code><button class="copy-btn" onclick="navigator.clipboard.writeText(document.getElementById('new-token-key').textContent)">Copy</button></div>
</div>
<div class="section"> <div class="section">
<h2>Static Tokens <span style="font-size:0.75rem;color:var(--text-muted);font-weight:400;">(from config, managed via environment variables)</span></h2> <h2>Static Tokens <span style="font-size:0.75rem;color:var(--text-muted);font-weight:400;">(from config, managed via environment variables)</span></h2>
@ -70,7 +67,9 @@
</td> </td>
<td>{{formatTime .CreatedAt}}</td> <td>{{formatTime .CreatedAt}}</td>
<td>{{if gt .LastUsedAt 0}}{{formatTime .LastUsedAt}}{{else}}never{{end}}</td> <td>{{if gt .LastUsedAt 0}}{{formatTime .LastUsedAt}}{{else}}never{{end}}</td>
<td><button class="btn btn-sm btn-danger" onclick="deleteToken({{.ID}})">Revoke</button></td> <td><button class="btn btn-sm btn-danger"
hx-delete="/api/tokens/{{.ID}}" hx-swap="none"
hx-confirm="Revoke this API token? This cannot be undone.">Revoke</button></td>
</tr> </tr>
{{end}}{{end}} {{end}}{{end}}
</tbody> </tbody>
@ -82,7 +81,8 @@
<div class="modal"> <div class="modal">
<h2>Create API Token</h2> <h2>Create API Token</h2>
<div id="create-token-error"></div> <div id="create-token-error"></div>
<form onsubmit="doCreateToken(event)"> <form hx-post="/api/tokens" hx-target="#new-token-display" hx-swap="innerHTML" hx-ext="json-enc"
hx-vals='js:{name: document.getElementById("token-name").value, rate_limit_rpm: parseInt(document.getElementById("token-rpm").value) || 0, daily_budget_usd: parseFloat(document.getElementById("token-budget").value) || 0}'>
<div class="form-group"> <div class="form-group">
<label>Token Name</label> <label>Token Name</label>
<input type="text" id="token-name" required placeholder="e.g. my-app"> <input type="text" id="token-name" required placeholder="e.g. my-app">
@ -118,33 +118,25 @@ function closeModal() {
document.getElementById('modal-create-token').addEventListener('click', function(e) { document.getElementById('modal-create-token').addEventListener('click', function(e) {
if (e.target === this) closeModal(); if (e.target === this) closeModal();
}); });
async function doCreateToken(e) {
e.preventDefault(); // After token creation: show key, close modal, refresh token list
try { document.body.addEventListener('tokenCreated', function() {
var resp = await fetch('/api/tokens', { closeModal();
method: 'POST', credentials: 'same-origin', document.getElementById('new-token-display').style.display = 'block';
headers: {'Content-Type': 'application/json'}, // Refresh the token list after a short delay
body: JSON.stringify({ setTimeout(function() {
name: document.getElementById('token-name').value,
rate_limit_rpm: parseInt(document.getElementById('token-rpm').value),
daily_budget_usd: parseFloat(document.getElementById('token-budget').value)
})
});
var data = await resp.json();
if (!resp.ok) { document.getElementById('create-token-error').innerHTML = '<div class="error-msg">' + (data.error||'Failed') + '</div>'; return; }
closeModal();
document.getElementById('new-token-key').textContent = data.key;
document.getElementById('new-token-display').style.display = 'block';
htmx.ajax('GET', '/tokens', {target: '#content', swap: 'innerHTML'}); htmx.ajax('GET', '/tokens', {target: '#content', swap: 'innerHTML'});
} catch (e) { document.getElementById('create-token-error').innerHTML = '<div class="error-msg">' + e.message + '</div>'; } }, 100);
} });
async function deleteToken(id) {
if (!confirm('Revoke this API token? This cannot be undone.')) return; // Handle token create errors (non-200 responses swap into error div)
try { document.body.addEventListener('htmx:beforeSwap', function(e) {
var resp = await fetch('/api/tokens/' + id, { method: 'DELETE', credentials: 'same-origin' }); if (e.detail.target.id === 'new-token-display' && !e.detail.isError && !e.detail.xhr.getResponseHeader('HX-Trigger')) {
if (!resp.ok) { var d = await resp.json(); alert(d.error||'Failed'); return; } // Error response - redirect to error div
htmx.ajax('GET', '/tokens', {target: '#content', swap: 'innerHTML'}); if (e.detail.xhr.status >= 400) {
} catch (e) { alert(e.message); } e.detail.target = document.getElementById('create-token-error');
} }
}
});
</script> </script>
{{end}} {{end}}

View file

@ -15,7 +15,9 @@
<td><span class="badge {{if .IsAdmin}}badge-admin{{else}}badge-user{{end}}">{{if .IsAdmin}}Admin{{else}}User{{end}}</span></td> <td><span class="badge {{if .IsAdmin}}badge-admin{{else}}badge-user{{end}}">{{if .IsAdmin}}Admin{{else}}User{{end}}</span></td>
<td>{{if .TOTPEnabled}}<span class="badge badge-totp">Enabled</span>{{else}}Off{{end}}</td> <td>{{if .TOTPEnabled}}<span class="badge badge-totp">Enabled</span>{{else}}Off{{end}}</td>
<td>{{formatTime .CreatedAt}}</td> <td>{{formatTime .CreatedAt}}</td>
<td>{{if ne .ID $.User.ID}}<button class="btn btn-sm btn-danger" onclick="deleteUser({{.ID}})">Delete</button>{{end}}</td> <td>{{if ne .ID $.User.ID}}<button class="btn btn-sm btn-danger"
hx-delete="/api/auth/users/{{.ID}}" hx-swap="none"
hx-confirm="Delete this user? All their sessions and tokens will be removed.">Delete</button>{{end}}</td>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>
@ -27,7 +29,8 @@
<div class="modal"> <div class="modal">
<h2>Create User</h2> <h2>Create User</h2>
<div id="create-user-error"></div> <div id="create-user-error"></div>
<form onsubmit="doCreateUser(event)"> <form hx-post="/api/auth/users" hx-target="#create-user-error" hx-swap="innerHTML" hx-ext="json-enc"
hx-vals='js:{username: document.getElementById("new-user-username").value, password: document.getElementById("new-user-password").value, is_admin: document.getElementById("new-user-admin").checked}'>
<div class="form-group"> <div class="form-group">
<label>Username</label> <label>Username</label>
<input type="text" id="new-user-username" required> <input type="text" id="new-user-username" required>
@ -63,31 +66,5 @@ function closeUserModal() {
document.getElementById('modal-create-user').addEventListener('click', function(e) { document.getElementById('modal-create-user').addEventListener('click', function(e) {
if (e.target === this) closeUserModal(); if (e.target === this) closeUserModal();
}); });
async function doCreateUser(e) {
e.preventDefault();
try {
var resp = await fetch('/api/auth/users', {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
username: document.getElementById('new-user-username').value,
password: document.getElementById('new-user-password').value,
is_admin: document.getElementById('new-user-admin').checked
})
});
var data = await resp.json();
if (!resp.ok) { document.getElementById('create-user-error').innerHTML = '<div class="error-msg">' + (data.error||'Failed') + '</div>'; return; }
closeUserModal();
htmx.ajax('GET', '/users', {target: '#content', swap: 'innerHTML'});
} catch (e) { document.getElementById('create-user-error').innerHTML = '<div class="error-msg">' + e.message + '</div>'; }
}
async function deleteUser(id) {
if (!confirm('Delete this user? All their sessions and tokens will be removed.')) return;
try {
var resp = await fetch('/api/auth/users/' + id, { method: 'DELETE', credentials: 'same-origin' });
if (!resp.ok) { var d = await resp.json(); alert(d.error||'Failed'); return; }
htmx.ajax('GET', '/users', {target: '#content', swap: 'innerHTML'});
} catch (e) { alert(e.message); }
}
</script> </script>
{{end}} {{end}}

View file

@ -5,6 +5,8 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Setup - LLM Gateway</title> <title>Setup - LLM Gateway</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script src="https://unpkg.com/htmx-ext-json-enc@2.0.1/json-enc.js"></script>
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
@ -25,14 +27,14 @@
<h1>LLM Gateway Setup</h1> <h1>LLM Gateway Setup</h1>
<p class="subtitle">Create the first admin account</p> <p class="subtitle">Create the first admin account</p>
<div id="setup-error"></div> <div id="setup-error"></div>
<form onsubmit="doSetup(event)"> <form hx-post="/api/auth/setup" hx-target="#setup-error" hx-swap="innerHTML" hx-ext="json-enc">
<div class="form-group"> <div class="form-group">
<label>Username</label> <label>Username</label>
<input type="text" id="setup-username" required autocomplete="username"> <input type="text" name="username" required autocomplete="username">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Password (min 8 characters)</label> <label>Password (min 8 characters)</label>
<input type="password" id="setup-password" required minlength="8" autocomplete="new-password"> <input type="password" name="password" required minlength="8" autocomplete="new-password">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Confirm Password</label> <label>Confirm Password</label>
@ -42,30 +44,15 @@
</form> </form>
</div> </div>
<script> <script>
function showError(msg) { document.body.addEventListener('htmx:confirm', function(e) {
document.getElementById('setup-error').innerHTML = '<div class="error-msg">' + msg + '</div>'; var form = e.target;
} if (!form.querySelector || !form.querySelector('#setup-password2')) return;
async function doSetup(e) { var pw = form.querySelector('[name=password]').value;
e.preventDefault();
const pw = document.getElementById('setup-password').value;
if (pw !== document.getElementById('setup-password2').value) { if (pw !== document.getElementById('setup-password2').value) {
showError('Passwords do not match'); e.preventDefault();
return; document.getElementById('setup-error').innerHTML = '<div class="error-msg">Passwords do not match</div>';
} }
try { });
const resp = await fetch('/api/auth/setup', {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
username: document.getElementById('setup-username').value,
password: pw
})
});
const data = await resp.json();
if (!resp.ok) { showError(data.error || 'Setup failed'); return; }
window.location.href = '/dashboard';
} catch (e) { showError(e.message); }
}
</script> </script>
</body> </body>
</html> </html>

View file

@ -11,6 +11,7 @@ import (
"time" "time"
"llm-gateway/internal/provider" "llm-gateway/internal/provider"
"llm-gateway/internal/storage"
) )
func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, req *provider.ChatRequest, routes []provider.Route, tokenName, requestID string, modelTimeouts *provider.ModelTimeouts) { func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, req *provider.ChatRequest, routes []provider.Route, tokenName, requestID string, modelTimeouts *provider.ModelTimeouts) {
@ -85,6 +86,10 @@ func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, req *prov
scanner := bufio.NewScanner(body) scanner := bufio.NewScanner(body)
scanner.Buffer(make([]byte, 64*1024), 256*1024) scanner.Buffer(make([]byte, 64*1024), 256*1024)
// Capture streamed lines for debug logging
debugEnabled := h.debugLogger != nil && h.debugLogger.IsEnabled()
var debugLines []string
scanDone := make(chan struct{}) scanDone := make(chan struct{})
go func() { go func() {
defer close(scanDone) defer close(scanDone)
@ -116,6 +121,10 @@ func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, req *prov
} }
} }
if debugEnabled {
debugLines = append(debugLines, line)
}
w.Write([]byte(line + "\n")) w.Write([]byte(line + "\n"))
flusher.Flush() flusher.Flush()
} }
@ -138,6 +147,32 @@ func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, req *prov
if h.healthTracker != nil { if h.healthTracker != nil {
h.healthTracker.Record(route.Provider.Name(), latency, nil) h.healthTracker.Record(route.Provider.Name(), latency, nil)
} }
// Debug logging for streaming requests
if debugEnabled && len(debugLines) > 0 {
respBody := strings.Join(debugLines, "\n")
reqBody, _ := json.Marshal(req)
reqBodyStr := string(reqBody)
if h.cfg.Debug.MaxBodyBytes > 0 {
if len(reqBodyStr) > h.cfg.Debug.MaxBodyBytes {
reqBodyStr = reqBodyStr[:h.cfg.Debug.MaxBodyBytes]
}
if len(respBody) > h.cfg.Debug.MaxBodyBytes {
respBody = respBody[:h.cfg.Debug.MaxBodyBytes]
}
}
h.debugLogger.Log(storage.DebugLogEntry{
RequestID: requestID,
TokenName: tokenName,
Model: req.Model,
Provider: route.Provider.Name(),
RequestBody: reqBodyStr,
ResponseBody: respBody,
RequestHeaders: formatHeaders(r.Header),
ResponseStatus: http.StatusOK,
})
}
return return
} }

View file

@ -19,7 +19,8 @@ type AuditEntry struct {
} }
type AuditLogger struct { type AuditLogger struct {
db *DB db *DB
OnWrite func()
} }
func NewAuditLogger(db *DB) *AuditLogger { func NewAuditLogger(db *DB) *AuditLogger {
@ -38,6 +39,8 @@ func (a *AuditLogger) Log(entry AuditEntry) {
) )
if err != nil { if err != nil {
log.Printf("ERROR: audit log: %v", err) log.Printf("ERROR: audit log: %v", err)
} else if a.OnWrite != nil {
a.OnWrite()
} }
} }

View file

@ -37,6 +37,7 @@ type DebugLogger struct {
db *DB db *DB
enabled atomic.Bool enabled atomic.Bool
dataDir string dataDir string
OnWrite func()
} }
func NewDebugLogger(db *DB, enabled bool, dataDir string) *DebugLogger { func NewDebugLogger(db *DB, enabled bool, dataDir string) *DebugLogger {
@ -105,6 +106,8 @@ func (d *DebugLogger) Log(entry DebugLogEntry) {
) )
if err != nil { if err != nil {
log.Printf("ERROR: debug log db insert: %v", err) log.Printf("ERROR: debug log db insert: %v", err)
} else if d.OnWrite != nil {
d.OnWrite()
} }
} }