237 lines
6.5 KiB
Go
237 lines
6.5 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
|
|
}
|
|
if entries == nil {
|
|
entries = []models.PricingEntry{}
|
|
}
|
|
|
|
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
|
|
}
|