-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathcost_forecast.go
More file actions
152 lines (142 loc) · 5.26 KB
/
Copy pathcost_forecast.go
File metadata and controls
152 lines (142 loc) · 5.26 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
package main
import (
"context"
"encoding/json"
"net/http"
"time"
)
// CostForecaster predicts when a service's daily budget will be exhausted
// using linear extrapolation from current spend versus elapsed-time-of-day.
//
// It reads the existing CostTracker (costs.go) which already tallies
// per-service today-so-far spend. The forecaster does no I/O beyond a
// single read of that tracker; latency is microseconds.
type CostForecaster struct {
tracker *CostTracker
}
// NewCostForecaster wraps an existing CostTracker.
func NewCostForecaster(tracker *CostTracker) *CostForecaster {
return &CostForecaster{tracker: tracker}
}
// ForecastResult is one service's projected end-of-day spend and budget
// exhaustion ETA. Multiple ForecastResults are returned by the API.
type ForecastResult struct {
Service string `json:"service"`
BudgetUSD float64 `json:"budget_usd"`
SpentSoFarUSD float64 `json:"spent_so_far_usd"`
HoursElapsed float64 `json:"hours_elapsed"`
HoursRemaining float64 `json:"hours_remaining"`
ProjectedTotalUSD float64 `json:"projected_total_usd"`
ProjectedOverBudget bool `json:"projected_over_budget"`
BudgetExhaustionTime string `json:"budget_exhaustion_time,omitempty"` // RFC3339 if extrapolated to hit budget today
BurnRateUSDPerHour float64 `json:"burn_rate_usd_per_hour"`
}
// Forecast computes one ForecastResult per configured service+budget.
// Services with no budget configured are skipped.
//
// Algorithm (deliberately simple to remain interpretable):
// - hours_elapsed = (time-of-day in UTC) / 1h
// - burn_rate = spent_so_far / hours_elapsed (zero before 0.1 h)
// - projected_total = burn_rate × 24
// - exhaustion_time = now + (budget - spent_so_far) / burn_rate (when burn rate > 0)
//
// Linear extrapolation is right for steady workloads. For bursty
// workloads it overestimates early in the day and undercounts late;
// callers who care can tighten with EWMA in a follow-up.
func (f *CostForecaster) Forecast() []ForecastResult {
if f == nil || f.tracker == nil || cfg == nil {
return []ForecastResult{}
}
now := time.Now().UTC()
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
hoursElapsed := now.Sub(startOfDay).Hours()
hoursRemaining := 24 - hoursElapsed
out := []ForecastResult{}
for service, budget := range cfg.Budgets {
spent := f.tracker.spentToday(service)
burn := 0.0
if hoursElapsed > 0.1 {
burn = spent / hoursElapsed
}
projected := burn * 24
over := projected > budget.DailyLimitUSD && budget.DailyLimitUSD > 0
exhaustion := ""
if burn > 0 && budget.DailyLimitUSD > 0 && spent < budget.DailyLimitUSD {
hoursToBudget := (budget.DailyLimitUSD - spent) / burn
if hoursToBudget < hoursRemaining {
exhaustion = now.Add(time.Duration(hoursToBudget * float64(time.Hour))).Format(time.RFC3339)
}
}
out = append(out, ForecastResult{
Service: service,
BudgetUSD: budget.DailyLimitUSD,
SpentSoFarUSD: spent,
HoursElapsed: hoursElapsed,
HoursRemaining: hoursRemaining,
BurnRateUSDPerHour: burn,
ProjectedTotalUSD: projected,
ProjectedOverBudget: over,
BudgetExhaustionTime: exhaustion,
})
}
return out
}
// handleCostForecast serves GET /api/costs/forecast.
func handleCostForecast(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
if costForecaster == nil {
_ = json.NewEncoder(w).Encode(map[string]any{
"object": "list",
"data": []any{},
"note": "cost forecaster not initialised; check that budgets are configured and a CostTracker is wired",
})
return
}
results := costForecaster.Forecast()
_ = json.NewEncoder(w).Encode(map[string]any{
"object": "list",
"as_of": time.Now().UTC().Format(time.RFC3339),
"data": results,
})
}
// costForecaster is the package-level handle. Set in main.go after
// CostTracker is initialised.
var costForecaster *CostForecaster
// initCostForecaster wires the forecaster to the CostTracker. Safe to
// call multiple times; latest tracker wins.
func initCostForecaster() {
if costs == nil {
return
}
costForecaster = NewCostForecaster(costs)
}
// counterfactualCost computes "what would this request have cost on
// every other healthy backend?" for the per-request comparison view.
// Returns a map of backend-name → estimated USD cost using cost_input_1m
// and cost_output_1m from each backend's config.
//
// Used by /api/costs/counterfactual?request_id=X (not implemented in
// this layer; it would need a per-request cost log, which is the job
// of a future migration).
func counterfactualCost(inputTokens, outputTokens int) map[string]float64 {
if pool == nil {
return nil
}
out := map[string]float64{}
pool.mu.RLock()
defer pool.mu.RUnlock()
for name, b := range pool.backends {
ci := b.Config.CostInput1M
co := b.Config.CostOutput1M
usd := (ci*float64(inputTokens) + co*float64(outputTokens)) / 1e6
out[name] = usd
}
return out
}
// stub placeholder so context import isn't dropped; future wiring will
// need ctx for shadow routing's per-request goroutine cancellation.
var _ = context.Background