diff --git a/api/v1alpha1/costprofile_types.go b/api/v1alpha1/costprofile_types.go index e189113..95efbec 100644 --- a/api/v1alpha1/costprofile_types.go +++ b/api/v1alpha1/costprofile_types.go @@ -70,6 +70,31 @@ type ElectricitySpec struct { IdleWattsThreshold *float64 `json:"idleWattsThreshold,omitempty"` } +// CloudTarget names a specific cloud provider + model to compute break-even +// against. Both must match an entry in the bundled cloud pricing catalog +// (case-insensitive); unknown targets are skipped and surfaced via a status +// condition rather than silently ignored. +type CloudTarget struct { + // provider is the cloud provider name (e.g. "anthropic", "openai", "google"). + // +kubebuilder:validation:MinLength=1 + Provider string `json:"provider"` + + // model is the cloud model id (e.g. "claude-sonnet-4-6", "gpt-5.4-nano"). + // +kubebuilder:validation:MinLength=1 + Model string `json:"model"` +} + +// CloudComparisonSpec selects which cloud models the break-even analysis is +// computed against for workloads covered by this profile. +type CloudComparisonSpec struct { + // targets is the list of cloud provider+model pairs to compare against. + // Choose models comparable to what you self-host (a small coder model + // breaks even against a budget cloud model, not a flagship). When omitted, + // InferCost defaults to one mid-tier model per provider. + // +optional + Targets []CloudTarget `json:"targets,omitempty"` +} + // CostProfileSpec defines the hardware economics for computing inference costs. type CostProfileSpec struct { // hardware declares GPU hardware cost parameters. @@ -89,6 +114,11 @@ type CostProfileSpec struct { // If omitted, all namespaces on matching nodes are included. // +optional NamespaceSelector map[string]string `json:"namespaceSelector,omitempty"` + + // cloudComparison selects which cloud models the break-even analysis compares + // against. When omitted, a sensible mid-tier default per provider is used. + // +optional + CloudComparison *CloudComparisonSpec `json:"cloudComparison,omitempty"` } // CostProfileStatus defines the computed state of a CostProfile. diff --git a/api/v1alpha1/usagereport_types.go b/api/v1alpha1/usagereport_types.go index 1b4872d..ba2796f 100644 --- a/api/v1alpha1/usagereport_types.go +++ b/api/v1alpha1/usagereport_types.go @@ -197,6 +197,12 @@ type UsageReportStatus struct { // +optional CloudComparison []CloudComparisonEntry `json:"cloudComparison,omitempty"` + // breakEvenAnalysis reports, per configured cloud target, the daily token + // volume at which on-prem cost equals the cloud cost, and how current + // throughput compares. Answers "at what volume does on-prem beat the cloud?" + // +optional + BreakEvenAnalysis []BreakEvenEntry `json:"breakEvenAnalysis,omitempty"` + // lastUpdated is the timestamp of the last report computation. // +optional LastUpdated *metav1.Time `json:"lastUpdated,omitempty"` @@ -208,6 +214,31 @@ type UsageReportStatus struct { Conditions []metav1.Condition `json:"conditions,omitempty"` } +// BreakEvenEntry reports, for one cloud target, the daily token volume at which +// on-prem cost equals that cloud model's cost, and how current throughput compares. +type BreakEvenEntry struct { + // provider is the cloud provider compared against. + Provider string `json:"provider"` + + // model is the cloud model compared against. + Model string `json:"model"` + + // breakEvenTokensPerDay is the daily token volume at which on-prem cost + // equals this cloud model's cost. Above it, on-prem is cheaper. + BreakEvenTokensPerDay int64 `json:"breakEvenTokensPerDay"` + + // currentUtilizationTokensPerDay is current throughput extrapolated to a full day. + CurrentUtilizationTokensPerDay int64 `json:"currentUtilizationTokensPerDay"` + + // percentOfBreakEven is currentUtilizationTokensPerDay / breakEvenTokensPerDay + // as a percentage (>= 100 means on-prem is at or past break-even). + PercentOfBreakEven float64 `json:"percentOfBreakEven"` + + // verdict summarizes the comparison at current utilization + // ("on-prem-cheaper-at-current-utilization" or "cloud-cheaper-at-current-utilization"). + Verdict string `json:"verdict"` +} + // +kubebuilder:object:root=true // +kubebuilder:subresource:status // +kubebuilder:printcolumn:name="Period",type=string,JSONPath=`.status.period` @@ -215,6 +246,7 @@ type UsageReportStatus struct { // +kubebuilder:printcolumn:name="$/MTok",type=number,JSONPath=`.status.costPerMillionTokens`,format=float // +kubebuilder:printcolumn:name="$/MTok (marginal)",type=number,JSONPath=`.status.marginalCostPerMillionTokens`,format=float,priority=1 // +kubebuilder:printcolumn:name="$/MTok (active)",type=number,JSONPath=`.status.activeHoursCostPerMillionTokens`,format=float,priority=1 +// +kubebuilder:printcolumn:name="Break-even %",type=number,JSONPath=`.status.breakEvenAnalysis[0].percentOfBreakEven`,format=float,priority=1 // +kubebuilder:printcolumn:name="Input Tokens",type=integer,JSONPath=`.status.inputTokens` // +kubebuilder:printcolumn:name="Output Tokens",type=integer,JSONPath=`.status.outputTokens` // +kubebuilder:printcolumn:name="Util %",type=number,JSONPath=`.status.utilizationPercent`,format=float,priority=1 diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 8062ce7..b2a0731 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -40,6 +40,21 @@ func (in *AlertThreshold) DeepCopy() *AlertThreshold { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BreakEvenEntry) DeepCopyInto(out *BreakEvenEntry) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BreakEvenEntry. +func (in *BreakEvenEntry) DeepCopy() *BreakEvenEntry { + if in == nil { + return nil + } + out := new(BreakEvenEntry) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *CloudComparisonEntry) DeepCopyInto(out *CloudComparisonEntry) { *out = *in @@ -55,6 +70,41 @@ func (in *CloudComparisonEntry) DeepCopy() *CloudComparisonEntry { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CloudComparisonSpec) DeepCopyInto(out *CloudComparisonSpec) { + *out = *in + if in.Targets != nil { + in, out := &in.Targets, &out.Targets + *out = make([]CloudTarget, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CloudComparisonSpec. +func (in *CloudComparisonSpec) DeepCopy() *CloudComparisonSpec { + if in == nil { + return nil + } + out := new(CloudComparisonSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CloudTarget) DeepCopyInto(out *CloudTarget) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CloudTarget. +func (in *CloudTarget) DeepCopy() *CloudTarget { + if in == nil { + return nil + } + out := new(CloudTarget) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *CostProfile) DeepCopyInto(out *CostProfile) { *out = *in @@ -133,6 +183,11 @@ func (in *CostProfileSpec) DeepCopyInto(out *CostProfileSpec) { (*out)[key] = val } } + if in.CloudComparison != nil { + in, out := &in.CloudComparison, &out.CloudComparison + *out = new(CloudComparisonSpec) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CostProfileSpec. @@ -475,6 +530,11 @@ func (in *UsageReportStatus) DeepCopyInto(out *UsageReportStatus) { *out = make([]CloudComparisonEntry, len(*in)) copy(*out, *in) } + if in.BreakEvenAnalysis != nil { + in, out := &in.BreakEvenAnalysis, &out.BreakEvenAnalysis + *out = make([]BreakEvenEntry, len(*in)) + copy(*out, *in) + } if in.LastUpdated != nil { in, out := &in.LastUpdated, &out.LastUpdated *out = (*in).DeepCopy() diff --git a/charts/infercost/templates/crds/costprofiles.yaml b/charts/infercost/templates/crds/costprofiles.yaml index e5eefd7..d0f1ed9 100644 --- a/charts/infercost/templates/crds/costprofiles.yaml +++ b/charts/infercost/templates/crds/costprofiles.yaml @@ -62,6 +62,40 @@ spec: description: CostProfileSpec defines the hardware economics for computing inference costs. properties: + cloudComparison: + description: |- + cloudComparison selects which cloud models the break-even analysis compares + against. When omitted, a sensible mid-tier default per provider is used. + properties: + targets: + description: |- + targets is the list of cloud provider+model pairs to compare against. + Choose models comparable to what you self-host (a small coder model + breaks even against a budget cloud model, not a flagship). When omitted, + InferCost defaults to one mid-tier model per provider. + items: + description: |- + CloudTarget names a specific cloud provider + model to compute break-even + against. Both must match an entry in the bundled cloud pricing catalog + (case-insensitive); unknown targets are skipped and surfaced via a status + condition rather than silently ignored. + properties: + model: + description: model is the cloud model id (e.g. "claude-sonnet-4-6", + "gpt-5.4-nano"). + minLength: 1 + type: string + provider: + description: provider is the cloud provider name (e.g. "anthropic", + "openai", "google"). + minLength: 1 + type: string + required: + - model + - provider + type: object + type: array + type: object electricity: description: electricity declares electricity cost parameters. properties: diff --git a/charts/infercost/templates/crds/usagereports.yaml b/charts/infercost/templates/crds/usagereports.yaml index 5e3de55..ed59151 100644 --- a/charts/infercost/templates/crds/usagereports.yaml +++ b/charts/infercost/templates/crds/usagereports.yaml @@ -37,6 +37,11 @@ spec: name: $/MTok (active) priority: 1 type: number + - format: float + jsonPath: .status.breakEvenAnalysis[0].percentOfBreakEven + name: Break-even % + priority: 1 + type: number - jsonPath: .status.inputTokens name: Input Tokens type: integer @@ -131,6 +136,52 @@ spec: power draw above the idle threshold. Paired with totalHoursInPeriod so operators can see the ratio without recomputing. type: number + breakEvenAnalysis: + description: |- + breakEvenAnalysis reports, per configured cloud target, the daily token + volume at which on-prem cost equals the cloud cost, and how current + throughput compares. Answers "at what volume does on-prem beat the cloud?" + items: + description: |- + BreakEvenEntry reports, for one cloud target, the daily token volume at which + on-prem cost equals that cloud model's cost, and how current throughput compares. + properties: + breakEvenTokensPerDay: + description: |- + breakEvenTokensPerDay is the daily token volume at which on-prem cost + equals this cloud model's cost. Above it, on-prem is cheaper. + format: int64 + type: integer + currentUtilizationTokensPerDay: + description: currentUtilizationTokensPerDay is current throughput + extrapolated to a full day. + format: int64 + type: integer + model: + description: model is the cloud model compared against. + type: string + percentOfBreakEven: + description: |- + percentOfBreakEven is currentUtilizationTokensPerDay / breakEvenTokensPerDay + as a percentage (>= 100 means on-prem is at or past break-even). + type: number + provider: + description: provider is the cloud provider compared against. + type: string + verdict: + description: |- + verdict summarizes the comparison at current utilization + ("on-prem-cheaper-at-current-utilization" or "cloud-cheaper-at-current-utilization"). + type: string + required: + - breakEvenTokensPerDay + - currentUtilizationTokensPerDay + - model + - percentOfBreakEven + - provider + - verdict + type: object + type: array byModel: description: byModel contains per-model cost breakdowns. items: diff --git a/config/crd/bases/finops.infercost.ai_costprofiles.yaml b/config/crd/bases/finops.infercost.ai_costprofiles.yaml index 1b7c7f2..563085b 100644 --- a/config/crd/bases/finops.infercost.ai_costprofiles.yaml +++ b/config/crd/bases/finops.infercost.ai_costprofiles.yaml @@ -61,6 +61,40 @@ spec: description: CostProfileSpec defines the hardware economics for computing inference costs. properties: + cloudComparison: + description: |- + cloudComparison selects which cloud models the break-even analysis compares + against. When omitted, a sensible mid-tier default per provider is used. + properties: + targets: + description: |- + targets is the list of cloud provider+model pairs to compare against. + Choose models comparable to what you self-host (a small coder model + breaks even against a budget cloud model, not a flagship). When omitted, + InferCost defaults to one mid-tier model per provider. + items: + description: |- + CloudTarget names a specific cloud provider + model to compute break-even + against. Both must match an entry in the bundled cloud pricing catalog + (case-insensitive); unknown targets are skipped and surfaced via a status + condition rather than silently ignored. + properties: + model: + description: model is the cloud model id (e.g. "claude-sonnet-4-6", + "gpt-5.4-nano"). + minLength: 1 + type: string + provider: + description: provider is the cloud provider name (e.g. "anthropic", + "openai", "google"). + minLength: 1 + type: string + required: + - model + - provider + type: object + type: array + type: object electricity: description: electricity declares electricity cost parameters. properties: diff --git a/config/crd/bases/finops.infercost.ai_usagereports.yaml b/config/crd/bases/finops.infercost.ai_usagereports.yaml index 85cf3f8..0216c12 100644 --- a/config/crd/bases/finops.infercost.ai_usagereports.yaml +++ b/config/crd/bases/finops.infercost.ai_usagereports.yaml @@ -36,6 +36,11 @@ spec: name: $/MTok (active) priority: 1 type: number + - format: float + jsonPath: .status.breakEvenAnalysis[0].percentOfBreakEven + name: Break-even % + priority: 1 + type: number - jsonPath: .status.inputTokens name: Input Tokens type: integer @@ -130,6 +135,52 @@ spec: power draw above the idle threshold. Paired with totalHoursInPeriod so operators can see the ratio without recomputing. type: number + breakEvenAnalysis: + description: |- + breakEvenAnalysis reports, per configured cloud target, the daily token + volume at which on-prem cost equals the cloud cost, and how current + throughput compares. Answers "at what volume does on-prem beat the cloud?" + items: + description: |- + BreakEvenEntry reports, for one cloud target, the daily token volume at which + on-prem cost equals that cloud model's cost, and how current throughput compares. + properties: + breakEvenTokensPerDay: + description: |- + breakEvenTokensPerDay is the daily token volume at which on-prem cost + equals this cloud model's cost. Above it, on-prem is cheaper. + format: int64 + type: integer + currentUtilizationTokensPerDay: + description: currentUtilizationTokensPerDay is current throughput + extrapolated to a full day. + format: int64 + type: integer + model: + description: model is the cloud model compared against. + type: string + percentOfBreakEven: + description: |- + percentOfBreakEven is currentUtilizationTokensPerDay / breakEvenTokensPerDay + as a percentage (>= 100 means on-prem is at or past break-even). + type: number + provider: + description: provider is the cloud provider compared against. + type: string + verdict: + description: |- + verdict summarizes the comparison at current utilization + ("on-prem-cheaper-at-current-utilization" or "cloud-cheaper-at-current-utilization"). + type: string + required: + - breakEvenTokensPerDay + - currentUtilizationTokensPerDay + - model + - percentOfBreakEven + - provider + - verdict + type: object + type: array byModel: description: byModel contains per-model cost breakdowns. items: diff --git a/config/grafana/infercost-dashboard.json b/config/grafana/infercost-dashboard.json index b6df415..37fbcaf 100644 --- a/config/grafana/infercost-dashboard.json +++ b/config/grafana/infercost-dashboard.json @@ -612,6 +612,80 @@ "legendFormat": "{{namespace}} limit" } ] + }, + { + "type": "row", + "id": 106, + "title": "Break-even Analysis", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 46 + }, + "collapsed": false, + "panels": [] + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "max": 150, + "min": 0, + "thresholds": { + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "yellow", + "value": 50 + }, + { + "color": "green", + "value": 100 + } + ] + }, + "unit": "percent" + } + }, + "gridPos": { + "h": 7, + "w": 24, + "x": 0, + "y": 47 + }, + "id": 18, + "options": { + "orientation": "auto", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + } + }, + "title": "Percent of Break-even (>=100% = on-prem cheaper)", + "type": "gauge", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "infercost_percent_of_break_even", + "legendFormat": "{{provider}}/{{cloud_model}}" + } + ] } ], "schemaVersion": 39, diff --git a/docs/cloud-comparison.md b/docs/cloud-comparison.md new file mode 100644 index 0000000..1bf76a3 --- /dev/null +++ b/docs/cloud-comparison.md @@ -0,0 +1,89 @@ +# Cloud comparison and break-even analysis + +InferCost answers the FinOps question that a flat `$/MTok` cannot: **at what daily +volume does running this workload on your own GPUs beat the cloud?** On-prem cost +per token depends on throughput (idle hardware still costs money); cloud cost per +token does not. Break-even collapses both sides into one comparable number. + +## The number + +For each configured cloud target, the `UsageReport` status reports: + +```yaml +status: + breakEvenAnalysis: + - provider: Anthropic + model: claude-sonnet-4-6 + breakEvenTokensPerDay: 142000 + currentUtilizationTokensPerDay: 6000 + percentOfBreakEven: 4.2 + verdict: cloud-cheaper-at-current-utilization +``` + +- **breakEvenTokensPerDay** — the daily token volume at which on-prem daily cost + equals what those tokens would cost on this cloud model. Above it, on-prem is cheaper. +- **currentUtilizationTokensPerDay** — your current throughput extrapolated to a full day. +- **percentOfBreakEven** — `currentUtilizationTokensPerDay / breakEvenTokensPerDay * 100`. + At or above 100%, on-prem wins at your current utilization. +- **verdict** — `on-prem-cheaper-at-current-utilization` or `cloud-cheaper-at-current-utilization`. + +## Methodology + +``` +breakEvenTokensPerDay = dailyHardwareCost / cloudCostPerToken +``` + +**dailyHardwareCost** is the fixed daily cost of owning the hardware, the cost that +must be covered before on-prem is worth it: + +``` +dailyHardwareCost = amortizationPerHour * 24 + + (idleWatts / 1000) * 24 * ratePerKWh * pue +``` + +- `amortizationPerHour` comes from the CostProfile status (purchase price + maintenance, + amortized over the useful life). +- `idleWatts` is `spec.electricity.idleWattsThreshold`, or a default of 20% of + `TDPWatts × GPUCount` (30 W × GPUCount when TDP isn't declared). Idle electricity is + included because the GPUs draw power whenever they're on, not just while serving. +- `pue` is `spec.electricity.pueFactor` (defaults to 1.0). + +**cloudCostPerToken** blends the model's input and output prices by your workload's +**actual input/output ratio** for the period (50/50 when no tokens have been observed): + +``` +cloudCostPerToken = (inFrac * inputPerMillion + outFrac * outputPerMillion) / 1e6 +``` + +## Choosing comparison targets + +A break-even number is only meaningful against a *comparable* cloud model: a small +self-hosted coder model competes with a budget cloud model, not a flagship. Choose +the model(s) you would otherwise use, per CostProfile: + +```yaml +apiVersion: finops.infercost.ai/v1alpha1 +kind: CostProfile +spec: + cloudComparison: + targets: + - provider: Anthropic + model: claude-sonnet-4-6 + - provider: OpenAI + model: gpt-5.4-nano +``` + +When `cloudComparison` is omitted, InferCost defaults to one mid-tier model per +provider (`gpt-5.4-mini`, `claude-sonnet-4-6`, `gemini-2.5-flash`) so the analysis +works out of the box. Targets are validated against the bundled pricing catalog +(see [pricing-refresh.md](pricing-refresh.md)); unrecognized targets are skipped and +logged rather than producing a misleading number. + +## Surfaces + +- **CRD status:** `kubectl get usagereport -o yaml` (and the `Break-even %` + print column shows the first target). +- **REST API:** `GET /api/v1/break-even` returns the per-target entries. +- **Prometheus / Grafana:** `infercost_break_even_tokens_per_day` and + `infercost_percent_of_break_even` (labels `cost_profile`, `provider`, `cloud_model`) + drive the "Percent of Break-even" gauge in the bundled dashboard (green at >= 100%). diff --git a/docs/cost-model.md b/docs/cost-model.md index 92ae961..8e27828 100644 --- a/docs/cost-model.md +++ b/docs/cost-model.md @@ -16,9 +16,10 @@ one means, which question it answers, and when it misleads. | `marginalCostPerMillionTokens` | Electricity-only \$ / 1M tokens during active time | What each token costs in power, once you own the hardware | | `activeEnergyKWh` | Integrated kWh across active intervals | Numerator of the marginal calculation, exposed so operators can sanity-check | -Future fields (tracked in GitHub issues [#37, #39, #41](https://github.com/defilantech/infercost/issues?q=is%3Aissue+label%3Aarea%2Fcost-model)): +- `breakEvenAnalysis[]` | Per cloud target, the daily token volume at which on-prem cost equals the cloud cost, plus current throughput and a verdict | The decision metric: see [cloud-comparison.md](cloud-comparison.md) + +Future fields (tracked in GitHub issues [#37, #41](https://github.com/defilantech/infercost/issues?q=is%3Aissue+label%3Aarea%2Fcost-model)): -- Break-even tokens-per-day per cloud provider (#39) - Active-hour amortization (#37) — will replace the current wall-clock amortization denominator when enabled - Utilization-aware status message (#41) diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 0e722ff..ef9ae48 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -467,3 +467,68 @@ func TestServer_Status(t *testing.T) { t.Error("status response missing comparisons key") } } + +func TestStore_BreakEven(t *testing.T) { + s := NewStore() + in := []BreakEvenData{ + {Provider: "Anthropic", Model: "claude-sonnet-4-6", BreakEvenTokensPerDay: 100000, + CurrentUtilizationTokensPerDay: 6000, PercentOfBreakEven: 6.0, Verdict: "cloud-cheaper-at-current-utilization"}, + } + s.SetBreakEven(in) + got := s.GetBreakEven() + if len(got) != 1 { + t.Fatalf("expected 1 break-even entry, got %d", len(got)) + } + if got[0].Provider != "Anthropic" || got[0].BreakEvenTokensPerDay != 100000 { + t.Errorf("unexpected entry: %+v", got[0]) + } + // Mutating the returned copy must not affect the store. + got[0].Provider = "mutated" + if s.GetBreakEven()[0].Provider != "Anthropic" { + t.Error("GetBreakEven returned a non-copy; store was mutated") + } +} + +func TestServer_BreakEven_NoData(t *testing.T) { + store := NewStore() + server := NewServer(":0", store) + + req := httptest.NewRequest("GET", "/api/v1/break-even", nil) + w := httptest.NewRecorder() + server.httpServer.Handler.ServeHTTP(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Errorf("break-even with no data status = %d, want %d", w.Code, http.StatusServiceUnavailable) + } +} + +func TestServer_BreakEven_WithData(t *testing.T) { + store := NewStore() + store.SetBreakEven([]BreakEvenData{ + {Provider: "OpenAI", Model: "gpt-5.4-mini", BreakEvenTokensPerDay: 250000, + CurrentUtilizationTokensPerDay: 300000, PercentOfBreakEven: 120.0, + Verdict: "on-prem-cheaper-at-current-utilization"}, + }) + server := NewServer(":0", store) + + req := httptest.NewRequest("GET", "/api/v1/break-even", nil) + w := httptest.NewRecorder() + server.httpServer.Handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } + var body struct { + BreakEven []BreakEvenData `json:"breakEven"` + Count int `json:"count"` + } + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if body.Count != 1 || len(body.BreakEven) != 1 { + t.Fatalf("expected 1 entry, got count=%d len=%d", body.Count, len(body.BreakEven)) + } + if body.BreakEven[0].Verdict != "on-prem-cheaper-at-current-utilization" { + t.Errorf("verdict = %q", body.BreakEven[0].Verdict) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index 2dde923..51e8834 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -35,6 +35,7 @@ func NewServer(addr string, store *Store) *Server { mux.HandleFunc("GET /api/v1/costs/by-namespace", s.handleCostsByNamespace) mux.HandleFunc("GET /api/v1/models", s.handleModels) mux.HandleFunc("GET /api/v1/compare", s.handleCompare) + mux.HandleFunc("GET /api/v1/break-even", s.handleBreakEven) mux.HandleFunc("GET /api/v1/budgets", s.handleBudgets) mux.HandleFunc("GET /api/v1/status", s.handleStatus) mux.HandleFunc("GET /healthz", s.handleHealthz) @@ -73,6 +74,20 @@ func (s *Server) handleCostsCurrent(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, data) } +func (s *Server) handleBreakEven(w http.ResponseWriter, _ *http.Request) { + data := s.store.GetBreakEven() + if len(data) == 0 { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{ + "error": "no break-even data available yet — waiting for first reconcile", + }) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "breakEven": data, + "count": len(data), + }) +} + func (s *Server) handleCostsByNamespace(w http.ResponseWriter, r *http.Request) { costs := s.store.GetNamespaceCosts() diff --git a/internal/api/store.go b/internal/api/store.go index e32e06b..1ad28db 100644 --- a/internal/api/store.go +++ b/internal/api/store.go @@ -64,6 +64,16 @@ type ComparisonData struct { OutputPerMTok float64 `json:"outputPerMillionTokensUSD"` } +// BreakEvenData holds the break-even analysis for one cloud target. +type BreakEvenData struct { + Provider string `json:"provider"` + Model string `json:"model"` + BreakEvenTokensPerDay int64 `json:"breakEvenTokensPerDay"` + CurrentUtilizationTokensPerDay int64 `json:"currentUtilizationTokensPerDay"` + PercentOfBreakEven float64 `json:"percentOfBreakEven"` + Verdict string `json:"verdict"` +} + // BudgetData holds the latest budget state for a TokenBudget CR. type BudgetData struct { Name string `json:"name"` @@ -82,6 +92,7 @@ type Store struct { comparisons []ComparisonData namespaceCosts []NamespaceCostData budgets []BudgetData + breakEven []BreakEvenData } // NewStore creates an empty store. @@ -110,6 +121,22 @@ func (s *Store) SetComparisons(comparisons []ComparisonData) { s.comparisons = comparisons } +// SetBreakEven updates the per-target break-even analysis data. +func (s *Store) SetBreakEven(data []BreakEvenData) { + s.mu.Lock() + defer s.mu.Unlock() + s.breakEven = data +} + +// GetBreakEven returns the latest break-even analysis (a copy). +func (s *Store) GetBreakEven() []BreakEvenData { + s.mu.RLock() + defer s.mu.RUnlock() + result := make([]BreakEvenData, len(s.breakEven)) + copy(result, s.breakEven) + return result +} + // GetCostData returns the latest cost data. func (s *Store) GetCostData() *CostData { s.mu.RLock() diff --git a/internal/calculator/calculator.go b/internal/calculator/calculator.go index f9b9b39..b232fbf 100644 --- a/internal/calculator/calculator.go +++ b/internal/calculator/calculator.go @@ -189,3 +189,53 @@ func CompareToCloud(inputTokens, outputTokens int64, onPremCostUSD float64, pric } return results } + +// DailyHardwareCost returns the fixed daily cost of owning the hardware: the +// amortization charged for 24h plus the electricity the GPUs draw while idle +// for a full day. This is the numerator of the break-even calculation — the +// cost that must be covered by serving tokens before on-prem beats the cloud. +// pueFactor <= 0 is treated as 1.0. +func DailyHardwareCost(amortizationPerHour, idleWatts, ratePerKWh, pueFactor float64) float64 { + pue := pueFactor + if pue <= 0 { + pue = 1.0 + } + amortizationPerDay := amortizationPerHour * 24.0 + idleElectricityPerDay := (idleWatts / 1000.0) * 24.0 * ratePerKWh * pue + return amortizationPerDay + idleElectricityPerDay +} + +// CloudCostPerToken returns the blended USD-per-token rate for a cloud model, +// weighting input vs output prices by the actual input/output ratio of the +// workload. When no tokens have been observed it falls back to a 50/50 blend. +func CloudCostPerToken(p CloudPricing, inputTokens, outputTokens int64) float64 { + total := inputTokens + outputTokens + var inFrac, outFrac float64 + if total <= 0 { + inFrac, outFrac = 0.5, 0.5 + } else { + inFrac = float64(inputTokens) / float64(total) + outFrac = float64(outputTokens) / float64(total) + } + return (inFrac*p.InputPerMillion + outFrac*p.OutputPerMillion) / 1_000_000.0 +} + +// BreakEvenTokensPerDay returns the tokens/day at which the daily hardware cost +// equals the cloud cost for the same tokens — above this volume on-prem is +// cheaper. Returns 0 when the cloud rate is non-positive. +func BreakEvenTokensPerDay(dailyHardwareCost, cloudCostPerToken float64) float64 { + if cloudCostPerToken <= 0 { + return 0 + } + return dailyHardwareCost / cloudCostPerToken +} + +// PercentOfBreakEven returns how far current throughput is toward the break-even +// volume as a percentage (>= 100 means on-prem is at or past break-even). +// Returns 0 when break-even is non-positive. +func PercentOfBreakEven(currentTokensPerDay, breakEvenTokensPerDay float64) float64 { + if breakEvenTokensPerDay <= 0 { + return 0 + } + return currentTokensPerDay / breakEvenTokensPerDay * 100.0 +} diff --git a/internal/calculator/calculator_test.go b/internal/calculator/calculator_test.go index f1942c8..694b343 100644 --- a/internal/calculator/calculator_test.go +++ b/internal/calculator/calculator_test.go @@ -690,3 +690,66 @@ func TestActiveHoursCostPerMillionTokens(t *testing.T) { }) } } + +func TestDailyHardwareCost(t *testing.T) { + tests := []struct { + name string + amortizationPerHour float64 + idleWatts float64 + ratePerKWh float64 + pueFactor float64 + want float64 + }{ + {"amort + idle electricity", 1.0, 100, 0.10, 1.0, 24.0 + 0.24}, // 24 + 0.1kW*24h*0.10 + {"pue <= 0 treated as 1.0", 1.0, 100, 0.10, 0, 24.24}, + {"pue multiplier applies to idle", 1.0, 100, 0.10, 1.5, 24.0 + 0.36}, + {"zero idle watts is amortization only", 1.0, 0, 0.10, 1.0, 24.0}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := DailyHardwareCost(tc.amortizationPerHour, tc.idleWatts, tc.ratePerKWh, tc.pueFactor) + if !almostEqual(got, tc.want) { + t.Errorf("DailyHardwareCost() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestCloudCostPerToken(t *testing.T) { + p := CloudPricing{Provider: "anthropic", Model: "claude-sonnet-4-6", InputPerMillion: 3.0, OutputPerMillion: 15.0} + tests := []struct { + name string + in, out int64 + want float64 + }{ + {"50/50 actual ratio", 1_000_000, 1_000_000, 9.0 / 1e6}, // (0.5*3 + 0.5*15)/1e6 + {"no tokens falls back to 50/50", 0, 0, 9.0 / 1e6}, + {"75/25 ratio", 3_000_000, 1_000_000, 6.0 / 1e6}, // (0.75*3 + 0.25*15)/1e6 + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := CloudCostPerToken(p, tc.in, tc.out) + if !almostEqual(got, tc.want) { + t.Errorf("CloudCostPerToken() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestBreakEvenTokensPerDay(t *testing.T) { + if got := BreakEvenTokensPerDay(24.24, 9.0/1e6); !almostEqual(got, 24.24/(9.0/1e6)) { + t.Errorf("BreakEvenTokensPerDay() = %v, want %v", got, 24.24/(9.0/1e6)) + } + if got := BreakEvenTokensPerDay(24.0, 0); got != 0 { + t.Errorf("zero cloud rate should yield 0, got %v", got) + } +} + +func TestPercentOfBreakEven(t *testing.T) { + if got := PercentOfBreakEven(1_000_000, 2_000_000); !almostEqual(got, 50.0) { + t.Errorf("PercentOfBreakEven() = %v, want 50", got) + } + if got := PercentOfBreakEven(1_000_000, 0); got != 0 { + t.Errorf("zero break-even should yield 0, got %v", got) + } +} diff --git a/internal/controller/usagereport_controller.go b/internal/controller/usagereport_controller.go index 54c9a1d..f633509 100644 --- a/internal/controller/usagereport_controller.go +++ b/internal/controller/usagereport_controller.go @@ -19,7 +19,9 @@ package controller import ( "context" "fmt" + "math" "sort" + "strings" "time" corev1 "k8s.io/api/core/v1" @@ -35,6 +37,7 @@ import ( finopsv1alpha1 "github.com/defilantech/infercost/api/v1alpha1" internalapi "github.com/defilantech/infercost/internal/api" "github.com/defilantech/infercost/internal/calculator" + "github.com/defilantech/infercost/internal/metrics" "github.com/defilantech/infercost/internal/scraper" "github.com/defilantech/infercost/internal/utilization" ) @@ -152,6 +155,12 @@ func (r *UsageReportReconciler) Reconcile(ctx context.Context, req ctrl.Request) activeHoursCostPerMillion := calculator.ActiveHoursCostPerMillionTokens( profile.Status.HourlyCostUSD, activeHours, hoursInPeriod, totalTokens) + breakEvenAnalysis, unknownTargets := computeBreakEven(&profile, totalIn, totalOut, hoursInPeriod) + if len(unknownTargets) > 0 { + log.Info("skipping unknown cloud comparison targets (not in pricing catalog)", "targets", unknownTargets) + } + emitBreakEvenMetrics(profile.Name, breakEvenAnalysis) + periodStr := formatPeriod(report.Spec.Schedule, periodStart) computed := computedStatus{ period: periodStr, @@ -164,6 +173,7 @@ func (r *UsageReportReconciler) Reconcile(ctx context.Context, req ctrl.Request) marginalCostPerMillionTokens: marginalCostPerMillion, activeHoursCostPerMillionTokens: activeHoursCostPerMillion, activeEnergyKWh: activeEnergyKWh, + breakEvenAnalysis: breakEvenAnalysis, byModel: byModel, byNamespace: byNamespace, utilizationPercent: utilizationPercent, @@ -180,6 +190,7 @@ func (r *UsageReportReconciler) Reconcile(ctx context.Context, req ctrl.Request) if r.APIStore != nil { r.APIStore.SetNamespaceCosts(nsCostData) + r.APIStore.SetBreakEven(breakEvenToAPI(breakEvenAnalysis)) } log.Info("usage report computed", @@ -371,6 +382,94 @@ func buildBreakdowns( // computedStatus is the intermediate view of a single reconcile's output, // passed from the computation phase to the write phase so the Reconcile entry // point stays short enough to read top-to-bottom. +// defaultBreakEvenTargets is the zero-config comparison set: one mid-tier model +// per provider. Operators override via CostProfile.spec.cloudComparison.targets. +func defaultBreakEvenTargets() []finopsv1alpha1.CloudTarget { + return []finopsv1alpha1.CloudTarget{ + {Provider: "OpenAI", Model: "gpt-5.4-mini"}, + {Provider: "Anthropic", Model: "claude-sonnet-4-6"}, + {Provider: "Google", Model: "gemini-2.5-flash"}, + } +} + +// computeBreakEven builds the per-target break-even analysis. Targets come from +// the CostProfile (or the mid-tier default). Each target's cloud rate is looked +// up case-insensitively in the bundled pricing catalog; unrecognized targets are +// skipped and returned in `unknown` so the caller can surface them. +func computeBreakEven(profile *finopsv1alpha1.CostProfile, totalIn, totalOut int64, hoursInPeriod float64) (entries []finopsv1alpha1.BreakEvenEntry, unknown []string) { + targets := defaultBreakEvenTargets() + if profile.Spec.CloudComparison != nil && len(profile.Spec.CloudComparison.Targets) > 0 { + targets = profile.Spec.CloudComparison.Targets + } + + pricingByKey := make(map[string]calculator.CloudPricing) + for _, p := range calculator.DefaultCloudPricing() { + pricingByKey[strings.ToLower(p.Provider)+"/"+strings.ToLower(p.Model)] = p + } + + dailyHardwareCost := calculator.DailyHardwareCost( + profile.Status.AmortizationRatePerHour, + resolveIdleThreshold(profile), + profile.Spec.Electricity.RatePerKWh, + profile.Spec.Electricity.PUEFactor, + ) + + var currentTokensPerDay float64 + if hoursInPeriod > 0 { + currentTokensPerDay = (float64(totalIn+totalOut) / hoursInPeriod) * 24.0 + } + + for _, t := range targets { + p, ok := pricingByKey[strings.ToLower(t.Provider)+"/"+strings.ToLower(t.Model)] + if !ok { + unknown = append(unknown, t.Provider+"/"+t.Model) + continue + } + cpt := calculator.CloudCostPerToken(p, totalIn, totalOut) + breakEven := calculator.BreakEvenTokensPerDay(dailyHardwareCost, cpt) + percent := calculator.PercentOfBreakEven(currentTokensPerDay, breakEven) + + verdict := "cloud-cheaper-at-current-utilization" + if breakEven > 0 && currentTokensPerDay >= breakEven { + verdict = "on-prem-cheaper-at-current-utilization" + } + + entries = append(entries, finopsv1alpha1.BreakEvenEntry{ + Provider: p.Provider, + Model: p.Model, + BreakEvenTokensPerDay: int64(math.Round(breakEven)), + CurrentUtilizationTokensPerDay: int64(math.Round(currentTokensPerDay)), + PercentOfBreakEven: percent, + Verdict: verdict, + }) + } + return entries, unknown +} + +// breakEvenToAPI converts CRD break-even entries to the REST API store shape. +func breakEvenToAPI(entries []finopsv1alpha1.BreakEvenEntry) []internalapi.BreakEvenData { + out := make([]internalapi.BreakEvenData, 0, len(entries)) + for _, e := range entries { + out = append(out, internalapi.BreakEvenData{ + Provider: e.Provider, + Model: e.Model, + BreakEvenTokensPerDay: e.BreakEvenTokensPerDay, + CurrentUtilizationTokensPerDay: e.CurrentUtilizationTokensPerDay, + PercentOfBreakEven: e.PercentOfBreakEven, + Verdict: e.Verdict, + }) + } + return out +} + +// emitBreakEvenMetrics exports the per-target break-even gauges for Grafana. +func emitBreakEvenMetrics(costProfile string, entries []finopsv1alpha1.BreakEvenEntry) { + for _, e := range entries { + metrics.BreakEvenTokensPerDay.WithLabelValues(costProfile, e.Provider, e.Model).Set(float64(e.BreakEvenTokensPerDay)) + metrics.PercentOfBreakEven.WithLabelValues(costProfile, e.Provider, e.Model).Set(e.PercentOfBreakEven) + } +} + type computedStatus struct { period string periodStart time.Time @@ -382,6 +481,7 @@ type computedStatus struct { marginalCostPerMillionTokens float64 activeHoursCostPerMillionTokens float64 activeEnergyKWh float64 + breakEvenAnalysis []finopsv1alpha1.BreakEvenEntry byModel []finopsv1alpha1.ModelCostBreakdown byNamespace []finopsv1alpha1.NamespaceCostBreakdown // utilization-derived fields (all zero when no sampler is wired) @@ -415,6 +515,7 @@ func (r *UsageReportReconciler) applyStatusIfChanged(ctx context.Context, report report.Status.ActiveEnergyKWh = c.activeEnergyKWh report.Status.ByModel = c.byModel report.Status.ByNamespace = c.byNamespace + report.Status.BreakEvenAnalysis = c.breakEvenAnalysis report.Status.UtilizationPercent = c.utilizationPercent report.Status.GPUEfficiencyRatio = c.gpuEfficiencyRatio report.Status.ActiveHoursInPeriod = c.activeHoursInPeriod diff --git a/internal/controller/usagereport_controller_test.go b/internal/controller/usagereport_controller_test.go index dd6d0a3..c2c51cf 100644 --- a/internal/controller/usagereport_controller_test.go +++ b/internal/controller/usagereport_controller_test.go @@ -146,6 +146,16 @@ var _ = Describe("UsageReport Controller", func() { // Cost should be > 0 since hourlyCostUSD is 0.06 and hoursInPeriod > 0 Expect(updated.Status.EstimatedCostUSD).To(BeNumerically(">", 0)) + By("verifying break-even analysis is populated (default mid-tier targets)") + Expect(updated.Status.BreakEvenAnalysis).NotTo(BeEmpty()) + for _, be := range updated.Status.BreakEvenAnalysis { + Expect(be.Provider).NotTo(BeEmpty()) + Expect(be.Model).NotTo(BeEmpty()) + Expect(be.Verdict).NotTo(BeEmpty()) + } + By("verifying break-even was pushed to the REST API store") + Expect(apiStore.GetBreakEven()).To(HaveLen(len(updated.Status.BreakEvenAnalysis))) + By("verifying the Ready condition is set") Expect(updated.Status.Conditions).NotTo(BeEmpty()) var readyCondition *metav1.Condition diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index d83d6e1..06bcd18 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -155,6 +155,28 @@ var ( }, []string{"namespace", "budget_name"}, ) + + // BreakEvenTokensPerDay is the daily token volume at which on-prem cost + // equals the cloud model's cost (above it, on-prem is cheaper). + BreakEvenTokensPerDay = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: "infercost", + Name: "break_even_tokens_per_day", + Help: "Daily token volume at which on-prem cost equals the cloud model's cost.", + }, + []string{"cost_profile", "provider", "cloud_model"}, + ) + + // PercentOfBreakEven is how far current throughput is toward break-even + // (>= 100 means on-prem is at or past break-even). + PercentOfBreakEven = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: "infercost", + Name: "percent_of_break_even", + Help: "Current daily throughput as a percentage of the break-even volume (>=100 means on-prem wins).", + }, + []string{"cost_profile", "provider", "cloud_model"}, + ) ) func init() { @@ -174,5 +196,7 @@ func init() { BudgetLimitUSD, BudgetCurrentSpendUSD, BudgetUtilizationPercent, + BreakEvenTokensPerDay, + PercentOfBreakEven, ) }