Ctrl K
ComunidadGuías…

internal/budget/service.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package budget

import (
	"cmp"
	"context"
	"errors"
	"fmt"
	"log/slog"
	"slices"
	"strings"
	"sync"
	"time"

	"github.com/enterpilot/gomodel/internal/core"
)

// ErrUnavailable indicates a budget service was used without an initialized store.
var ErrUnavailable = errors.New("budget service is unavailable")

// ErrQuotaTemplatesUnavailable indicates that a per-child template was found
// while the deployment does not have the quota-templates capability.
var ErrQuotaTemplatesUnavailable = errors.New("per-child quota templates are not enabled")

// ErrBilledBudgetsUnavailable indicates that a budget on billed cost was found
// while the deployment lacks the billed-budgets capability or has markups
// disabled.
var ErrBilledBudgetsUnavailable = errors.New("budgets on billed cost require a GoModel Pro license with the billing entitlement and pricing.markups_enabled")

// ServiceOption configures a budget service.
type ServiceOption func(*Service)

// WithQuotaTemplates controls whether per-child user-path budgets are accepted.
func WithQuotaTemplates(enabled bool) ServiceOption {
	return func(service *Service) {
		service.caps.quotaTemplates = enabled
	}
}

// WithBilledBudgets controls whether budgets on billed cost are accepted.
func WithBilledBudgets(enabled bool) ServiceOption {
	return func(service *Service) {
		service.caps.billedBudgets = enabled
	}
}

// WithBilledCacheHits reports whether response-cache hits add billed cost
// (markups enabled and bill_cache_hits on), which makes CheckCacheHit enforce
// billed budgets before a cached response is replayed.
func WithBilledCacheHits(enabled bool) ServiceOption {
	return func(service *Service) {
		service.billedCacheHits = enabled
	}
}

// WithProviderBilledCacheHits controls whether provider budgets on billed cost
// count billed response-cache hits. The default is true.
func WithProviderBilledCacheHits(enabled bool) ServiceOption {
	return func(service *Service) {
		service.providerBilledCacheHits = enabled
	}
}

// capabilities records which optional budget behaviors the deployment has.
type capabilities struct {
	quotaTemplates bool
	billedBudgets  bool
}

// validate refuses budgets being written that need a capability the
// deployment does not have.
func (c capabilities) validate(budgets []Budget) error {
	for _, item := range budgets {
		if item.PerChild && !c.quotaTemplates {
			return fmt.Errorf("%w: budget for user path %q", ErrQuotaTemplatesUnavailable, item.Subject)
		}
		if item.CostBasis == CostBasisBilled && !c.billedBudgets {
			return fmt.Errorf("%w: budget for %s", ErrBilledBudgetsUnavailable, item.SubjectLabel())
		}
	}
	return nil
}

type Service struct {
	store Store
	mu    sync.RWMutex
	// refreshMu serializes Refresh from the store read through the swap, so
	// a slower refresh never installs an older snapshot over a newer one.
	refreshMu sync.Mutex

	budgets  []Budget
	settings Settings
	// billedConsumerBudgets is set when a user-path or label budget limits
	// billed cost, so CheckCacheHit can return without a store round trip.
	billedConsumerBudgets bool
	// downgradeWarned remembers the stored billed budgets already reported as
	// enforced on provider cost, so each is logged once.
	downgradeWarned map[string]struct{}

	caps            capabilities
	billedCacheHits bool
	// providerBilledCacheHits keeps billed cache hits in provider budgets on
	// billed cost; see config.BudgetsConfig.ProviderBilledCacheHits.
	providerBilledCacheHits bool

	// changed is called after every successful write; see SetChangeNotifier.
	changed func(context.Context)

	// providerBillable reports whether a provider's cost is owed by the
	// caller; see SetProviderBillable. nil treats every provider as billable.
	providerBillable func(providerName string) bool
}

// SetProviderBillable installs the lookup that tells a non-billable provider
// (billable: false) from a billable one. A request routed to a non-billable
// provider is checked against provider budgets only: its cost is not owed
// by the caller, so user-path and label budgets neither gate nor count it.
// Call it before serving.
func (s *Service) SetProviderBillable(fn func(providerName string) bool) {
	if s != nil {
		s.providerBillable = fn
	}
}

// billable reports whether the routed provider's cost is owed by the caller.
// An unknown route (no provider yet) is billable: consumer budgets apply.
func (s *Service) billable(providerName string) bool {
	providerName = strings.TrimSpace(providerName)
	return s.providerBillable == nil || providerName == "" || s.providerBillable(providerName)
}

// SetChangeNotifier installs fn, called after every successful write of the
// budgets or their settings so other gateway instances reload them. Call it
// before serving; writes made before it was set (startup seeding) are not
// announced.
func (s *Service) SetChangeNotifier(fn func(context.Context)) {
	if s != nil {
		s.changed = fn
	}
}

// notifyChanged announces a successful write. It runs after the local
// refresh, whether or not that succeeded: the store changed either way.
func (s *Service) notifyChanged(ctx context.Context) {
	if s.changed != nil {
		s.changed(ctx)
	}
}

func NewService(ctx context.Context, store Store, options ...ServiceOption) (*Service, error) {
	if store == nil {
		return nil, fmt.Errorf("budget store is required")
	}
	service := &Service{
		store:                   store,
		settings:                DefaultSettings(),
		caps:                    capabilities{quotaTemplates: true, billedBudgets: true},
		providerBilledCacheHits: true,
	}
	for _, option := range options {
		if option != nil {
			option(service)
		}
	}
	if err := service.Refresh(ctx); err != nil {
		return nil, err
	}
	return service, nil
}

func (s *Service) Refresh(ctx context.Context) error {
	if s == nil || s.store == nil {
		return ErrUnavailable
	}
	s.refreshMu.Lock()
	defer s.refreshMu.Unlock()
	budgets, err := s.store.ListBudgets(ctx)
	if err != nil {
		return err
	}
	// Stored per-child templates still need the capability: without it their
	// limits would silently change meaning.
	if err := (capabilities{quotaTemplates: s.caps.quotaTemplates, billedBudgets: true}).validate(budgets); err != nil {
		return err
	}
	settings, err := s.store.GetSettings(ctx)
	if err != nil {
		return err
	}
	slices.SortStableFunc(budgets, func(a, b Budget) int {
		if order := cmp.Or(
			cmp.Compare(scopeRank(a.Scope), scopeRank(b.Scope)),
			cmp.Compare(a.Subject, b.Subject),
		); order != 0 {
			return order
		}
		return cmp.Compare(b.PeriodSeconds, a.PeriodSeconds)
	})
	s.mu.Lock()
	budgets = s.downgradeUnavailableBilled(budgets)
	s.budgets = budgets
	s.settings = settings
	s.billedConsumerBudgets = slices.ContainsFunc(budgets, func(b Budget) bool {
		return b.EnforcedBasis() == CostBasisBilled && b.Scope != ScopeProvider
	})
	s.mu.Unlock()
	return nil
}

// scopeRank orders budgets for evaluation. Provider budgets come last so that,
// when CheckWithResults stops at the first exhausted budget, a consumer breach
// (which failover cannot relieve) is the one reported.
func scopeRank(scope Scope) int {
	switch scope {
	case ScopeLabel:
		return 0
	case ScopeUserPath:
		return 1
	default:
		return 2
	}
}

func (s *Service) UpsertBudgets(ctx context.Context, budgets []Budget) error {
	if s == nil || s.store == nil {
		return ErrUnavailable
	}
	if err := s.validateEdits(budgets); err != nil {
		return err
	}
	if err := s.store.UpsertBudgets(ctx, budgets); err != nil {
		return err
	}
	defer s.notifyChanged(ctx)
	return s.Refresh(ctx)
}

func (s *Service) DeleteBudget(ctx context.Context, scope Scope, subject string, periodSeconds int64) error {
	if s == nil || s.store == nil {
		return ErrUnavailable
	}
	scope, subject, err := normalizeBudgetKey(scope, subject, periodSeconds)
	if err != nil {
		return err
	}
	if err := s.store.DeleteBudget(ctx, scope, subject, periodSeconds); err != nil {
		return err
	}
	defer s.notifyChanged(ctx)
	return s.Refresh(ctx)
}

func (s *Service) ReplaceConfigBudgets(ctx context.Context, budgets []Budget) error {
	if s == nil || s.store == nil {
		return ErrUnavailable
	}
	// Config budgets are declared, not edited, so they get the full check,
	// like startup seeding.
	if err := s.caps.validate(budgets); err != nil {
		return err
	}
	if err := s.store.ReplaceConfigBudgets(ctx, budgets); err != nil {
		return err
	}
	defer s.notifyChanged(ctx)
	return s.Refresh(ctx)
}

// validateEdits refuses budgets being written that need a capability the
// deployment does not have. A budget already stored as billed keeps its basis
// while billed budgets are unavailable, so editing its amount does not switch
// it to provider cost for good.
func (s *Service) validateEdits(budgets []Budget) error {
	if s == nil {
		return nil
	}
	checked := make([]Budget, 0, len(budgets))
	for _, item := range budgets {
		if item.CostBasis == CostBasisBilled && s.StoredAsBilled(item.Scope, item.Subject, item.PeriodSeconds) {
			item.CostBasis = CostBasisProvider
		}
		checked = append(checked, item)
	}
	return s.caps.validate(checked)
}

// StoredAsBilled reports whether the budget with this key is stored on billed
// cost, whether or not billed budgets are currently available.
func (s *Service) StoredAsBilled(scope Scope, subject string, periodSeconds int64) bool {
	if s == nil {
		return false
	}
	key := budgetKey(scope, subject, periodSeconds)
	s.mu.RLock()
	defer s.mu.RUnlock()
	return slices.ContainsFunc(s.budgets, func(b Budget) bool {
		return b.CostBasis == CostBasisBilled && budgetKey(b.Scope, b.Subject, b.PeriodSeconds) == key
	})
}

// downgradeUnavailableBilled enforces stored billed budgets on provider cost
// while billed budgets are unavailable (license lapsed or markups disabled),
// so the limit stays in force and the gateway still starts; restoring the
// entitlement or switching the budget to provider cost ends it. CostBasis
// keeps the stored basis, so listings show it and edits keep it. Each budget
// is logged once. The caller holds s.mu.
func (s *Service) downgradeUnavailableBilled(budgets []Budget) []Budget {
	if s.caps.billedBudgets {
		return budgets
	}
	for i := range budgets {
		if budgets[i].CostBasis != CostBasisBilled {
			continue
		}
		budgets[i].EnforcedCostBasis = CostBasisProvider
		key := budgetKey(budgets[i].Scope, budgets[i].Subject, budgets[i].PeriodSeconds)
		if _, warned := s.downgradeWarned[key]; warned {
			continue
		}
		if s.downgradeWarned == nil {
			s.downgradeWarned = make(map[string]struct{})
		}
		s.downgradeWarned[key] = struct{}{}
		slog.Warn("budget on billed cost enforced on provider cost: billed budgets need a GoModel Pro license with the billing entitlement and PRICING_MARKUPS_ENABLED=true",
			"scope", budgets[i].Scope,
			"subject", budgets[i].Subject,
			"period", PeriodLabel(budgets[i].PeriodSeconds),
		)
	}
	return budgets
}

func (s *Service) Budgets() []Budget {
	if s == nil {
		return nil
	}
	s.mu.RLock()
	defer s.mu.RUnlock()
	return append([]Budget(nil), s.budgets...)
}

func (s *Service) Settings() Settings {
	if s == nil {
		return DefaultSettings()
	}
	s.mu.RLock()
	defer s.mu.RUnlock()
	return s.settings
}

func (s *Service) SaveSettings(ctx context.Context, settings Settings) (Settings, error) {
	if s == nil || s.store == nil {
		return Settings{}, ErrUnavailable
	}
	saved, err := s.store.SaveSettings(ctx, settings)
	if err != nil {
		return Settings{}, err
	}
	defer s.notifyChanged(ctx)
	if err := s.Refresh(ctx); err != nil {
		return saved, fmt.Errorf("refresh budget service after saving settings: %w", err)
	}
	return saved, nil
}

// Statuses evaluates every configured budget without enforcing limits.
func (s *Service) Statuses(ctx context.Context, now time.Time) ([]CheckResult, error) {
	return s.statusesMatching(ctx, nil, now)
}

// StatusesFor evaluates every budget covering the request subjects without
// enforcing limits. Unlike CheckWithResults it never stops at an exhausted
// budget, so callers get the full status picture even when several are
// exceeded.
func (s *Service) StatusesFor(ctx context.Context, subjects Subjects, now time.Time) ([]CheckResult, error) {
	return s.statusesMatching(ctx, &subjects, now)
}

// statusesMatching evaluates the budgets covering subjects, or all of them
// when subjects is nil.
func (s *Service) statusesMatching(ctx context.Context, subjects *Subjects, now time.Time) ([]CheckResult, error) {
	if s == nil || s.store == nil {
		return nil, ErrUnavailable
	}
	matching, settings, now, err := s.match(subjects, now)
	if err != nil {
		return nil, err
	}
	return s.evaluate(ctx, matching, now, settings)
}

func (s *Service) ResetBudget(ctx context.Context, scope Scope, subject string, periodSeconds int64, at time.Time) error {
	if s == nil || s.store == nil {
		return ErrUnavailable
	}
	scope, subject, err := normalizeBudgetKey(scope, subject, periodSeconds)
	if err != nil {
		return err
	}
	if at.IsZero() {
		at = time.Now().UTC()
	}
	if err := s.store.ResetBudget(ctx, scope, subject, periodSeconds, at.UTC()); err != nil {
		return err
	}
	defer s.notifyChanged(ctx)
	return s.Refresh(ctx)
}

func (s *Service) ResetAll(ctx context.Context, at time.Time) error {
	if s == nil || s.store == nil {
		return ErrUnavailable
	}
	if at.IsZero() {
		at = time.Now().UTC()
	}
	if err := s.store.ResetAllBudgets(ctx, at.UTC()); err != nil {
		return err
	}
	defer s.notifyChanged(ctx)
	return s.Refresh(ctx)
}

func (s *Service) Check(ctx context.Context, subjects Subjects, now time.Time) error {
	_, err := s.CheckWithResults(ctx, subjects, now)
	return err
}

// CheckWithResults evaluates every budget covering the request subjects and
// stops at the first exhausted one, returning it as an ExceededError alongside
// the results evaluated so far.
func (s *Service) CheckWithResults(ctx context.Context, subjects Subjects, now time.Time) ([]CheckResult, error) {
	if s == nil || s.store == nil {
		return nil, ErrUnavailable
	}
	matching, settings, now, err := s.match(&subjects, now)
	if err != nil {
		return nil, err
	}
	if len(matching) == 0 {
		return nil, nil
	}
	results, err := s.evaluate(ctx, matching, now, settings)
	if err != nil {
		return results, err
	}
	for i, result := range results {
		if result.HasUsage && result.Spent >= result.Budget.Amount {
			return results[:i+1], &ExceededError{Result: result}
		}
	}
	return results, nil
}

// CheckCacheHit evaluates the budgets a response-cache hit spends against
// before the cached response is replayed. Only billed user-path and label
// budgets qualify, and only while cache hits are billed: provider cost never
// counts cache hits, and a hit is not routed to any provider. subjects.Provider
// names the provider the request resolved to, so a hit of a billable: false
// provider is admitted like the request itself would be. It returns without a
// store round trip when no such budget exists.
func (s *Service) CheckCacheHit(ctx context.Context, subjects Subjects, now time.Time) error {
	if s == nil || s.store == nil || !s.billedCacheHits {
		return nil
	}
	s.mu.RLock()
	billed := s.billedConsumerBudgets
	s.mu.RUnlock()
	if !billed {
		return nil
	}
	if !s.billable(subjects.Provider) {
		// Consumer budgets are all a cache hit can spend against, and the
		// caller owes nothing for this provider, cache hits included.
		return nil
	}
	// Provider budgets never count cache hits; the provider was only needed
	// for the exemption above.
	subjects.Provider = ""
	matching, settings, now, err := s.match(&subjects, now)
	if err != nil {
		return err
	}
	matching = slices.DeleteFunc(matching, func(b Budget) bool { return b.EnforcedBasis() != CostBasisBilled })
	if len(matching) == 0 {
		return nil
	}
	results, err := s.evaluate(ctx, matching, now, settings)
	if err != nil {
		return err
	}
	for _, result := range results {
		if result.HasUsage && result.Spent >= result.Budget.Amount {
			return &ExceededError{Result: result}
		}
	}
	return nil
}

// FailoverWithinBudget reports whether a request admitted on the primary
// provider may fail over to target: no provider budget of target is
// exhausted and, when the primary was not billable, no user-path or label
// budget covering the request is either. Admission skips consumer budgets
// for a non-billable primary, so the first billable target has to pass
// them; after a billable primary they already passed, and switching targets
// cannot relieve them, so they are not asked again.
func (s *Service) FailoverWithinBudget(ctx context.Context, primary, target string) bool {
	if !s.ProviderWithinBudget(ctx, target) {
		return false
	}
	if s == nil || s.store == nil || s.billable(primary) || !s.billable(target) {
		return true
	}
	userPath := core.UserPathFromContext(ctx)
	if userPath == "" {
		userPath = "/"
	}
	subjects := Subjects{UserPath: userPath, Labels: core.RequestLabelsFromContext(ctx), Provider: target}
	matching, settings, now, err := s.match(&subjects, time.Now().UTC())
	if err != nil {
		return false
	}
	matching = slices.DeleteFunc(matching, func(b Budget) bool { return b.Scope == ScopeProvider })
	if len(matching) == 0 {
		return true
	}
	results, err := s.evaluate(ctx, matching, now, settings)
	if err != nil {
		return false
	}
	for _, result := range results {
		if result.HasUsage && result.Spent >= result.Budget.Amount {
			return false
		}
	}
	return true
}

// ProviderWithinBudget reports whether no provider budget for providerName is
// exhausted. Failover uses it to skip targets whose provider budget is spent;
// consumer budgets are deliberately ignored because switching targets cannot
// relieve them. A store failure counts as exhausted, matching admission, which
// refuses a request when the budget check fails.
func (s *Service) ProviderWithinBudget(ctx context.Context, providerName string) bool {
	if s == nil || s.store == nil || providerName == "" {
		return true
	}
	now := time.Now().UTC()
	s.mu.RLock()
	budgets := s.budgets
	settings := s.settings
	s.mu.RUnlock()

	subjects := Subjects{Provider: providerName}
	var matching []Budget
	for _, budget := range budgets {
		if budget.Scope == ScopeProvider && budget.appliesTo(subjects) {
			matching = append(matching, budget)
		}
	}
	if len(matching) == 0 {
		return true
	}
	results, err := s.evaluate(ctx, matching, now, settings)
	if err != nil {
		return false
	}
	for _, result := range results {
		if result.HasUsage && result.Spent >= result.Budget.Amount {
			return false
		}
	}
	return true
}

// match snapshots the budgets covering subjects — or all of them when subjects
// is nil — along with the reset settings and the normalized evaluation time.
func (s *Service) match(subjects *Subjects, now time.Time) ([]Budget, Settings, time.Time, error) {
	if now.IsZero() {
		now = time.Now().UTC()
	}
	now = now.UTC()

	// Refresh publishes a freshly built slice rather than mutating the current
	// one, so the header read under the lock stays valid afterwards and the
	// matching pass needs no copy.
	s.mu.RLock()
	budgets := s.budgets
	settings := s.settings
	s.mu.RUnlock()

	if subjects == nil {
		return slices.Clone(budgets), settings, now, nil
	}
	userPath, err := NormalizeUserPath(subjects.UserPath)
	if err != nil {
		return nil, settings, now, err
	}
	resolved := Subjects{UserPath: userPath, Labels: subjects.Labels, Provider: subjects.Provider}
	consumer := s.billable(resolved.Provider)

	matching := make([]Budget, 0, len(budgets))
	for _, budget := range budgets {
		if !consumer && budget.Scope != ScopeProvider {
			continue
		}
		if matched, ok := budget.resolve(resolved); ok {
			matching = append(matching, matched)
		}
	}
	return matching, settings, now, nil
}

// evaluate resolves the active period of every budget and asks the store for
// all their spends in one round trip. Enforcement runs on every request, so the
// batched lookup is what keeps a wide match set from costing a query each.
func (s *Service) evaluate(ctx context.Context, budgets []Budget, now time.Time, settings Settings) ([]CheckResult, error) {
	if len(budgets) == 0 {
		return []CheckResult{}, nil
	}
	results := make([]CheckResult, len(budgets))
	windows := make([]SpendWindow, 0, len(budgets))
	windowIndexes := make([]int, 0, len(budgets))
	for i, budget := range budgets {
		start, end := PeriodBounds(now, budget.PeriodSeconds, settings)
		if budget.LastResetAt != nil && budget.LastResetAt.After(start) {
			start = budget.LastResetAt.UTC()
		}
		results[i] = CheckResult{Budget: budget, PeriodStart: start, PeriodEnd: end, Remaining: budget.Amount}
		// A template shown in the global admin list has no single usage total.
		// Only resolved request/user-path checks query one child partition.
		if budget.PerChild && budget.EffectiveSubject == "" {
			continue
		}
		windows = append(windows, SpendWindow{
			Scope:            budget.Scope,
			Subject:          budget.evaluationSubject(),
			CostBasis:        budget.EnforcedBasis(),
			Start:            start,
			End:              now,
			ExcludeCacheHits: budget.Scope == ScopeProvider && !s.providerBilledCacheHits,
		})
		windowIndexes = append(windowIndexes, i)
	}
	if len(windows) == 0 {
		return results, nil
	}

	spends, err := s.store.SumSpend(ctx, windows)
	if err != nil {
		return nil, err
	}
	if len(spends) != len(windows) {
		return nil, fmt.Errorf("budget store returned %d spends for %d windows", len(spends), len(windows))
	}
	for i, spend := range spends {
		result := &results[windowIndexes[i]]
		result.Spent = spend.Total
		result.HasUsage = spend.HasUsage
		result.Remaining = result.Budget.Amount - spend.Total
	}
	return results, nil
}