diff --git a/README.md b/README.md
index 6f1ad97..bd5a78b 100644
--- a/README.md
+++ b/README.md
@@ -217,6 +217,49 @@ Results are automatically saved to a structured directory in the following forma
| `--compose-file`| | Custom docker-compose.yml; overrides the built-in default |
| `--keep-server` | `false` | Leave the vLLM server running after the benchmark |
| `--server-timeout`| `900` | Seconds to wait for the vLLM server to become ready |
+| `--cost` | `true` | Compute rent-vs-buy cost metrics and embed them in the result JSON |
+| `--system-bundle` | | Named system bundle to price the owned node as an all-in system (e.g. `PRU2500_8x5090`) |
+| `--pue` | *(config default)* | Power Usage Effectiveness for owned-side electricity |
+| `--avg-gpu-watts` | *(sampled)* | Override GPU power draw (W/GPU); `0` samples live via `nvidia-smi` |
+| `--pricing-file` | *(embedded)* | External `hardware_pricing.json` to override the embedded pricing config |
+
+---
+
+### Cost Metrics (Rent vs Buy)
+
+When `--cost` is enabled (default), each benchmark run is enriched with a `cost_metrics`
+block in `benchmark_result.json` and surfaced in the dashboard. It answers "is it cheaper
+to own the CoreSpan node or rent equivalent GPUs?" using the run's real duration, token
+counts, GPU count (TP×PP), and GPU power.
+
+**Owned cost is fully loaded** — amortized hardware CapEx **plus electricity** (PUE-adjusted,
+US industrial rate) — so it is a fair comparison against rental rates, which already bundle power.
+
+- **Power** is measured live during the run via `nvidia-smi`, scoped to the **GPUs in use**
+ (the busiest `TP×PP` GPUs by draw) so electricity stays consistent with the per-GPU CapEx.
+ If sampling is unavailable it falls back to the per-GPU default in the pricing config, or to
+ `--avg-gpu-watts`.
+- **System bundles** contribute a **per-GPU** all-in price scaled by the GPUs the run actually
+ used, so a 4-GPU run (`TP1×PP4`, the default compose) against the 8-GPU `PRU2500_8x5090` bundle
+ prices 4 GPUs — while a full 8-GPU run reproduces the whole-node bundle price exactly.
+- Output includes cost-per-1M-tokens (3:1 output/input weighting) and a rent-vs-buy ladder across
+ market tiers (spot → premium), each with a `buy`/`rent` verdict.
+
+**Supported GPUs** (auto-detected from `nvidia-smi`): RTX 5090, RTX 5070, Tesla T4, A100, H100 —
+each carries a full rent-vs-buy tier ladder (others fall back to `DEFAULT` pricing). The
+RTX 5090 + `PRU2500_8x5090` bundle reproduces the published
+[rent-or-buy blog](https://www.corespan.ai/resources/blog/rent-or-buy-rtx-5090-pru-2500) figures.
+
+```bash
+# Price the owned side as the all-in PRU-2500 + 8x RTX 5090 system
+ai-studio-cli bench --requests 500 --concurrency 32 --system-bundle PRU2500_8x5090
+
+# Use a fixed power figure and a custom pricing file instead of live sampling
+ai-studio-cli bench --avg-gpu-watts 550 --pricing-file ./hardware_pricing.json
+```
+
+> Cost dollars scale with the run window, so a short benchmark shows small amounts; the
+> cost-per-1M-tokens and the buy/rent verdict are the run-scoped signals to read.
---
@@ -234,8 +277,9 @@ ai-studio-cli bench-ui --result-dir /path/to/bench-results
```
The dashboard provides:
-- **KPI cards** — Total throughput, output throughput, TTFT, and TPOT at a glance
+- **KPI cards** — Total throughput, output throughput, TTFT, TPOT, and the rent-vs-buy verdict (at the market-median tier) at a glance
- **Performance trend chart** — Visualise metrics across runs with configurable Y-axis
+- **Cost analysis panel** — Owned (fully-loaded) cost, electricity, blended $/1M tokens, avg GPU power, and the full rent-vs-buy tier ladder with per-tier `buy`/`rent` verdicts
- **Model config bar** — Shows TP/PP, quantization, dtype, max model length, GPU memory utilization
- **Filterable table** — Filter by model, GPU, and precision; search across all runs
- **Sidebar navigation** — Quickly switch between benchmark runs
diff --git a/ai-studio-cli/cmd/bench.go b/ai-studio-cli/cmd/bench.go
index 5ad491b..898db28 100644
--- a/ai-studio-cli/cmd/bench.go
+++ b/ai-studio-cli/cmd/bench.go
@@ -12,6 +12,7 @@ import (
"strings"
"time"
+ "github.com/corespan/ai-studio-cli/internal/power"
"github.com/spf13/cobra"
"golang.org/x/term"
)
@@ -52,6 +53,17 @@ type benchRunner struct {
gpuTag string
openUI bool
uiPort int
+
+ // cost metrics
+ costEnabled bool
+ systemBundle string
+ pue float64
+ pricingFile string
+ avgGPUWattsOverride float64
+
+ // populated at runtime
+ gpuWattReadings []float64 // per-GPU draw sampled during the run
+ resolvedGPU string
}
var bench = &benchRunner{}
@@ -103,6 +115,12 @@ func (r *benchRunner) registerFlags(cmd *cobra.Command) {
// Dashboard
cmd.Flags().BoolVar(&r.openUI, "ui", true, "On an interactive terminal, launch the results dashboard when the run finishes and print a clickable link (blocks until Ctrl+C); auto-skipped for non-interactive/CI runs, or pass --ui=false")
cmd.Flags().IntVar(&r.uiPort, "ui-port", 9090, "Port for the dashboard launched by --ui")
+ // Cost metrics
+ cmd.Flags().BoolVar(&r.costEnabled, "cost", true, "Compute rent-vs-buy cost metrics and embed them in the result JSON")
+ cmd.Flags().StringVar(&r.systemBundle, "system-bundle", "", "Named system bundle from the pricing config (e.g. PRU2500_8x5090) to price the owned node as an all-in system")
+ cmd.Flags().Float64Var(&r.pue, "pue", 0, "Power Usage Effectiveness for owned-side electricity (0 = use pricing config default)")
+ cmd.Flags().StringVar(&r.pricingFile, "pricing-file", "", "Path to an external hardware_pricing.json to override the embedded pricing config")
+ cmd.Flags().Float64Var(&r.avgGPUWattsOverride, "avg-gpu-watts", 0, "Override GPU power draw (watts per GPU) for cost energy; 0 = sample live via nvidia-smi")
}
// entry point
@@ -155,6 +173,7 @@ func (r *benchRunner) runBenchmark(cmd *cobra.Command, composePath string) error
if gpu == "" {
gpu = detectGPUTag()
}
+ r.resolvedGPU = gpu
timestamp := time.Now().Format("2006-01-02T15-04-05")
structuredDir := filepath.Join(r.resultDir, sanitizePath(modelName), sanitizePath(gpu), timestamp)
@@ -174,8 +193,19 @@ func (r *benchRunner) runBenchmark(cmd *cobra.Command, composePath string) error
return fmt.Errorf("creating result directory: %w", err)
}
- if err := r.execute(cmd.Context(), args, structuredDir); err != nil {
- return err
+ // Sample GPU power during the run so the cost engine can charge realistic
+ // owned-side electricity (no telemetry stack exists otherwise).
+ var sampler *power.Sampler
+ if r.costEnabled && r.avgGPUWattsOverride <= 0 {
+ sampler = power.NewSampler(2 * time.Second)
+ sampler.Start()
+ }
+ runErr := r.execute(cmd.Context(), args, structuredDir)
+ if sampler != nil {
+ r.gpuWattReadings = sampler.Stop()
+ }
+ if runErr != nil {
+ return runErr
}
resultPath := filepath.Join(structuredDir, r.resultFilename)
diff --git a/ai-studio-cli/cmd/cost.go b/ai-studio-cli/cmd/cost.go
new file mode 100644
index 0000000..f488ca9
--- /dev/null
+++ b/ai-studio-cli/cmd/cost.go
@@ -0,0 +1,82 @@
+package cmd
+
+import (
+ "fmt"
+ "sort"
+
+ "github.com/corespan/ai-studio-cli/internal/cost"
+)
+
+// injectCostMetrics computes rent-vs-buy cost metrics from the benchmark result
+// and adds them under raw["cost_metrics"]. It reads token/duration figures from
+// the vLLM result, GPU count from the model config (TP*PP), and owned-side power
+// from the live sample (falling back to the pricing config's default wattage).
+func (r *benchRunner) injectCostMetrics(raw map[string]interface{}, cfg ModelConfig) error {
+ engine, err := cost.NewEngine(r.pricingFile)
+ if err != nil {
+ return fmt.Errorf("loading pricing config: %w", err)
+ }
+
+ duration := toFloat(raw["duration"])
+ inTok := toFloat(raw["total_input_tokens"])
+ outTok := toFloat(raw["total_output_tokens"])
+ if duration <= 0 || (inTok+outTok) <= 0 {
+ return fmt.Errorf("result missing duration/token fields; skipping cost metrics")
+ }
+
+ numGPUs := cfg.TensorParallel * cfg.PipelineParallel
+ if numGPUs < 1 {
+ numGPUs = 1
+ }
+
+ gpuName := r.resolvedGPU
+ if gpuName == "" {
+ gpuName = detectGPUTag()
+ }
+
+ // Per-GPU power precedence: explicit override > live sample of the GPUs in
+ // use > per-GPU default from the pricing config.
+ perGPUWatts := r.avgGPUWattsOverride
+ if perGPUWatts <= 0 {
+ perGPUWatts = avgWattsOfUsedGPUs(r.gpuWattReadings, numGPUs)
+ }
+ if perGPUWatts <= 0 {
+ perGPUWatts = engine.DefaultWatts(gpuName)
+ }
+
+ result := engine.CalculateLLM(cost.LLMInput{
+ GPUName: gpuName,
+ NumGPUs: numGPUs,
+ DurationSeconds: duration,
+ AvgPowerWatts: perGPUWatts,
+ TotalInputTokens: inTok,
+ TotalOutputTokens: outTok,
+ Infra: cost.InfraConfig{
+ SystemBundle: r.systemBundle,
+ PUE: r.pue,
+ },
+ })
+
+ raw["cost_metrics"] = result
+ return nil
+}
+
+// avgWattsOfUsedGPUs returns the mean per-GPU draw of the busiest `used` GPUs.
+// The GPUs doing the work draw the most power, so ranking by draw and taking the
+// top N approximates "the GPUs in use" without needing device-ID mapping — and
+// keeps electricity scoped to the same GPUs the CapEx is scoped to.
+func avgWattsOfUsedGPUs(readings []float64, used int) float64 {
+ if len(readings) == 0 || used <= 0 {
+ return 0
+ }
+ sorted := append([]float64(nil), readings...)
+ sort.Sort(sort.Reverse(sort.Float64Slice(sorted)))
+ if used > len(sorted) {
+ used = len(sorted)
+ }
+ var sum float64
+ for i := 0; i < used; i++ {
+ sum += sorted[i]
+ }
+ return sum / float64(used)
+}
diff --git a/ai-studio-cli/cmd/metadata.go b/ai-studio-cli/cmd/metadata.go
index 990f2a8..5e49465 100644
--- a/ai-studio-cli/cmd/metadata.go
+++ b/ai-studio-cli/cmd/metadata.go
@@ -32,6 +32,14 @@ func (r *benchRunner) patchResultJSON(resultPath, composePath string) error {
raw["total_token_throughput"] = outTP + inTP
}
+ if r.costEnabled {
+ if err := r.injectCostMetrics(raw, cfg); err != nil {
+ fmt.Printf(" [cost] warning: %v\n", err)
+ } else {
+ fmt.Println(" [cost] rent-vs-buy metrics added to result JSON")
+ }
+ }
+
out, err := json.MarshalIndent(raw, "", " ")
if err != nil {
return fmt.Errorf("marshalling patched result: %w", err)
diff --git a/ai-studio-cli/internal/benchui/ui/app.js b/ai-studio-cli/internal/benchui/ui/app.js
index 2f1d376..660a887 100644
--- a/ai-studio-cli/internal/benchui/ui/app.js
+++ b/ai-studio-cli/internal/benchui/ui/app.js
@@ -37,6 +37,7 @@
valOutputThroughput: $('#val-output-throughput'),
valTtft: $('#val-ttft'),
valTpot: $('#val-tpot'),
+ valVerdict: $('#val-verdict'),
};
// ----- API -----
@@ -233,6 +234,7 @@
state.selectedRun = await fetchRunDetail(model, gpu, timestamp, signal);
renderKPIs();
renderConfigBar();
+ renderCost();
} catch (err) {
if (err.name === 'AbortError') return;
console.error('Error loading run detail:', err);
@@ -248,6 +250,24 @@
dom.valOutputThroughput.textContent = fmtNum(r.output_throughput);
dom.valTtft.textContent = fmtNum(r.mean_ttft_ms);
dom.valTpot.textContent = fmtNum(r.mean_tpot_ms);
+ renderVerdictKPI();
+ }
+
+ // Rent-vs-buy verdict at the market-median tier, shown in the top KPI row.
+ function renderVerdictKPI() {
+ const r = state.selectedRun;
+ const cm = r && r.cost_metrics;
+ const ladder = cm && cm.rent_vs_buy_analysis;
+ const tiers = ladder && Array.isArray(ladder.tiers) ? ladder.tiers : [];
+ const median = tiers.find(t => t.tier === 'dedicated_median');
+ if (!median) {
+ dom.valVerdict.textContent = '—';
+ dom.valVerdict.style.color = '';
+ return;
+ }
+ const buy = median.verdict === 'buy';
+ dom.valVerdict.textContent = buy ? 'BUY' : 'RENT';
+ dom.valVerdict.style.color = buy ? 'hsl(142, 60%, 55%)' : 'hsl(38, 90%, 60%)';
}
// ----- Config Bar -----
@@ -278,6 +298,59 @@
dom.cfgGmem.textContent = mc.gpu_memory_utilization ? `mem ${mc.gpu_memory_utilization}` : '';
}
+ // ----- Cost Analysis -----
+ function renderCost() {
+ const r = state.selectedRun;
+ const cm = r && r.cost_metrics;
+ const sec = $('#cost-section');
+ if (!sec) return;
+
+ const ladder = cm && cm.rent_vs_buy_analysis;
+ if (!cm || !ladder || !Array.isArray(ladder.tiers) || ladder.tiers.length === 0) {
+ sec.classList.add('hidden');
+ return;
+ }
+ sec.classList.remove('hidden');
+
+ $('#cost-owned').textContent = fmtUSD(ladder.owned_fully_loaded_usd);
+ $('#cost-energy').textContent = fmtUSD(ladder.owned_energy_cost_usd);
+ $('#cost-blended').textContent = cm.corespan_infra
+ ? fmtUSD(cm.corespan_infra.blended_cost_per_1m_tokens_usd, 2)
+ : '—';
+ $('#cost-watts').textContent = fmtNum(cm.avg_gpu_power_watts);
+
+ $('#cost-ladder-body').innerHTML = ladder.tiers.map(t => {
+ const buy = t.verdict === 'buy';
+ const cls = buy ? 'verdict-buy' : 'verdict-rent';
+ const sign = Number(t.savings_usd_vs_buy) >= 0 ? '+' : '−';
+ const mag = fmtUSD(Math.abs(Number(t.savings_usd_vs_buy)));
+ return `
diff --git a/ai-studio-cli/internal/cost/engine.go b/ai-studio-cli/internal/cost/engine.go
new file mode 100644
index 0000000..7142ea6
--- /dev/null
+++ b/ai-studio-cli/internal/cost/engine.go
@@ -0,0 +1,334 @@
+// Package cost ports the AI-ML workbench CostEngine to Go for the aistudio-cli
+// vLLM benchmark flow. It computes LLM cost-per-1M-tokens (3:1 output weighting)
+// and a rent-vs-buy ladder that reproduces the CoreSpan RTX 5090 / PRU-2500 blog
+// findings. The owned side is "fully loaded" (amortized CapEx + electricity) so it
+// is a fair comparison against rental rates, which already bundle power.
+package cost
+
+import (
+ _ "embed"
+ "encoding/json"
+ "fmt"
+ "math"
+ "os"
+ "sort"
+ "strings"
+)
+
+//go:embed pricing.json
+var embeddedPricing []byte
+
+// ---- Config schema (mirrors hardware_pricing.json) ----
+
+type HWSpec struct {
+ CapexUSD float64 `json:"capex_usd"`
+ LifespanYears float64 `json:"lifespan_years"`
+ DefaultWatts float64 `json:"default_watts,omitempty"`
+}
+
+type BundleSpec struct {
+ Description string `json:"description"`
+ CapexUSD float64 `json:"capex_usd"`
+ LifespanYears float64 `json:"lifespan_years"`
+ NumGPUs int `json:"num_gpus"`
+}
+
+type Tier struct {
+ USDHr float64 `json:"usd_hr"`
+ Label string `json:"label"`
+}
+
+type MarketRate struct {
+ RentalHourlyUSD float64 `json:"rental_hourly_usd"`
+ HyperscalerHourlyUSD float64 `json:"hyperscaler_hourly_usd"`
+ RentalTiers map[string]Tier `json:"rental_tiers"`
+}
+
+type Config struct {
+ Meta struct {
+ LastUpdatedUTC string `json:"last_updated_utc"`
+ ElectricityKWhUSD float64 `json:"electricity_kwh_usd"`
+ PUE float64 `json:"pue"`
+ } `json:"meta"`
+ CorespanInventory struct {
+ GPUs map[string]HWSpec `json:"gpus"`
+ SystemBundles map[string]BundleSpec `json:"system_bundles"`
+ } `json:"corespan_inventory"`
+ MarketRates struct {
+ GPUs map[string]MarketRate `json:"gpus"`
+ } `json:"market_rates"`
+}
+
+// ---- Inputs / outputs ----
+
+type InfraConfig struct {
+ SystemBundle string
+ PUE float64 // 0 => use config default
+}
+
+type LLMInput struct {
+ GPUName string
+ NumGPUs int // GPUs the workload actually used (e.g. TP*PP)
+ DurationSeconds float64
+ AvgPowerWatts float64 // per-GPU draw for the GPUs in use (measured or estimated)
+ TotalInputTokens float64
+ TotalOutputTokens float64
+ Infra InfraConfig
+}
+
+type TokenCost struct {
+ Blended float64 `json:"blended_cost_per_1m_tokens_usd"`
+ Input float64 `json:"input_cost_per_1m_tokens_usd"`
+ Output float64 `json:"output_cost_per_1m_tokens_usd"`
+}
+
+type LadderTier struct {
+ Tier string `json:"tier"`
+ Label string `json:"label"`
+ RentalHourly float64 `json:"rental_hourly_usd"`
+ RentalTotal float64 `json:"rental_total_usd"`
+ SavingsVsBuy float64 `json:"savings_usd_vs_buy"`
+ Verdict string `json:"verdict"`
+}
+
+type Ladder struct {
+ OwnedCapexOnly float64 `json:"owned_capex_only_usd"`
+ OwnedEnergyCost float64 `json:"owned_energy_cost_usd"`
+ OwnedFullyLoaded float64 `json:"owned_fully_loaded_usd"`
+ EffectiveNumGPUs int `json:"effective_num_gpus"`
+ WindowHours float64 `json:"window_hours"`
+ Tiers []LadderTier `json:"tiers"`
+}
+
+type LLMResult struct {
+ GPUDetected string `json:"gpu_detected"`
+ PricingSourceDate string `json:"pricing_source_date"`
+ WorkloadDurationHours float64 `json:"workload_duration_hours"`
+ AvgGPUPowerWatts float64 `json:"avg_gpu_power_watts"`
+ OwnedCapexOnlyUSD float64 `json:"owned_capex_only_usd"`
+ OwnedEnergyCostUSD float64 `json:"owned_energy_cost_usd"`
+ OwnedFullyLoadedUSD float64 `json:"owned_fully_loaded_usd"`
+ BaselineSavingsUSD float64 `json:"baseline_savings_usd"`
+ CorespanInfra TokenCost `json:"corespan_infra"`
+ Rental TokenCost `json:"rental"`
+ Hyperscaler TokenCost `json:"hyperscaler"`
+ RentVsBuy Ladder `json:"rent_vs_buy_analysis"`
+}
+
+// ---- Engine ----
+
+type Engine struct {
+ cfg Config
+}
+
+// NewEngine loads the embedded pricing config, or an external file when path is non-empty.
+func NewEngine(externalPath string) (*Engine, error) {
+ raw := embeddedPricing
+ if externalPath != "" {
+ b, err := os.ReadFile(externalPath)
+ if err != nil {
+ return nil, fmt.Errorf("reading pricing file %q: %w", externalPath, err)
+ }
+ raw = b
+ }
+ var cfg Config
+ if err := json.Unmarshal(raw, &cfg); err != nil {
+ return nil, fmt.Errorf("parsing pricing config: %w", err)
+ }
+ if len(cfg.CorespanInventory.GPUs) == 0 {
+ return nil, fmt.Errorf("pricing config has no GPU inventory")
+ }
+ return &Engine{cfg: cfg}, nil
+}
+
+func (e *Engine) gpuKey(name string) string {
+ u := strings.ToUpper(name)
+ switch {
+ case strings.Contains(u, "A100"):
+ return "A100"
+ case strings.Contains(u, "H100"):
+ return "H100"
+ case strings.Contains(u, "5090"):
+ return "RTX 5090"
+ case strings.Contains(u, "5070"):
+ return "RTX 5070"
+ case strings.Contains(u, "T4"):
+ return "Tesla T4"
+ default:
+ return "DEFAULT"
+ }
+}
+
+func amortizeHourly(capex, lifespanYears float64) float64 {
+ if lifespanYears <= 0 {
+ return 0
+ }
+ return capex / (lifespanYears * 365 * 24)
+}
+
+// DefaultWatts returns the fallback per-GPU wattage for a GPU when live sampling
+// is unavailable.
+func (e *Engine) DefaultWatts(gpuName string) float64 {
+ if spec, ok := e.cfg.CorespanInventory.GPUs[e.gpuKey(gpuName)]; ok && spec.DefaultWatts > 0 {
+ return spec.DefaultWatts
+ }
+ if spec, ok := e.cfg.CorespanInventory.GPUs["DEFAULT"]; ok && spec.DefaultWatts > 0 {
+ return spec.DefaultWatts
+ }
+ return 400
+}
+
+func (e *Engine) resolvePUE(infra InfraConfig) float64 {
+ if infra.PUE > 0 {
+ return infra.PUE
+ }
+ if e.cfg.Meta.PUE > 0 {
+ return e.cfg.Meta.PUE
+ }
+ return 1.0
+}
+
+// ownedEnergyCost is charged only to the owned side (rental rates already bundle
+// power). It takes the *total* node watts, not per-GPU, so live measurements of a
+// partially-used node are billed exactly as drawn.
+func (e *Engine) ownedEnergyCost(totalWatts, hours float64, infra InfraConfig) float64 {
+ return (totalWatts / 1000.0) * e.resolvePUE(infra) * hours * e.cfg.Meta.ElectricityKWhUSD
+}
+
+// resolveCorespanCapex returns the amortized owned CapEx for the window and the
+// effective GPU count. A named system bundle contributes its *per-GPU* all-in
+// price, scaled by the GPUs the workload actually used — so a 4-GPU run against
+// an 8-GPU bundle prices 4 GPUs, while a full 8-GPU run reproduces the whole-node
+// bundle price exactly.
+func (e *Engine) resolveCorespanCapex(gpuKey string, numGPUs int, hours float64, infra InfraConfig) (float64, int) {
+ if numGPUs < 1 {
+ numGPUs = 1
+ }
+ if infra.SystemBundle != "" {
+ if b, ok := e.cfg.CorespanInventory.SystemBundles[infra.SystemBundle]; ok {
+ perGPU := b.CapexUSD
+ if b.NumGPUs > 0 {
+ perGPU = b.CapexUSD / float64(b.NumGPUs)
+ }
+ return amortizeHourly(perGPU, b.LifespanYears) * float64(numGPUs) * hours, numGPUs
+ }
+ }
+ gpu, ok := e.cfg.CorespanInventory.GPUs[gpuKey]
+ if !ok {
+ gpu = e.cfg.CorespanInventory.GPUs["DEFAULT"]
+ }
+ return amortizeHourly(gpu.CapexUSD, gpu.LifespanYears) * float64(numGPUs) * hours, numGPUs
+}
+
+func (e *Engine) marketRate(gpuKey string) MarketRate {
+ if m, ok := e.cfg.MarketRates.GPUs[gpuKey]; ok {
+ return m
+ }
+ return e.cfg.MarketRates.GPUs["DEFAULT"]
+}
+
+// coreLadder builds the rent-vs-buy ladder from already-resolved owned CapEx and
+// energy. Tiers are ordered ascending by rate for a stable, readable ladder.
+func (e *Engine) coreLadder(gpuKey string, eff int, hours, capex, energy float64) Ladder {
+ fullyLoaded := capex + energy
+ market := e.marketRate(gpuKey)
+
+ keys := make([]string, 0, len(market.RentalTiers))
+ for k := range market.RentalTiers {
+ keys = append(keys, k)
+ }
+ sort.Slice(keys, func(i, j int) bool {
+ return market.RentalTiers[keys[i]].USDHr < market.RentalTiers[keys[j]].USDHr
+ })
+
+ tiers := make([]LadderTier, 0, len(keys))
+ for _, k := range keys {
+ t := market.RentalTiers[k]
+ rentalTotal := t.USDHr * float64(eff) * hours
+ savings := rentalTotal - fullyLoaded
+ verdict := "rent"
+ if savings > 0 {
+ verdict = "buy"
+ }
+ tiers = append(tiers, LadderTier{
+ Tier: k,
+ Label: t.Label,
+ RentalHourly: t.USDHr,
+ RentalTotal: round(rentalTotal, 6),
+ SavingsVsBuy: round(savings, 6),
+ Verdict: verdict,
+ })
+ }
+
+ return Ladder{
+ OwnedCapexOnly: round(capex, 6),
+ OwnedEnergyCost: round(energy, 6),
+ OwnedFullyLoaded: round(fullyLoaded, 6),
+ EffectiveNumGPUs: eff,
+ WindowHours: round(hours, 4),
+ Tiers: tiers,
+ }
+}
+
+// RentVsBuyLadder is the per-GPU convenience entrypoint: pass average watts per
+// GPU and it charges avgWattsPerGPU * (GPUs used) of power.
+func (e *Engine) RentVsBuyLadder(gpuKey string, numGPUs int, hours, avgWattsPerGPU float64, infra InfraConfig) Ladder {
+ capex, eff := e.resolveCorespanCapex(gpuKey, numGPUs, hours, infra)
+ energy := e.ownedEnergyCost(avgWattsPerGPU*float64(eff), hours, infra)
+ return e.coreLadder(gpuKey, eff, hours, capex, energy)
+}
+
+// CalculateLLM computes token-cost economics plus the rent-vs-buy ladder for a run.
+func (e *Engine) CalculateLLM(in LLMInput) LLMResult {
+ gpuKey := e.gpuKey(in.GPUName)
+ hours := in.DurationSeconds / 3600.0
+
+ capex, eff := e.resolveCorespanCapex(gpuKey, in.NumGPUs, hours, in.Infra)
+
+ // Power is scoped to the GPUs in use — per-GPU draw × the used count — so it
+ // stays consistent with the per-GPU CapEx (idle GPUs are not billed here).
+ energy := e.ownedEnergyCost(in.AvgPowerWatts*float64(eff), hours, in.Infra)
+ corespanTotal := capex + energy
+
+ market := e.marketRate(gpuKey)
+ rentalTotal := market.RentalHourlyUSD * float64(eff) * hours
+ hyperTotal := market.HyperscalerHourlyUSD * float64(eff) * hours
+
+ totalTokens := in.TotalInputTokens + in.TotalOutputTokens
+ weighted := func(totalCost float64) TokenCost {
+ if totalTokens <= 0 {
+ return TokenCost{}
+ }
+ blended := totalCost / totalTokens * 1e6
+ wd := in.TotalInputTokens + 3*in.TotalOutputTokens // 3:1 output-to-input weighting
+ if wd <= 0 {
+ return TokenCost{Blended: round(blended, 4)}
+ }
+ baseInput := totalCost / wd
+ return TokenCost{
+ Blended: round(blended, 4),
+ Input: round(baseInput*1e6, 4),
+ Output: round(baseInput*3*1e6, 4),
+ }
+ }
+
+ return LLMResult{
+ GPUDetected: in.GPUName,
+ PricingSourceDate: e.cfg.Meta.LastUpdatedUTC,
+ WorkloadDurationHours: round(hours, 6),
+ AvgGPUPowerWatts: round(in.AvgPowerWatts, 2),
+ OwnedCapexOnlyUSD: round(capex, 6),
+ OwnedEnergyCostUSD: round(energy, 6),
+ OwnedFullyLoadedUSD: round(corespanTotal, 6),
+ BaselineSavingsUSD: round(hyperTotal-corespanTotal, 6),
+ CorespanInfra: weighted(corespanTotal),
+ Rental: weighted(rentalTotal),
+ Hyperscaler: weighted(hyperTotal),
+ RentVsBuy: e.coreLadder(gpuKey, eff, hours, capex, energy),
+ }
+}
+
+func round(v float64, places int) float64 {
+ p := math.Pow(10, float64(places))
+ return math.Round(v*p) / p
+}
diff --git a/ai-studio-cli/internal/cost/engine_test.go b/ai-studio-cli/internal/cost/engine_test.go
new file mode 100644
index 0000000..c5c989a
--- /dev/null
+++ b/ai-studio-cli/internal/cost/engine_test.go
@@ -0,0 +1,176 @@
+package cost
+
+import (
+ "math"
+ "testing"
+)
+
+// Golden test: the Go cost engine must reproduce the published rent-vs-buy blog
+// findings. Blog: https://www.corespan.ai/resources/blog/rent-or-buy-rtx-5090-pru-2500
+// Scenario: PRU 2500 + 8x RTX 5090, 24/7 over 3 years (210,240 GPU-hours), $99,999 all-in.
+
+const (
+ years = 3.0
+ windowHours = years * 365 * 24 // 26,280
+ avgWatts = 550.0 // ~96% of the 575W TDP under sustained inference
+ bundleCapex = 99999.0
+ bundle = "PRU2500_8x5090"
+)
+
+// Published savings (positive = buying wins), CapEx-only basis, to the dollar.
+var blogSavingsCapexOnly = map[string]float64{
+ "spot": -43234, // rent wins
+ "reserved": -18005, // rent wins
+ "dedicated_median": 36657, // buy wins
+ "dedicated_high": 85012, // buy wins
+ "premium_secure": 108139, // buy wins
+}
+
+func testEngine(t *testing.T) *Engine {
+ t.Helper()
+ e, err := NewEngine("")
+ if err != nil {
+ t.Fatalf("NewEngine: %v", err)
+ }
+ return e
+}
+
+func testLadder(t *testing.T) Ladder {
+ t.Helper()
+ e := testEngine(t)
+ if got := e.gpuKey("NVIDIA GeForce RTX 5090"); got != "RTX 5090" {
+ t.Fatalf("gpuKey = %q, want RTX 5090", got)
+ }
+ return e.RentVsBuyLadder("RTX 5090", 8, windowHours, avgWatts, InfraConfig{SystemBundle: bundle})
+}
+
+func tierByKey(l Ladder) map[string]LadderTier {
+ m := make(map[string]LadderTier, len(l.Tiers))
+ for _, t := range l.Tiers {
+ m[t.Tier] = t
+ }
+ return m
+}
+
+func TestBundleCapexAndGPUCount(t *testing.T) {
+ l := testLadder(t)
+ if l.EffectiveNumGPUs != 8 {
+ t.Errorf("effective GPUs = %d, want 8", l.EffectiveNumGPUs)
+ }
+ if math.Abs(l.OwnedCapexOnly-bundleCapex) > 0.01 {
+ t.Errorf("owned capex = %v, want %v", l.OwnedCapexOnly, bundleCapex)
+ }
+ perGPUHr := bundleCapex / (8 * windowHours)
+ if math.Abs(perGPUHr-0.4756) > 0.001 {
+ t.Errorf("per-GPU-hr = %v, want ~0.4756", perGPUHr)
+ }
+}
+
+func TestCapexOnlyReproducesBlogToTheDollar(t *testing.T) {
+ l := testLadder(t)
+ byTier := tierByKey(l)
+ for tier, want := range blogSavingsCapexOnly {
+ lt, ok := byTier[tier]
+ if !ok {
+ t.Errorf("tier %q missing from ladder", tier)
+ continue
+ }
+ capexOnlySavings := lt.RentalTotal - bundleCapex
+ if math.Abs(capexOnlySavings-want) > 1.0 {
+ t.Errorf("%s: capex-only savings = %.2f, want ~%.0f", tier, capexOnlySavings, want)
+ }
+ }
+}
+
+func TestOwnedElectricityIsAdded(t *testing.T) {
+ l := testLadder(t)
+ // 550W x8, PUE 1.15, $0.087/kWh over the window.
+ wantEnergy := (avgWatts * 8 / 1000.0) * 1.15 * windowHours * 0.087
+ if math.Abs(l.OwnedEnergyCost-wantEnergy) > 1.0 {
+ t.Errorf("owned energy = %v, want ~%v", l.OwnedEnergyCost, wantEnergy)
+ }
+ if math.Abs(l.OwnedFullyLoaded-(bundleCapex+wantEnergy)) > 1.0 {
+ t.Errorf("fully-loaded = %v, want ~%v", l.OwnedFullyLoaded, bundleCapex+wantEnergy)
+ }
+}
+
+func TestFullyLoadedMedianSavings(t *testing.T) {
+ l := testLadder(t)
+ median := tierByKey(l)["dedicated_median"]
+ if median.Verdict != "buy" {
+ t.Errorf("median verdict = %q, want buy", median.Verdict)
+ }
+ // Blog's $36,657 minus ~$11,569 electricity ≈ $25,088.
+ if math.Abs(median.SavingsVsBuy-25088) > 5 {
+ t.Errorf("median fully-loaded savings = %v, want ~25088", median.SavingsVsBuy)
+ }
+}
+
+func TestConclusionHoldsBuyWinsAtProductionTiers(t *testing.T) {
+ byTier := tierByKey(testLadder(t))
+ want := map[string]string{
+ "spot": "rent",
+ "reserved": "rent",
+ "dedicated_median": "buy",
+ "dedicated_high": "buy",
+ "premium_secure": "buy",
+ }
+ for tier, verdict := range want {
+ if got := byTier[tier].Verdict; got != verdict {
+ t.Errorf("%s verdict = %q, want %q", tier, got, verdict)
+ }
+ }
+}
+
+func TestTiersSortedAscending(t *testing.T) {
+ l := testLadder(t)
+ for i := 1; i < len(l.Tiers); i++ {
+ if l.Tiers[i].RentalHourly < l.Tiers[i-1].RentalHourly {
+ t.Errorf("tiers not ascending at %d: %v < %v", i, l.Tiers[i].RentalHourly, l.Tiers[i-1].RentalHourly)
+ }
+ }
+}
+
+// Fix #1: a run that uses fewer GPUs than the bundle (e.g. the default compose's
+// TP1xPP4 = 4 GPUs against the 8-GPU bundle) must price the GPUs actually used —
+// the bundle contributes a per-GPU rate, not the whole-node count.
+func TestSubNodeBundleScalesByGPUsUsed(t *testing.T) {
+ e := testEngine(t)
+ l := e.RentVsBuyLadder("RTX 5090", 4, windowHours, avgWatts, InfraConfig{SystemBundle: bundle})
+ if l.EffectiveNumGPUs != 4 {
+ t.Errorf("effective GPUs = %d, want 4 (must not inherit the bundle's 8)", l.EffectiveNumGPUs)
+ }
+ // 4 of 8 GPUs => half the all-in node CapEx.
+ wantCapex := bundleCapex / 2.0
+ if math.Abs(l.OwnedCapexOnly-wantCapex) > 0.01 {
+ t.Errorf("owned capex = %v, want %v", l.OwnedCapexOnly, wantCapex)
+ }
+ // Electricity should scale to 4 GPUs, not 8.
+ wantEnergy := (avgWatts * 4 / 1000.0) * 1.15 * windowHours * 0.087
+ if math.Abs(l.OwnedEnergyCost-wantEnergy) > 1.0 {
+ t.Errorf("owned energy = %v, want ~%v", l.OwnedEnergyCost, wantEnergy)
+ }
+}
+
+func TestCalculateLLMTokenCosts(t *testing.T) {
+ e := testEngine(t)
+ res := e.CalculateLLM(LLMInput{
+ GPUName: "NVIDIA GeForce RTX 5090",
+ NumGPUs: 8,
+ DurationSeconds: windowHours * 3600,
+ AvgPowerWatts: avgWatts,
+ TotalInputTokens: 1_000_000,
+ TotalOutputTokens: 1_000_000,
+ Infra: InfraConfig{SystemBundle: bundle},
+ })
+ if res.RentVsBuy.EffectiveNumGPUs != 8 {
+ t.Errorf("effective GPUs = %d, want 8", res.RentVsBuy.EffectiveNumGPUs)
+ }
+ // Output tokens are weighted 3x input.
+ if res.CorespanInfra.Output <= res.CorespanInfra.Input {
+ t.Errorf("output cost %.4f should exceed input cost %.4f (3:1 weighting)", res.CorespanInfra.Output, res.CorespanInfra.Input)
+ }
+ if res.CorespanInfra.Blended <= 0 {
+ t.Errorf("blended cost should be positive, got %v", res.CorespanInfra.Blended)
+ }
+}
diff --git a/ai-studio-cli/internal/cost/pricing.json b/ai-studio-cli/internal/cost/pricing.json
new file mode 100644
index 0000000..d9e48ca
--- /dev/null
+++ b/ai-studio-cli/internal/cost/pricing.json
@@ -0,0 +1,89 @@
+{
+ "meta": {
+ "last_updated_utc": "2026-07-17T00:00:00",
+ "electricity_kwh_usd": 0.087,
+ "pue": 1.15
+ },
+ "corespan_inventory": {
+ "gpus": {
+ "Tesla T4": { "capex_usd": 650, "lifespan_years": 4, "default_watts": 70 },
+ "A100": { "capex_usd": 15000, "lifespan_years": 4, "default_watts": 400 },
+ "H100": { "capex_usd": 30000, "lifespan_years": 4, "default_watts": 700 },
+ "RTX 5070": { "capex_usd": 599, "lifespan_years": 3, "default_watts": 250 },
+ "RTX 5090": { "capex_usd": 12499, "lifespan_years": 3, "default_watts": 550 },
+ "DEFAULT": { "capex_usd": 5000, "lifespan_years": 4, "default_watts": 400 }
+ },
+ "system_bundles": {
+ "PRU2500_8x5090": {
+ "description": "PRU 2500 + 8x RTX 5090, direct liquid cooling included. All-in list price amortized over 3 years.",
+ "capex_usd": 99999,
+ "lifespan_years": 3,
+ "num_gpus": 8
+ }
+ }
+ },
+ "market_rates": {
+ "gpus": {
+ "Tesla T4": {
+ "rental_hourly_usd": 0.13,
+ "hyperscaler_hourly_usd": 0.526,
+ "rental_tiers": {
+ "spot": { "usd_hr": 0.08, "label": "Spot (Vast.ai low)" },
+ "peer": { "usd_hr": 0.13, "label": "Peer marketplace (Vast.ai)" },
+ "reserved": { "usd_hr": 0.15, "label": "Reserved / spot (AWS g4dn)" },
+ "dedicated_median": { "usd_hr": 0.35, "label": "Dedicated datacenter median" },
+ "premium_secure": { "usd_hr": 0.526, "label": "Hyperscaler on-demand (AWS g4dn / Azure NC4as T4 v3)" }
+ }
+ },
+ "A100": {
+ "rental_hourly_usd": 0.72,
+ "hyperscaler_hourly_usd": 3.67,
+ "rental_tiers": {
+ "spot": { "usd_hr": 0.6, "label": "Spot (Spheron)" },
+ "peer": { "usd_hr": 0.72, "label": "Peer marketplace (Vast.ai)" },
+ "reserved": { "usd_hr": 1.07, "label": "On-demand community (Spheron)" },
+ "dedicated_median": { "usd_hr": 1.64, "label": "Dedicated on-demand (RunPod)" },
+ "premium_secure": { "usd_hr": 2.06, "label": "Premium SLA (Lambda)" }
+ }
+ },
+ "H100": {
+ "rental_hourly_usd": 1.99,
+ "hyperscaler_hourly_usd": 6.98,
+ "rental_tiers": {
+ "spot": { "usd_hr": 1.55, "label": "Spot (Vast.ai)" },
+ "peer": { "usd_hr": 1.99, "label": "Community pool (RunPod)" },
+ "reserved": { "usd_hr": 2.2, "label": "Reserved / committed" },
+ "dedicated_median": { "usd_hr": 2.99, "label": "Dedicated on-demand median" },
+ "premium_secure": { "usd_hr": 3.99, "label": "Premium secure on-demand" }
+ }
+ },
+ "RTX 5070": {
+ "rental_hourly_usd": 0.09,
+ "hyperscaler_hourly_usd": 0.67,
+ "rental_tiers": {
+ "spot": { "usd_hr": 0.06, "label": "Spot (Vast.ai floor)" },
+ "peer": { "usd_hr": 0.09, "label": "Peer marketplace (Vast.ai)" },
+ "reserved": { "usd_hr": 0.12, "label": "Reserved / committed" },
+ "dedicated_median": { "usd_hr": 0.27, "label": "On-demand median (Vast.ai high)" },
+ "premium_secure": { "usd_hr": 0.67, "label": "Premium / dedicated high" }
+ }
+ },
+ "RTX 5090": {
+ "rental_hourly_usd": 0.36,
+ "hyperscaler_hourly_usd": 0.99,
+ "rental_tiers": {
+ "spot": { "usd_hr": 0.27, "label": "Spot / Batch (Salad)" },
+ "peer": { "usd_hr": 0.36, "label": "Peer marketplace (Vast.ai on-demand median)" },
+ "reserved": { "usd_hr": 0.39, "label": "Reserved / committed (Runcrate)" },
+ "dedicated_median": { "usd_hr": 0.65, "label": "Dedicated datacenter median" },
+ "dedicated_high": { "usd_hr": 0.88, "label": "On-demand high (Vast.ai)" },
+ "premium_secure": { "usd_hr": 0.99, "label": "Premium secure (RunPod Secure)" }
+ }
+ },
+ "DEFAULT": {
+ "rental_hourly_usd": 1.25,
+ "hyperscaler_hourly_usd": 3.0
+ }
+ }
+ }
+}
diff --git a/ai-studio-cli/internal/power/sampler.go b/ai-studio-cli/internal/power/sampler.go
new file mode 100644
index 0000000..21f847b
--- /dev/null
+++ b/ai-studio-cli/internal/power/sampler.go
@@ -0,0 +1,116 @@
+// Package power samples GPU power draw via nvidia-smi during a benchmark run so
+// the cost engine can charge realistic owned-side electricity. aistudio-cli has
+// no Prometheus/telemetry stack, so this lightweight poller stands in for it.
+//
+// It records per-GPU draw so the caller can bill only the GPUs actually in use
+// (see cost integration), keeping electricity consistent with the per-GPU CapEx.
+package power
+
+import (
+ "os/exec"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+// Sampler periodically records per-GPU power draw across visible GPUs.
+type Sampler struct {
+ interval time.Duration
+ stopCh chan struct{}
+ doneCh chan struct{}
+
+ mu sync.Mutex
+ sums []float64 // running sum of watts per GPU index
+ counts []int // samples counted per GPU index
+}
+
+// NewSampler returns a sampler that polls every interval (default 2s).
+func NewSampler(interval time.Duration) *Sampler {
+ if interval <= 0 {
+ interval = 2 * time.Second
+ }
+ return &Sampler{
+ interval: interval,
+ stopCh: make(chan struct{}),
+ doneCh: make(chan struct{}),
+ }
+}
+
+// Start begins sampling in the background. Call Stop exactly once afterwards.
+func (s *Sampler) Start() {
+ go func() {
+ defer close(s.doneCh)
+ s.sampleOnce() // immediate first sample
+ ticker := time.NewTicker(s.interval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-s.stopCh:
+ return
+ case <-ticker.C:
+ s.sampleOnce()
+ }
+ }
+ }()
+}
+
+func (s *Sampler) sampleOnce() {
+ watts, ok := queryPerGPUWatts()
+ if !ok {
+ return
+ }
+ s.mu.Lock()
+ if s.sums == nil {
+ s.sums = make([]float64, len(watts))
+ s.counts = make([]int, len(watts))
+ }
+ for i, w := range watts {
+ if i < len(s.sums) {
+ s.sums[i] += w
+ s.counts[i]++
+ }
+ }
+ s.mu.Unlock()
+}
+
+// Stop halts sampling and returns the time-averaged watts for each visible GPU
+// (one entry per GPU index). Empty if nothing could be sampled — e.g.
+// nvidia-smi absent. The caller decides which GPUs count as "in use".
+func (s *Sampler) Stop() []float64 {
+ close(s.stopCh)
+ <-s.doneCh
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ out := make([]float64, 0, len(s.sums))
+ for i := range s.sums {
+ if s.counts[i] > 0 {
+ out = append(out, s.sums[i]/float64(s.counts[i]))
+ }
+ }
+ return out
+}
+
+// queryPerGPUWatts returns one power.draw reading per visible GPU at one instant.
+func queryPerGPUWatts() ([]float64, bool) {
+ out, err := exec.Command("nvidia-smi", "--query-gpu=power.draw", "--format=csv,noheader,nounits").Output()
+ if err != nil {
+ return nil, false
+ }
+ var watts []float64
+ for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+ v, err := strconv.ParseFloat(line, 64)
+ if err != nil {
+ continue
+ }
+ watts = append(watts, v)
+ }
+ if len(watts) == 0 {
+ return nil, false
+ }
+ return watts, true
+}