ultisuite-backend/internal/api/admin/ai_usage.go
R3D347HR4Y 3978622050
Some checks are pending
CI / Go tests (push) Waiting to run
CI / Integration tests (push) Waiting to run
CI / DB migrations (push) Waiting to run
refactor(ai): update AI gateway and cost management features
- Refactored AI gateway to utilize new cost management structures for usage tracking.
- Replaced deprecated token extraction methods with a unified cost parsing approach.
- Enhanced usage fallback mechanisms and introduced detailed usage metrics in responses.
- Added new metering functionality to record AI usage and costs effectively.
- Updated tests to reflect changes in usage parsing and cost calculations.
- Introduced new API endpoints for retrieving AI usage summaries and pricing information.
2026-06-16 10:46:33 +02:00

385 lines
13 KiB
Go

package admin
import (
"context"
"fmt"
"time"
"github.com/ultisuite/ulti-backend/internal/ai/cost"
)
type AIUsageSummary struct {
CostTodayMicroEUR int64 `json:"cost_today_micro_eur"`
CostMonthMicroEUR int64 `json:"cost_month_micro_eur"`
CostTodayEUR float64 `json:"cost_today_eur"`
CostMonthEUR float64 `json:"cost_month_eur"`
Currency string `json:"currency"`
DailySeries []AIUsageDayPoint `json:"daily_series"`
TopUsers []AIUsageTopUser `json:"top_users"`
TopModels []AIUsageTopModel `json:"top_models"`
OrgPolicy cost.EffectiveLimits `json:"org_policy"`
}
type AIUsageDayPoint struct {
Date string `json:"date"`
CostOrgMicroEUR int64 `json:"cost_org_micro_eur"`
CostUserMicroEUR int64 `json:"cost_user_micro_eur"`
CostOrgEUR float64 `json:"cost_org_eur"`
Requests int `json:"requests"`
}
type AIUsageTopUser struct {
UserID string `json:"user_id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
CostOrgMicroEUR int64 `json:"cost_org_micro_eur"`
CostOrgEUR float64 `json:"cost_org_eur"`
}
type AIUsageTopModel struct {
ModelID string `json:"model_id"`
CostMicroEUR int64 `json:"cost_micro_eur"`
CostEUR float64 `json:"cost_eur"`
RequestCount int `json:"request_count"`
}
type AIUserUsageDetail struct {
UserID string `json:"user_id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
Summary AIUsageSummary `json:"summary"`
Events []AIUsageEventItem `json:"events"`
EventsTotal int `json:"events_total"`
}
type AIUsageEventItem struct {
ID string `json:"id"`
CreatedAt string `json:"created_at"`
Feature string `json:"feature"`
ModelID string `json:"model_id"`
ProviderID string `json:"provider_id"`
BillingScope string `json:"billing_scope"`
CostMicroEUR int64 `json:"cost_micro_eur"`
CostEUR float64 `json:"cost_eur"`
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
CachedInputTokens int `json:"cached_input_tokens"`
Estimated bool `json:"estimated"`
}
type AIPricingEntry struct {
ModelID string `json:"model_id"`
ProviderType string `json:"provider_type"`
InputMicroEURPerMTok int64 `json:"input_micro_eur_per_mtok"`
CachedInputMicroEURPerMTok int64 `json:"cached_input_micro_eur_per_mtok,omitempty"`
OutputMicroEURPerMTok int64 `json:"output_micro_eur_per_mtok"`
ReasoningMicroEURPerMTok int64 `json:"reasoning_micro_eur_per_mtok,omitempty"`
InputEURPerMTok float64 `json:"input_eur_per_mtok"`
OutputEURPerMTok float64 `json:"output_eur_per_mtok"`
}
type AICostPolicyEntry struct {
ScopeType string `json:"scope_type"`
ScopeID *string `json:"scope_id,omitempty"`
ScopeName string `json:"scope_name,omitempty"`
DailyLimitMicroEUR *int64 `json:"daily_limit_micro_eur,omitempty"`
MonthlyLimitMicroEUR *int64 `json:"monthly_limit_micro_eur,omitempty"`
DailyLimitEUR *float64 `json:"daily_limit_eur,omitempty"`
MonthlyLimitEUR *float64 `json:"monthly_limit_eur,omitempty"`
WarnThresholdPct int `json:"warn_threshold_pct"`
}
type putAIPricingRequest struct {
Prices []AIPricingEntry `json:"prices"`
}
type putAICostPolicyRequest struct {
DailyLimitEUR *float64 `json:"daily_limit_eur"`
MonthlyLimitEUR *float64 `json:"monthly_limit_eur"`
WarnThresholdPct int `json:"warn_threshold_pct"`
DailyLimitMicroEUR *int64 `json:"daily_limit_micro_eur,omitempty"`
MonthlyLimitMicroEUR *int64 `json:"monthly_limit_micro_eur,omitempty"`
}
func (s *Service) GetAIUsageSummary(ctx context.Context, billingScope string) (AIUsageSummary, error) {
today := time.Now().UTC().Truncate(24 * time.Hour)
month := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, time.UTC)
out := AIUsageSummary{
Currency: "EUR",
DailySeries: []AIUsageDayPoint{},
TopUsers: []AIUsageTopUser{},
TopModels: []AIUsageTopModel{},
}
policySvc := cost.NewPolicyService(s.db)
orgPolicy, _ := policySvc.GetOrgPolicy(ctx)
out.OrgPolicy = orgPolicy
var dailyOrg, dailyUser int64
_ = s.db.QueryRow(ctx, `
SELECT COALESCE(cost_micro_eur_org, 0), COALESCE(cost_micro_eur_user, 0)
FROM ai_org_usage_daily WHERE usage_date = $1
`, today).Scan(&dailyOrg, &dailyUser)
var monthlyOrg int64
_ = s.db.QueryRow(ctx, `
SELECT COALESCE(cost_micro_eur_org, 0) FROM ai_org_usage_monthly WHERE usage_month = $1
`, month).Scan(&monthlyOrg)
if billingScope == "user" {
out.CostTodayMicroEUR = dailyUser
} else {
out.CostTodayMicroEUR = dailyOrg
}
out.CostMonthMicroEUR = monthlyOrg
out.CostTodayEUR = cost.MicroEURToEUR(out.CostTodayMicroEUR)
out.CostMonthEUR = cost.MicroEURToEUR(out.CostMonthMicroEUR)
rows, err := s.db.Query(ctx, `
SELECT usage_date, cost_micro_eur_org, cost_micro_eur_user, requests
FROM ai_org_usage_daily
WHERE usage_date >= $1
ORDER BY usage_date ASC
`, today.AddDate(0, 0, -29))
if err == nil {
defer rows.Close()
for rows.Next() {
var pt AIUsageDayPoint
var d time.Time
if err := rows.Scan(&d, &pt.CostOrgMicroEUR, &pt.CostUserMicroEUR, &pt.Requests); err != nil {
continue
}
pt.Date = d.Format("2006-01-02")
pt.CostOrgEUR = cost.MicroEURToEUR(pt.CostOrgMicroEUR)
out.DailySeries = append(out.DailySeries, pt)
}
}
topUserRows, err := s.db.Query(ctx, `
SELECT u.id::text, COALESCE(u.email, ''), COALESCE(u.display_name, ''),
COALESCE(SUM(e.cost_micro_eur), 0)
FROM ai_usage_events e
JOIN users u ON u.id = e.user_id
WHERE e.created_at >= $1 AND e.billing_scope = 'org'
GROUP BY u.id, u.email, u.display_name
ORDER BY SUM(e.cost_micro_eur) DESC
LIMIT 10
`, month)
if err == nil {
defer topUserRows.Close()
for topUserRows.Next() {
var u AIUsageTopUser
if err := topUserRows.Scan(&u.UserID, &u.Email, &u.DisplayName, &u.CostOrgMicroEUR); err != nil {
continue
}
u.CostOrgEUR = cost.MicroEURToEUR(u.CostOrgMicroEUR)
out.TopUsers = append(out.TopUsers, u)
}
}
topModelRows, err := s.db.Query(ctx, `
SELECT model_id, COALESCE(SUM(cost_micro_eur), 0), COUNT(*)
FROM ai_usage_events
WHERE created_at >= $1 AND billing_scope = 'org'
GROUP BY model_id
ORDER BY SUM(cost_micro_eur) DESC
LIMIT 10
`, month)
if err == nil {
defer topModelRows.Close()
for topModelRows.Next() {
var m AIUsageTopModel
if err := topModelRows.Scan(&m.ModelID, &m.CostMicroEUR, &m.RequestCount); err != nil {
continue
}
m.CostEUR = cost.MicroEURToEUR(m.CostMicroEUR)
out.TopModels = append(out.TopModels, m)
}
}
return out, nil
}
func (s *Service) GetUserAIUsage(ctx context.Context, userID string, limit, offset int) (AIUserUsageDetail, error) {
if limit <= 0 {
limit = 50
}
var detail AIUserUsageDetail
detail.Events = []AIUsageEventItem{}
err := s.db.QueryRow(ctx, `
SELECT id::text, COALESCE(email, ''), COALESCE(display_name, '')
FROM users WHERE id = $1::uuid
`, userID).Scan(&detail.UserID, &detail.Email, &detail.DisplayName)
if err != nil {
return detail, fmt.Errorf("user not found")
}
summary, _ := s.GetAIUsageSummary(ctx, "org")
detail.Summary = summary
_ = s.db.QueryRow(ctx, `
SELECT COUNT(*) FROM ai_usage_events WHERE user_id = $1::uuid
`, userID).Scan(&detail.EventsTotal)
rows, err := s.db.Query(ctx, `
SELECT id::text, created_at, feature, model_id, provider_id, billing_scope,
cost_micro_eur, prompt_tokens, completion_tokens, cached_input_tokens, estimated
FROM ai_usage_events
WHERE user_id = $1::uuid
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
`, userID, limit, offset)
if err != nil {
return detail, err
}
defer rows.Close()
for rows.Next() {
var ev AIUsageEventItem
var created time.Time
if err := rows.Scan(&ev.ID, &created, &ev.Feature, &ev.ModelID, &ev.ProviderID,
&ev.BillingScope, &ev.CostMicroEUR, &ev.PromptTokens, &ev.CompletionTokens,
&ev.CachedInputTokens, &ev.Estimated); err != nil {
continue
}
ev.CreatedAt = created.UTC().Format(time.RFC3339)
ev.CostEUR = cost.MicroEURToEUR(ev.CostMicroEUR)
detail.Events = append(detail.Events, ev)
}
return detail, rows.Err()
}
func (s *Service) ListAIPricing(ctx context.Context) ([]AIPricingEntry, error) {
store := cost.NewPricingStore(s.db)
prices, err := store.ListPrices(ctx)
if err != nil {
return []AIPricingEntry{}, err
}
out := make([]AIPricingEntry, 0, len(prices))
for _, p := range prices {
out = append(out, AIPricingEntry{
ModelID: p.ModelID,
ProviderType: p.ProviderType,
InputMicroEURPerMTok: p.InputMicroEURPerMTok,
CachedInputMicroEURPerMTok: p.CachedInputMicroEURPerMTok,
OutputMicroEURPerMTok: p.OutputMicroEURPerMTok,
ReasoningMicroEURPerMTok: p.ReasoningMicroEURPerMTok,
InputEURPerMTok: cost.MicroEURToEUR(p.InputMicroEURPerMTok),
OutputEURPerMTok: cost.MicroEURToEUR(p.OutputMicroEURPerMTok),
})
}
return out, nil
}
func (s *Service) PutAIPricing(ctx context.Context, actorSub string, req putAIPricingRequest) ([]AIPricingEntry, error) {
store := cost.NewPricingStore(s.db)
for _, p := range req.Prices {
if err := store.UpsertModelPrice(ctx, cost.ModelPrice{
ModelID: p.ModelID,
ProviderType: p.ProviderType,
InputMicroEURPerMTok: p.InputMicroEURPerMTok,
CachedInputMicroEURPerMTok: p.CachedInputMicroEURPerMTok,
OutputMicroEURPerMTok: p.OutputMicroEURPerMTok,
ReasoningMicroEURPerMTok: p.ReasoningMicroEURPerMTok,
}); err != nil {
return nil, err
}
}
s.logAudit(ctx, actorSub, "update_ai_pricing", map[string]any{"count": len(req.Prices)})
return s.ListAIPricing(ctx)
}
func (s *Service) ListAICostPolicies(ctx context.Context) ([]AICostPolicyEntry, error) {
rows, err := s.db.Query(ctx, `
SELECT p.scope_type, p.scope_id::text, p.daily_limit_micro_eur, p.monthly_limit_micro_eur, p.warn_threshold_pct,
COALESCE(g.name, u.email, 'Organisation')
FROM ai_cost_policies p
LEFT JOIN user_groups g ON p.scope_type = 'group' AND g.id = p.scope_id
LEFT JOIN users u ON p.scope_type = 'user' AND u.id = p.scope_id
ORDER BY p.priority ASC, p.scope_type
`)
if err != nil {
return []AICostPolicyEntry{}, err
}
defer rows.Close()
var out = make([]AICostPolicyEntry, 0)
for rows.Next() {
var e AICostPolicyEntry
var scopeID *string
if err := rows.Scan(&e.ScopeType, &scopeID, &e.DailyLimitMicroEUR, &e.MonthlyLimitMicroEUR,
&e.WarnThresholdPct, &e.ScopeName); err != nil {
continue
}
e.ScopeID = scopeID
if e.DailyLimitMicroEUR != nil {
v := cost.MicroEURToEUR(*e.DailyLimitMicroEUR)
e.DailyLimitEUR = &v
}
if e.MonthlyLimitMicroEUR != nil {
v := cost.MicroEURToEUR(*e.MonthlyLimitMicroEUR)
e.MonthlyLimitEUR = &v
}
out = append(out, e)
}
return out, rows.Err()
}
func (s *Service) PutUserAICostPolicy(ctx context.Context, actorSub, userID string, req putAICostPolicyRequest) error {
daily, monthly := resolveCostLimits(req)
policy := cost.NewPolicyService(s.db)
if err := policy.UpsertScopePolicy(ctx, "user", userID, daily, monthly, req.WarnThresholdPct); err != nil {
return err
}
s.logAudit(ctx, actorSub, "set_user_ai_cost_policy", map[string]any{"user_id": userID})
return nil
}
func (s *Service) PutGroupAICostPolicy(ctx context.Context, actorSub, groupID string, req putAICostPolicyRequest) error {
daily, monthly := resolveCostLimits(req)
policy := cost.NewPolicyService(s.db)
if err := policy.UpsertScopePolicy(ctx, "group", groupID, daily, monthly, req.WarnThresholdPct); err != nil {
return err
}
s.logAudit(ctx, actorSub, "set_group_ai_cost_policy", map[string]any{"group_id": groupID})
return nil
}
func (s *Service) syncUsageQuotasToCostPolicy(ctx context.Context, usageQuotas map[string]any) error {
if usageQuotas == nil {
return nil
}
var daily, monthly *int64
warnPct := 80
if v, ok := usageQuotas["llm_daily_cost_limit_eur"].(float64); ok && v > 0 {
m := int64(v * 1_000_000)
daily = &m
}
if v, ok := usageQuotas["llm_monthly_cost_limit_eur"].(float64); ok && v > 0 {
m := int64(v * 1_000_000)
monthly = &m
}
if v, ok := usageQuotas["llm_cost_warn_threshold_pct"].(float64); ok && v > 0 {
warnPct = int(v)
}
policy := cost.NewPolicyService(s.db)
return policy.UpsertOrgPolicy(ctx, daily, monthly, warnPct)
}
func resolveCostLimits(req putAICostPolicyRequest) (*int64, *int64) {
var daily, monthly *int64
if req.DailyLimitMicroEUR != nil {
daily = req.DailyLimitMicroEUR
} else if req.DailyLimitEUR != nil && *req.DailyLimitEUR > 0 {
v := int64(*req.DailyLimitEUR * 1_000_000)
daily = &v
}
if req.MonthlyLimitMicroEUR != nil {
monthly = req.MonthlyLimitMicroEUR
} else if req.MonthlyLimitEUR != nil && *req.MonthlyLimitEUR > 0 {
v := int64(*req.MonthlyLimitEUR * 1_000_000)
monthly = &v
}
return daily, monthly
}