From c3974e8064e00bfe680a38ee6f4ab2272a42146a Mon Sep 17 00:00:00 2001 From: Christopher Maher Date: Wed, 3 Jun 2026 23:37:13 -0700 Subject: [PATCH] feat(usagereport): utilization-aware status message framing Rewrites the UsageReport Ready-condition message (and adds a matching section to `infercost status`) so the headline number is never shown without context. A flat "Period: $0.0660 across 3,993 tokens" reads as "$16/MTok, worse than every cloud"; the new framing surfaces the amortized rate WITH utilization, the marginal rate, and the break-even comparison: Period 2026-04-23: $0.0660 across 3,993 tokens. Amortized: $16.52/MTok at 4% utilization. Marginal: $0.0040/MTok. Break-even with Anthropic/claude-opus-4-6: 142K tokens/day (you served 6K). - internal/report.StatusMessage is a pure, table-tested formatter shared by the controller and CLI so their framing stays identical. Marginal line is omitted when 0 (no energy sampled); break-even line omitted when no targets. - Builds on the break-even work (#39); the break-even line uses status.breakEvenAnalysis. Closes #41 Signed-off-by: Christopher Maher --- internal/controller/usagereport_controller.go | 3 +- .../controller/usagereport_controller_test.go | 4 + internal/report/message.go | 72 +++++++++++++++++ internal/report/message_test.go | 81 +++++++++++++++++++ pkg/cli/status.go | 24 ++++++ 5 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 internal/report/message.go create mode 100644 internal/report/message_test.go diff --git a/internal/controller/usagereport_controller.go b/internal/controller/usagereport_controller.go index f633509..00163a6 100644 --- a/internal/controller/usagereport_controller.go +++ b/internal/controller/usagereport_controller.go @@ -38,6 +38,7 @@ import ( internalapi "github.com/defilantech/infercost/internal/api" "github.com/defilantech/infercost/internal/calculator" "github.com/defilantech/infercost/internal/metrics" + reporting "github.com/defilantech/infercost/internal/report" "github.com/defilantech/infercost/internal/scraper" "github.com/defilantech/infercost/internal/utilization" ) @@ -526,7 +527,7 @@ func (r *UsageReportReconciler) applyStatusIfChanged(ctx context.Context, report Type: "Ready", Status: metav1.ConditionTrue, Reason: "ReportComputed", - Message: fmt.Sprintf("Period %s: $%.4f across %d tokens", c.period, c.totalCost, totalTokens), + Message: reporting.StatusMessage(report.Status), LastTransitionTime: metaNow, }) diff --git a/internal/controller/usagereport_controller_test.go b/internal/controller/usagereport_controller_test.go index c2c51cf..61ecc29 100644 --- a/internal/controller/usagereport_controller_test.go +++ b/internal/controller/usagereport_controller_test.go @@ -168,6 +168,10 @@ var _ = Describe("UsageReport Controller", func() { Expect(readyCondition).NotTo(BeNil()) Expect(readyCondition.Status).To(Equal(metav1.ConditionTrue)) Expect(readyCondition.Reason).To(Equal("ReportComputed")) + By("verifying the utilization-aware framing (issue #41)") + Expect(readyCondition.Message).To(ContainSubstring("Amortized:")) + Expect(readyCondition.Message).To(ContainSubstring("utilization")) + Expect(readyCondition.Message).To(ContainSubstring("Break-even with")) }) }) diff --git a/internal/report/message.go b/internal/report/message.go new file mode 100644 index 0000000..b65705c --- /dev/null +++ b/internal/report/message.go @@ -0,0 +1,72 @@ +// Package report renders human-facing summaries of UsageReport status, shared +// by the controller (status condition message) and the CLI so their framing +// stays identical. +package report + +import ( + "fmt" + "strings" + + finopsv1alpha1 "github.com/defilantech/infercost/api/v1alpha1" +) + +// StatusMessage renders the utilization-aware framing for a UsageReport: the +// raw period cost, the amortized $/MTok with its utilization context, the +// marginal $/MTok (when known), and the break-even comparison against the first +// configured cloud target. Surfacing utilization inline keeps the amortized +// number from reading as "worse than every cloud" at low utilization. +func StatusMessage(s finopsv1alpha1.UsageReportStatus) string { + totalTokens := s.InputTokens + s.OutputTokens + var b strings.Builder + + fmt.Fprintf(&b, "Period %s: $%.4f across %s tokens.", s.Period, s.EstimatedCostUSD, withCommas(totalTokens)) + fmt.Fprintf(&b, "\nAmortized: $%.2f/MTok at %.0f%% utilization.", s.CostPerMillionTokens, s.UtilizationPercent) + + // Marginal is omitted when zero: that means no active energy was sampled, so + // "$0.0000/MTok" would be misleading rather than honest. + if s.MarginalCostPerMillionTokens > 0 { + fmt.Fprintf(&b, "\nMarginal: $%.4f/MTok.", s.MarginalCostPerMillionTokens) + } + + if len(s.BreakEvenAnalysis) > 0 { + be := s.BreakEvenAnalysis[0] + fmt.Fprintf(&b, "\nBreak-even with %s/%s: %s tokens/day (you served %s).", + be.Provider, be.Model, + humanizeTokens(be.BreakEvenTokensPerDay), + humanizeTokens(be.CurrentUtilizationTokensPerDay)) + } + + return b.String() +} + +// humanizeTokens renders a token count compactly: 950, 6K, 142K, 1.2M. +func humanizeTokens(n int64) string { + switch { + case n >= 1_000_000: + return strings.TrimSuffix(fmt.Sprintf("%.1f", float64(n)/1_000_000), ".0") + "M" + case n >= 1_000: + return strings.TrimSuffix(fmt.Sprintf("%.1f", float64(n)/1_000), ".0") + "K" + default: + return fmt.Sprintf("%d", n) + } +} + +// withCommas formats an integer with thousands separators (3993 -> "3,993"). +func withCommas(n int64) string { + s := fmt.Sprintf("%d", n) + neg := strings.HasPrefix(s, "-") + if neg { + s = s[1:] + } + var out []byte + for i := 0; i < len(s); i++ { + if i > 0 && (len(s)-i)%3 == 0 { + out = append(out, ',') + } + out = append(out, s[i]) + } + if neg { + return "-" + string(out) + } + return string(out) +} diff --git a/internal/report/message_test.go b/internal/report/message_test.go new file mode 100644 index 0000000..d54edff --- /dev/null +++ b/internal/report/message_test.go @@ -0,0 +1,81 @@ +package report + +import ( + "strings" + "testing" + + finopsv1alpha1 "github.com/defilantech/infercost/api/v1alpha1" +) + +func fullStatus() finopsv1alpha1.UsageReportStatus { + return finopsv1alpha1.UsageReportStatus{ + Period: "2026-04-23", + EstimatedCostUSD: 0.0660, + InputTokens: 2000, + OutputTokens: 1993, + CostPerMillionTokens: 16.52, + MarginalCostPerMillionTokens: 0.004, + UtilizationPercent: 4.0, + BreakEvenAnalysis: []finopsv1alpha1.BreakEvenEntry{ + {Provider: "Anthropic", Model: "claude-opus-4-6", + BreakEvenTokensPerDay: 142000, CurrentUtilizationTokensPerDay: 6000, + PercentOfBreakEven: 4.2, Verdict: "cloud-cheaper-at-current-utilization"}, + }, + } +} + +func TestStatusMessage_FullFraming(t *testing.T) { + msg := StatusMessage(fullStatus()) + wants := []string{ + "Period 2026-04-23: $0.0660 across 3,993 tokens.", + "Amortized: $16.52/MTok at 4% utilization.", + "Marginal: $0.0040/MTok.", + "Break-even with Anthropic/claude-opus-4-6: 142K tokens/day (you served 6K).", + } + for _, w := range wants { + if !strings.Contains(msg, w) { + t.Errorf("message missing line %q\ngot:\n%s", w, msg) + } + } +} + +func TestStatusMessage_NoBreakEvenLineWhenEmpty(t *testing.T) { + s := fullStatus() + s.BreakEvenAnalysis = nil + msg := StatusMessage(s) + if strings.Contains(msg, "Break-even") { + t.Errorf("expected no break-even line when analysis is empty; got:\n%s", msg) + } +} + +func TestStatusMessage_NoMarginalLineWhenZero(t *testing.T) { + s := fullStatus() + s.MarginalCostPerMillionTokens = 0 + msg := StatusMessage(s) + if strings.Contains(msg, "Marginal:") { + t.Errorf("expected no marginal line when marginal is 0; got:\n%s", msg) + } +} + +func TestHumanizeTokens(t *testing.T) { + cases := map[int64]string{ + 950: "950", + 6000: "6K", + 142000: "142K", + 1200000: "1.2M", + } + for in, want := range cases { + if got := humanizeTokens(in); got != want { + t.Errorf("humanizeTokens(%d) = %q, want %q", in, got, want) + } + } +} + +func TestWithCommas(t *testing.T) { + if got := withCommas(3993); got != "3,993" { + t.Errorf("withCommas(3993) = %q, want 3,993", got) + } + if got := withCommas(1000000); got != "1,000,000" { + t.Errorf("withCommas(1000000) = %q, want 1,000,000", got) + } +} diff --git a/pkg/cli/status.go b/pkg/cli/status.go index f3d91cf..8e0a74e 100644 --- a/pkg/cli/status.go +++ b/pkg/cli/status.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "strings" "text/tabwriter" "time" @@ -14,6 +15,7 @@ import ( finopsv1alpha1 "github.com/defilantech/infercost/api/v1alpha1" "github.com/defilantech/infercost/internal/calculator" + reporting "github.com/defilantech/infercost/internal/report" "github.com/defilantech/infercost/internal/scraper" ) @@ -219,6 +221,28 @@ func runStatus(opts *statusOptions) error { _ = w.Flush() } + // Utilization-aware framing per UsageReport (issue #41): surfaces the + // amortized/marginal/break-even context inline so the headline cost isn't + // misread as "worse than cloud" at low utilization. + var reports finopsv1alpha1.UsageReportList + if err := k8sClient.List(ctx, &reports); err == nil && len(reports.Items) > 0 { + printed := false + for _, rep := range reports.Items { + if rep.Status.Period == "" { + continue // not computed yet + } + if !printed { + fmt.Println("\nUSAGE REPORTS") + fmt.Println("=============") + printed = true + } + fmt.Printf("\n%s:\n", rep.Name) + for line := range strings.SplitSeq(reporting.StatusMessage(rep.Status), "\n") { + fmt.Printf(" %s\n", line) + } + } + } + return nil }