This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/usage.go
2026-02-24 16:51:08 +00:00

234 lines
6.4 KiB
Go

package handlers
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// UsageHandler manages usage tracking and pricing endpoints.
type UsageHandler struct {
stores store.Stores
}
// NewUsageHandler creates a usage handler.
func NewUsageHandler(s store.Stores) *UsageHandler {
return &UsageHandler{stores: s}
}
// ── Admin: Aggregated Usage ────────────────
// GET /admin/usage?since=...&until=...&group_by=model|user|day|provider
func (h *UsageHandler) AdminUsage(c *gin.Context) {
opts := h.parseQueryOptions(c)
opts.ExcludeBYOK = true // Admin views exclude personal BYOK
results, err := h.stores.Usage.QueryByModel(c.Request.Context(), opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query usage"})
return
}
totals, err := h.stores.Usage.GetTotals(c.Request.Context(), opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get totals"})
return
}
c.JSON(http.StatusOK, gin.H{
"totals": totals,
"results": results,
})
}
// ── Admin: Per-User Usage ──────────────────
// GET /admin/usage/users/:id
func (h *UsageHandler) AdminUserUsage(c *gin.Context) {
userID := c.Param("id")
opts := h.parseQueryOptions(c)
opts.ExcludeBYOK = true
results, err := h.stores.Usage.QueryByUser(c.Request.Context(), userID, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query user usage"})
return
}
c.JSON(http.StatusOK, gin.H{"results": results})
}
// ── Admin: Per-Team Usage ──────────────────
// GET /admin/usage/teams/:id
func (h *UsageHandler) AdminTeamUsage(c *gin.Context) {
teamID := c.Param("id")
opts := h.parseQueryOptions(c)
opts.ExcludeBYOK = true
results, err := h.stores.Usage.QueryByTeam(c.Request.Context(), teamID, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query team usage"})
return
}
c.JSON(http.StatusOK, gin.H{"results": results})
}
// ── User: Personal Usage ───────────────────
// GET /usage
func (h *UsageHandler) PersonalUsage(c *gin.Context) {
userID := getUserID(c)
opts := h.parseQueryOptions(c)
totals, err := h.stores.Usage.GetPersonalTotals(c.Request.Context(), userID, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get usage"})
return
}
results, err := h.stores.Usage.QueryByUserPersonal(c.Request.Context(), userID, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query usage"})
return
}
c.JSON(http.StatusOK, gin.H{
"totals": totals,
"results": results,
})
}
// ── Team Admin: Team Provider Usage ────────
// GET /teams/:teamId/usage
//
// Shows usage against providers owned by this team. Only team admins
// (enforced by RequireTeamAdmin middleware) can see this view.
func (h *UsageHandler) TeamUsage(c *gin.Context) {
teamID := c.Param("teamId")
opts := h.parseQueryOptions(c)
totals, err := h.stores.Usage.GetTeamProviderTotals(c.Request.Context(), teamID, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get team usage"})
return
}
results, err := h.stores.Usage.QueryByTeamProviders(c.Request.Context(), teamID, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query team usage"})
return
}
c.JSON(http.StatusOK, gin.H{
"totals": totals,
"results": results,
})
}
// ── Admin: List Pricing ────────────────────
// GET /admin/pricing
func (h *UsageHandler) ListPricing(c *gin.Context) {
entries, err := h.stores.Pricing.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list pricing"})
return
}
c.JSON(http.StatusOK, entries)
}
// ── Admin: Upsert Pricing ──────────────────
// PUT /admin/pricing
func (h *UsageHandler) UpsertPricing(c *gin.Context) {
var req models.PricingEntry
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate provider is admin-managed (global or team), not personal BYOK
pc, err := h.stores.Providers.GetByID(c.Request.Context(), req.ProviderConfigID)
if err != nil || pc == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "provider config not found"})
return
}
if pc.Scope == "personal" {
c.JSON(http.StatusForbidden, gin.H{"error": "cannot set pricing for personal BYOK providers"})
return
}
userID := getUserID(c)
req.Source = "manual"
req.UpdatedBy = &userID
if err := h.stores.Pricing.Upsert(c.Request.Context(), &req); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save pricing"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "pricing saved"})
}
// ── Admin: Delete Pricing ──────────────────
// DELETE /admin/pricing/:provider/:model
func (h *UsageHandler) DeletePricing(c *gin.Context) {
providerConfigID := c.Param("provider")
modelID := c.Param("model")
if err := h.stores.Pricing.Delete(c.Request.Context(), providerConfigID, modelID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete pricing"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "pricing deleted"})
}
// ── Helpers ────────────────────────────────
func (h *UsageHandler) parseQueryOptions(c *gin.Context) store.UsageQueryOptions {
opts := store.UsageQueryOptions{
GroupBy: c.DefaultQuery("group_by", "model"),
Limit: 100,
}
if since := c.Query("since"); since != "" {
if t, err := time.Parse(time.RFC3339, since); err == nil {
opts.Since = &t
}
}
if until := c.Query("until"); until != "" {
if t, err := time.Parse(time.RFC3339, until); err == nil {
opts.Until = &t
}
}
// Convenience shorthand: ?period=7d|30d|90d
if period := c.Query("period"); period != "" && opts.Since == nil {
var dur time.Duration
switch period {
case "7d":
dur = 7 * 24 * time.Hour
case "30d":
dur = 30 * 24 * time.Hour
case "90d":
dur = 90 * 24 * time.Hour
}
if dur > 0 {
t := time.Now().Add(-dur)
opts.Since = &t
}
}
return opts
}