- 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.
385 lines
13 KiB
Go
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
|
|
}
|