瀏覽代碼

AI Inference: usage cost basis bug fix (#3901)

simanadler 2 月之前
父節點
當前提交
a2b55e81b7
共有 3 個文件被更改,包括 660 次插入9 次删除
  1. 2 6
      pkg/costmodel/allocation_helpers.go
  2. 56 2
      pkg/inferencecost/collector.go
  3. 602 1
      pkg/inferencecost/collector_test.go

+ 2 - 6
pkg/costmodel/allocation_helpers.go

@@ -924,14 +924,10 @@ func applyGPUsAllocated(podMap map[podKey]*pod, resGPUsRequested []*source.GPUsR
 			}
 
 			hrs := thisPod.Allocations[container].Minutes() / 60.0
+			// GPUHours reflects the full reserved GPU allocation (request × hours).
+			// For usage-based cost accounting, apply GPUUsageAverage separately.
 			thisPod.Allocations[container].GPUHours = res.Data[0].Value * hrs
 
-			// For now, it will always be the case that Request==Allocation. If
-			// you would like to use a GPU you need to request the full GPU.
-			// Therefore max(usage,request) will always equal request. In the
-			// future this may need to be refactored when building support for
-			// GPU Time Slicing.
-
 			if thisPod.Allocations[container].GPUAllocation == nil {
 				thisPod.Allocations[container].GPUAllocation = &opencost.GPUAllocation{
 					GPURequestAverage: &res.Data[0].Value,

+ 56 - 2
pkg/inferencecost/collector.go

@@ -293,8 +293,62 @@ func (c *Collector) extractAllocationResults(as *opencost.AllocationSet, isAlloc
 			// For allocation cost: use TotalCost() which includes idle and shared
 			existing.allocationTotalCost += alloc.TotalCost()
 		} else {
-			// For usage cost: use TotalCost() from the ShareNone query (no idle)
-			existing.usageTotalCost += alloc.TotalCost()
+			// For usage cost: use TotalCost() from the ShareNone query (no idle),
+			// then scale GPU, CPU, and RAM by their actual utilisation when available.
+			// This ensures costBasis=usage reflects actual resource consumption rather
+			// than the full reservation cost.
+			//
+			// Resources intentionally left unscaled:
+			//   Network  — already billed by actual bytes transferred, no reservation to remove.
+			//   PV       — billed by provisioned capacity; no IO utilisation metric available.
+			//   LB       — billed per hour of existence; no per-request utilisation signal.
+			//   External — opaque cloud billing pass-through; no usage signal attached.
+			cost := alloc.TotalCost()
+
+			// GPU: scale by SM duty cycle (GPUUsageAverage ∈ [0,1]).
+			// GPUHours (and therefore GPUCost) always reflects the full reservation;
+			// GPUUsageAverage is the fraction of time the GPU cores were active.
+			// Any non-nil value is clamped to [0,1] so that zero utilisation
+			// correctly reduces cost to $0 and out-of-range values are handled
+			// deterministically rather than silently ignored.
+			if alloc.GPUAllocation != nil && alloc.GPUAllocation.GPUUsageAverage != nil {
+				util := *alloc.GPUAllocation.GPUUsageAverage
+				if util < 0 {
+					util = 0
+				} else if util > 1 {
+					util = 1
+				}
+				scaledGPUCost := alloc.GPUTotalCost() * util
+				cost = cost - alloc.GPUTotalCost() + scaledGPUCost
+				log.Debugf("InferenceCost usage: GPU scaled model=%s ns=%s orig=$%.4f scaled=$%.4f util=%.1f%%",
+					modelName, namespace, alloc.GPUTotalCost(), scaledGPUCost, util*100)
+			}
+
+			// CPU: scale by core utilisation ratio (usage / request).
+			// CPUCoreRequestAverage and CPUCoreUsageAverage are plain float64 (not pointers).
+			if alloc.CPUCoreRequestAverage > 0 &&
+				alloc.CPUCoreUsageAverage > 0 &&
+				alloc.CPUCoreUsageAverage < alloc.CPUCoreRequestAverage {
+				cpuUtil := alloc.CPUCoreUsageAverage / alloc.CPUCoreRequestAverage
+				scaledCPUCost := alloc.CPUTotalCost() * cpuUtil
+				cost = cost - alloc.CPUTotalCost() + scaledCPUCost
+				log.Debugf("InferenceCost usage: CPU scaled model=%s ns=%s orig=$%.4f scaled=$%.4f util=%.1f%%",
+					modelName, namespace, alloc.CPUTotalCost(), scaledCPUCost, cpuUtil*100)
+			}
+
+			// RAM: scale by byte utilisation ratio (usage / request).
+			// RAMBytesRequestAverage and RAMBytesUsageAverage are plain float64 (not pointers).
+			if alloc.RAMBytesRequestAverage > 0 &&
+				alloc.RAMBytesUsageAverage > 0 &&
+				alloc.RAMBytesUsageAverage < alloc.RAMBytesRequestAverage {
+				ramUtil := alloc.RAMBytesUsageAverage / alloc.RAMBytesRequestAverage
+				scaledRAMCost := alloc.RAMTotalCost() * ramUtil
+				cost = cost - alloc.RAMTotalCost() + scaledRAMCost
+				log.Debugf("InferenceCost usage: RAM scaled model=%s ns=%s orig=$%.4f scaled=$%.4f util=%.1f%%",
+					modelName, namespace, alloc.RAMTotalCost(), scaledRAMCost, ramUtil*100)
+			}
+
+			existing.usageTotalCost += cost
 		}
 
 		// When aggregating multiple allocations, preserve the first non-empty values

+ 602 - 1
pkg/inferencecost/collector_test.go

@@ -185,7 +185,14 @@ func TestCollector_ExtractAllocationResults(t *testing.T) {
 }
 
 // TestCollector_UsageCost_ExcludesIdle verifies the mathematical relationship
-// between allocation and usage costs when idle is present.
+// between allocation and usage costs when idle is present, in the absence of
+// utilisation metrics.
+//
+// Note: when utilisation metrics (CPUCoreUsageAverage, RAMBytesUsageAverage,
+// GPUUsageAverage) are available, usage cost is further reduced below
+// allocationCost - idle by scaling each resource to its actual consumption.
+// This test covers only the idle-exclusion step; see
+// TestCollector_UsageCost_ScalesResourcesByUtilisation for utilisation scaling.
 func TestCollector_UsageCost_ExcludesIdle(t *testing.T) {
 	// With ShareWeighted: AllocationTotalCost = 4.0 (GPU 3.0 + CPU 0.5 + RAM 0.5)
 	// With ShareNone: UsageCost = 2.6 (excludes idle: 1.0 + 0.2 + 0.2 = 1.4)
@@ -202,6 +209,600 @@ func TestCollector_UsageCost_ExcludesIdle(t *testing.T) {
 	}
 }
 
+// makeAllocationWithUtilisation creates an Allocation with both cost and
+// utilisation fields set, for testing the usage cost scaling path.
+// Pass nil for gpuUsageAverage to omit the GPUAllocation entirely (no GPU metric available).
+func makeAllocationWithUtilisation(
+	name string,
+	gpuCost, cpuCost, ramCost float64,
+	gpuUsageAverage *float64, // SM duty cycle fraction [0,1]; nil means no GPU metric
+	cpuCoreRequest, cpuCoreUsage float64,
+	ramBytesRequest, ramBytesUsage float64,
+	namespace string,
+) *opencost.Allocation {
+	a := &opencost.Allocation{
+		Name:                   name,
+		GPUCost:                gpuCost,
+		CPUCost:                cpuCost,
+		RAMCost:                ramCost,
+		CPUCoreRequestAverage:  cpuCoreRequest,
+		CPUCoreUsageAverage:    cpuCoreUsage,
+		RAMBytesRequestAverage: ramBytesRequest,
+		RAMBytesUsageAverage:   ramBytesUsage,
+		Properties: &opencost.AllocationProperties{
+			Namespace: namespace,
+			Labels:    opencost.AllocationLabels(map[string]string{"llm-d.ai/model": name}),
+		},
+	}
+	if gpuUsageAverage != nil {
+		a.GPUAllocation = &opencost.GPUAllocation{
+			GPUUsageAverage: gpuUsageAverage,
+		}
+	}
+	return a
+}
+
+// gpuUsage is a helper that returns a pointer to a float64, for use in
+// makeAllocationWithUtilisation calls.
+func gpuUsage(v float64) *float64 { return &v }
+
+// TestCollector_UsageCost_ScalesResourcesByUtilisation verifies that when
+// utilisation metrics are present, extractAllocationResults scales GPU, CPU,
+// and RAM costs proportionally to their actual consumption.
+//
+// Numbers:
+//
+//	GPU $6 at 50% → $3.00
+//	CPU $4 at 25% (1 core used / 4 requested) → $1.00
+//	RAM $2 at 10% (10 GB used / 100 GB requested) → $0.20
+//	Total = $4.20
+func TestCollector_UsageCost_ScalesResourcesByUtilisation(t *testing.T) {
+	cfg := baseConfig()
+	c := &Collector{config: cfg}
+
+	now := time.Now()
+	alloc := makeAllocationWithUtilisation(
+		"llama-3",
+		6.0,           // gpuCost
+		4.0,           // cpuCost
+		2.0,           // ramCost
+		gpuUsage(0.5), // gpuUsageAverage: 50%
+		4.0, 1.0,      // cpuCoreRequest=4, cpuCoreUsage=1 → 25%
+		100.0, 10.0, // ramBytesRequest=100, ramBytesUsage=10 → 10%
+		"llm-prod",
+	)
+	as := opencost.NewAllocationSet(now.Add(-5*time.Minute), now)
+	as.Set(alloc)
+
+	results, err := c.extractAllocationResults(as, false)
+	if err != nil {
+		t.Fatalf("extractAllocationResults failed: %v", err)
+	}
+
+	key := modelNamespaceKey("llama-3", "llm-prod")
+	r, ok := results[key]
+	if !ok {
+		t.Fatal("expected result for llama-3/llm-prod")
+	}
+
+	// GPU: $6 × 0.50 = $3.00
+	// CPU: $4 × (1/4) = $1.00
+	// RAM: $2 × (10/100) = $0.20
+	// Total: $4.20
+	want := 4.20
+	if !floatEq(r.usageTotalCost, want) {
+		t.Errorf("usageTotalCost want %.2f got %.4f", want, r.usageTotalCost)
+	}
+}
+
+// TestCollector_UsageCost_NoScalingWhenUtilisationMetricsAbsent verifies that
+// when utilisation averages are zero (metrics not available), extractAllocationResults
+// leaves usageTotalCost at the full TotalCost() — the safe fallback.
+func TestCollector_UsageCost_NoScalingWhenUtilisationMetricsAbsent(t *testing.T) {
+	cfg := baseConfig()
+	c := &Collector{config: cfg}
+
+	now := time.Now()
+	// No utilisation fields set — CPUCoreUsageAverage and RAMBytesUsageAverage
+	// default to 0, so gating conditions are not met and no scaling fires.
+	alloc := &opencost.Allocation{
+		Name:    "llama-3",
+		GPUCost: 6.0,
+		CPUCost: 4.0,
+		RAMCost: 2.0,
+		Properties: &opencost.AllocationProperties{
+			Namespace: "llm-prod",
+			Labels:    opencost.AllocationLabels(map[string]string{"llm-d.ai/model": "llama-3"}),
+		},
+	}
+	as := opencost.NewAllocationSet(now.Add(-5*time.Minute), now)
+	as.Set(alloc)
+
+	results, err := c.extractAllocationResults(as, false)
+	if err != nil {
+		t.Fatalf("extractAllocationResults failed: %v", err)
+	}
+
+	key := modelNamespaceKey("llama-3", "llm-prod")
+	r, ok := results[key]
+	if !ok {
+		t.Fatal("expected result for llama-3/llm-prod")
+	}
+
+	// No utilisation metrics → full TotalCost() = $12.00 unchanged.
+	want := 12.0
+	if !floatEq(r.usageTotalCost, want) {
+		t.Errorf("usageTotalCost want %.2f got %.4f (expected no scaling)", want, r.usageTotalCost)
+	}
+}
+
+// TestCollector_UsageCost_ZeroGPUUsage verifies that GPUUsageAverage==0 (GPU
+// completely idle) scales the GPU cost to $0, not left at full reservation.
+// This is the primary regression test for the original exclusive `> 0` guard.
+func TestCollector_UsageCost_ZeroGPUUsage(t *testing.T) {
+	cfg := baseConfig()
+	c := &Collector{config: cfg}
+
+	now := time.Now()
+	alloc := makeAllocationWithUtilisation(
+		"llama-3",
+		6.0,           // gpuCost
+		4.0,           // cpuCost
+		2.0,           // ramCost
+		gpuUsage(0.0), // GPUUsageAverage = 0: completely idle GPU
+		0, 0,          // no CPU scaling
+		0, 0, // no RAM scaling
+		"llm-prod",
+	)
+	as := opencost.NewAllocationSet(now.Add(-5*time.Minute), now)
+	as.Set(alloc)
+
+	results, err := c.extractAllocationResults(as, false)
+	if err != nil {
+		t.Fatalf("extractAllocationResults failed: %v", err)
+	}
+
+	key := modelNamespaceKey("llama-3", "llm-prod")
+	r, ok := results[key]
+	if !ok {
+		t.Fatal("expected result for llama-3/llm-prod")
+	}
+
+	// GPU $6 × 0.0 = $0; CPU $4 + RAM $2 = $6 total (no CPU/RAM scaling).
+	want := 6.0
+	if !floatEq(r.usageTotalCost, want) {
+		t.Errorf("usageTotalCost want %.2f got %.4f (zero GPU usage should zero GPU cost)", want, r.usageTotalCost)
+	}
+}
+
+// TestCollector_UsageCost_OutOfRangeGPUUsageClamped verifies that
+// GPUUsageAverage values outside [0,1] are clamped before scaling, so they
+// never produce nonsensical (negative or inflated) costs.
+func TestCollector_UsageCost_OutOfRangeGPUUsageClamped(t *testing.T) {
+	cfg := baseConfig()
+	c := &Collector{config: cfg}
+	now := time.Now()
+
+	tests := []struct {
+		name          string
+		gpuUsageAvg   float64
+		wantUsageCost float64 // GPU $6 clamped + CPU $4 + RAM $2 (no CPU/RAM scaling)
+	}{
+		{"above_one", 1.5, 12.0}, // clamped to 1.0 → $6 GPU + $4 CPU + $2 RAM
+		{"negative", -0.5, 6.0},  // clamped to 0.0 → $0 GPU + $4 CPU + $2 RAM
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			alloc := makeAllocationWithUtilisation(
+				"llama-3",
+				6.0, 4.0, 2.0,
+				gpuUsage(tc.gpuUsageAvg),
+				0, 0,
+				0, 0,
+				"llm-prod",
+			)
+			as := opencost.NewAllocationSet(now.Add(-5*time.Minute), now)
+			as.Set(alloc)
+
+			results, err := c.extractAllocationResults(as, false)
+			if err != nil {
+				t.Fatalf("extractAllocationResults failed: %v", err)
+			}
+
+			key := modelNamespaceKey("llama-3", "llm-prod")
+			r, ok := results[key]
+			if !ok {
+				t.Fatal("expected result for llama-3/llm-prod")
+			}
+
+			if !floatEq(r.usageTotalCost, tc.wantUsageCost) {
+				t.Errorf("usageTotalCost want %.2f got %.4f (gpuUsageAvg=%.2f should be clamped)",
+					tc.wantUsageCost, r.usageTotalCost, tc.gpuUsageAvg)
+			}
+		})
+	}
+}
+
+// TestCollector_UsageCost_GPUOnlyScaling verifies that GPU cost is scaled by
+// GPUUsageAverage while CPU and RAM costs are left at their full reservation
+// when no CPU/RAM utilisation metrics are available.
+//
+// Numbers:
+//
+//	GPU $6 × 0.75 = $4.50
+//	CPU $4 (no scaling — CPUCoreUsageAverage == 0)
+//	RAM $2 (no scaling — RAMBytesUsageAverage == 0)
+//	Total = $10.50
+func TestCollector_UsageCost_GPUOnlyScaling(t *testing.T) {
+	cfg := baseConfig()
+	c := &Collector{config: cfg}
+	now := time.Now()
+
+	alloc := makeAllocationWithUtilisation(
+		"llama-3",
+		6.0, 4.0, 2.0,
+		gpuUsage(0.75), // GPU 75%
+		0, 0,           // no CPU utilisation metrics
+		0, 0, // no RAM utilisation metrics
+		"llm-prod",
+	)
+	as := opencost.NewAllocationSet(now.Add(-5*time.Minute), now)
+	as.Set(alloc)
+
+	results, err := c.extractAllocationResults(as, false)
+	if err != nil {
+		t.Fatalf("extractAllocationResults failed: %v", err)
+	}
+
+	key := modelNamespaceKey("llama-3", "llm-prod")
+	r, ok := results[key]
+	if !ok {
+		t.Fatal("expected result for llama-3/llm-prod")
+	}
+
+	// GPU $6 × 0.75 = $4.50; CPU $4 + RAM $2 unchanged → $10.50
+	want := 10.50
+	if !floatEq(r.usageTotalCost, want) {
+		t.Errorf("usageTotalCost want %.2f got %.4f", want, r.usageTotalCost)
+	}
+}
+
+// TestCollector_UsageCost_CPUOnlyScaling verifies that CPU cost is scaled by
+// the core utilisation ratio while GPU and RAM costs are left at their full
+// reservation when those metrics are absent.
+//
+// Numbers:
+//
+//	GPU $6 (no GPUAllocation → no scaling)
+//	CPU $4 × (2/8) = $1.00
+//	RAM $2 (no scaling — RAMBytesUsageAverage == 0)
+//	Total = $9.00
+func TestCollector_UsageCost_CPUOnlyScaling(t *testing.T) {
+	cfg := baseConfig()
+	c := &Collector{config: cfg}
+	now := time.Now()
+
+	alloc := makeAllocationWithUtilisation(
+		"llama-3",
+		6.0, 4.0, 2.0,
+		nil,      // no GPUAllocation
+		8.0, 2.0, // cpuRequest=8, cpuUsage=2 → 25%
+		0, 0, // no RAM utilisation metrics
+		"llm-prod",
+	)
+	as := opencost.NewAllocationSet(now.Add(-5*time.Minute), now)
+	as.Set(alloc)
+
+	results, err := c.extractAllocationResults(as, false)
+	if err != nil {
+		t.Fatalf("extractAllocationResults failed: %v", err)
+	}
+
+	key := modelNamespaceKey("llama-3", "llm-prod")
+	r, ok := results[key]
+	if !ok {
+		t.Fatal("expected result for llama-3/llm-prod")
+	}
+
+	// GPU $6 unchanged + CPU $4 × (2/8) = $1.00 + RAM $2 unchanged = $9.00
+	want := 9.00
+	if !floatEq(r.usageTotalCost, want) {
+		t.Errorf("usageTotalCost want %.2f got %.4f", want, r.usageTotalCost)
+	}
+}
+
+// TestCollector_UsageCost_RAMOnlyScaling verifies that RAM cost is scaled by
+// the byte utilisation ratio while GPU and CPU costs are left at their full
+// reservation when those metrics are absent.
+//
+// Numbers:
+//
+//	GPU $6 (no GPUAllocation → no scaling)
+//	CPU $4 (no scaling — CPUCoreUsageAverage == 0)
+//	RAM $2 × (20/200) = $0.20
+//	Total = $10.20
+func TestCollector_UsageCost_RAMOnlyScaling(t *testing.T) {
+	cfg := baseConfig()
+	c := &Collector{config: cfg}
+	now := time.Now()
+
+	alloc := makeAllocationWithUtilisation(
+		"llama-3",
+		6.0, 4.0, 2.0,
+		nil,  // no GPUAllocation
+		0, 0, // no CPU utilisation metrics
+		200.0, 20.0, // ramRequest=200, ramUsage=20 → 10%
+		"llm-prod",
+	)
+	as := opencost.NewAllocationSet(now.Add(-5*time.Minute), now)
+	as.Set(alloc)
+
+	results, err := c.extractAllocationResults(as, false)
+	if err != nil {
+		t.Fatalf("extractAllocationResults failed: %v", err)
+	}
+
+	key := modelNamespaceKey("llama-3", "llm-prod")
+	r, ok := results[key]
+	if !ok {
+		t.Fatal("expected result for llama-3/llm-prod")
+	}
+
+	// GPU $6 + CPU $4 unchanged + RAM $2 × (20/200) = $0.20 → $10.20
+	want := 10.20
+	if !floatEq(r.usageTotalCost, want) {
+		t.Errorf("usageTotalCost want %.2f got %.4f", want, r.usageTotalCost)
+	}
+}
+
+// TestCollector_UsageCost_CPUUsageEqualsRequest verifies that when CPU usage
+// exactly equals the request (utilisation == 100%), the guard condition
+// (usage < request) prevents scaling and the full CPU cost is retained.
+// This also confirms no double-counting from the subtraction/addition path.
+func TestCollector_UsageCost_CPUUsageEqualsRequest(t *testing.T) {
+	cfg := baseConfig()
+	c := &Collector{config: cfg}
+	now := time.Now()
+
+	alloc := makeAllocationWithUtilisation(
+		"llama-3",
+		0, 4.0, 0,
+		nil,      // no GPU
+		4.0, 4.0, // usage == request → guard (usage < request) is false → no scaling
+		0, 0,
+		"llm-prod",
+	)
+	as := opencost.NewAllocationSet(now.Add(-5*time.Minute), now)
+	as.Set(alloc)
+
+	results, err := c.extractAllocationResults(as, false)
+	if err != nil {
+		t.Fatalf("extractAllocationResults failed: %v", err)
+	}
+
+	key := modelNamespaceKey("llama-3", "llm-prod")
+	r, ok := results[key]
+	if !ok {
+		t.Fatal("expected result for llama-3/llm-prod")
+	}
+
+	// usage == request → no scaling → full $4.00 retained
+	want := 4.0
+	if !floatEq(r.usageTotalCost, want) {
+		t.Errorf("usageTotalCost want %.2f got %.4f (CPU at 100%% should not be scaled)", want, r.usageTotalCost)
+	}
+}
+
+// TestCollector_UsageCost_RAMUsageEqualsRequest verifies the same guard for RAM:
+// when RAM usage exactly equals the request, no scaling fires and full cost is kept.
+func TestCollector_UsageCost_RAMUsageEqualsRequest(t *testing.T) {
+	cfg := baseConfig()
+	c := &Collector{config: cfg}
+	now := time.Now()
+
+	alloc := makeAllocationWithUtilisation(
+		"llama-3",
+		0, 0, 2.0,
+		nil,
+		0, 0,
+		100.0, 100.0, // usage == request → no scaling
+		"llm-prod",
+	)
+	as := opencost.NewAllocationSet(now.Add(-5*time.Minute), now)
+	as.Set(alloc)
+
+	results, err := c.extractAllocationResults(as, false)
+	if err != nil {
+		t.Fatalf("extractAllocationResults failed: %v", err)
+	}
+
+	key := modelNamespaceKey("llama-3", "llm-prod")
+	r, ok := results[key]
+	if !ok {
+		t.Fatal("expected result for llama-3/llm-prod")
+	}
+
+	// usage == request → full $2.00 retained
+	want := 2.0
+	if !floatEq(r.usageTotalCost, want) {
+		t.Errorf("usageTotalCost want %.2f got %.4f (RAM at 100%% should not be scaled)", want, r.usageTotalCost)
+	}
+}
+
+// TestCollector_UsageCost_CPUOvercommit verifies that when CPU usage exceeds
+// the request (overcommit), the guard (usage < request) prevents scaling and
+// the full CPU cost is retained — overcommit situations should not produce
+// sub-reservation costs.
+func TestCollector_UsageCost_CPUOvercommit(t *testing.T) {
+	cfg := baseConfig()
+	c := &Collector{config: cfg}
+	now := time.Now()
+
+	alloc := makeAllocationWithUtilisation(
+		"llama-3",
+		0, 4.0, 0,
+		nil,
+		2.0, 3.0, // usage (3.0) > request (2.0) → overcommit, no scaling
+		0, 0,
+		"llm-prod",
+	)
+	as := opencost.NewAllocationSet(now.Add(-5*time.Minute), now)
+	as.Set(alloc)
+
+	results, err := c.extractAllocationResults(as, false)
+	if err != nil {
+		t.Fatalf("extractAllocationResults failed: %v", err)
+	}
+
+	key := modelNamespaceKey("llama-3", "llm-prod")
+	r, ok := results[key]
+	if !ok {
+		t.Fatal("expected result for llama-3/llm-prod")
+	}
+
+	// overcommit → no scaling → full $4.00 retained
+	want := 4.0
+	if !floatEq(r.usageTotalCost, want) {
+		t.Errorf("usageTotalCost want %.2f got %.4f (CPU overcommit should not reduce cost)", want, r.usageTotalCost)
+	}
+}
+
+// TestCollector_UsageCost_RAMOvercommit verifies the same overcommit guard for RAM.
+func TestCollector_UsageCost_RAMOvercommit(t *testing.T) {
+	cfg := baseConfig()
+	c := &Collector{config: cfg}
+	now := time.Now()
+
+	alloc := makeAllocationWithUtilisation(
+		"llama-3",
+		0, 0, 2.0,
+		nil,
+		0, 0,
+		50.0, 80.0, // usage (80) > request (50) → overcommit, no scaling
+		"llm-prod",
+	)
+	as := opencost.NewAllocationSet(now.Add(-5*time.Minute), now)
+	as.Set(alloc)
+
+	results, err := c.extractAllocationResults(as, false)
+	if err != nil {
+		t.Fatalf("extractAllocationResults failed: %v", err)
+	}
+
+	key := modelNamespaceKey("llama-3", "llm-prod")
+	r, ok := results[key]
+	if !ok {
+		t.Fatal("expected result for llama-3/llm-prod")
+	}
+
+	// overcommit → no scaling → full $2.00 retained
+	want := 2.0
+	if !floatEq(r.usageTotalCost, want) {
+		t.Errorf("usageTotalCost want %.2f got %.4f (RAM overcommit should not reduce cost)", want, r.usageTotalCost)
+	}
+}
+
+// TestCollector_UsageCost_NilGPUAllocationStruct verifies that an allocation
+// with a nil GPUAllocation pointer (as opposed to a non-nil struct with a nil
+// GPUUsageAverage pointer) is handled safely — no GPU scaling, no panic.
+func TestCollector_UsageCost_NilGPUAllocationStruct(t *testing.T) {
+	cfg := baseConfig()
+	c := &Collector{config: cfg}
+	now := time.Now()
+
+	// makeAllocationWithUtilisation passes nil for gpuUsageAverage → GPUAllocation stays nil
+	alloc := makeAllocationWithUtilisation(
+		"llama-3",
+		6.0, 4.0, 2.0,
+		nil, // GPUAllocation == nil
+		0, 0,
+		0, 0,
+		"llm-prod",
+	)
+	as := opencost.NewAllocationSet(now.Add(-5*time.Minute), now)
+	as.Set(alloc)
+
+	results, err := c.extractAllocationResults(as, false)
+	if err != nil {
+		t.Fatalf("extractAllocationResults failed: %v", err)
+	}
+
+	key := modelNamespaceKey("llama-3", "llm-prod")
+	r, ok := results[key]
+	if !ok {
+		t.Fatal("expected result for llama-3/llm-prod")
+	}
+
+	// No GPUAllocation → no GPU scaling; no CPU/RAM metrics → no CPU/RAM scaling.
+	// Full TotalCost() = $12.00 retained.
+	want := 12.0
+	if !floatEq(r.usageTotalCost, want) {
+		t.Errorf("usageTotalCost want %.2f got %.4f (nil GPUAllocation should not panic or scale)", want, r.usageTotalCost)
+	}
+}
+
+// TestCollector_UsageCost_TwoModelsIndependent verifies that two different
+// models in the same namespace produce two independent result entries, each
+// scaled only by its own utilisation metrics with no cross-contamination.
+func TestCollector_UsageCost_TwoModelsIndependent(t *testing.T) {
+	cfg := baseConfig()
+	c := &Collector{config: cfg}
+	now := time.Now()
+
+	// Model A: GPU $6 × 0.5 = $3.00; CPU/RAM no metrics → $3 + $4 + $2 = $9.00
+	modelA := makeAllocationWithUtilisation(
+		"llama-3",
+		6.0, 4.0, 2.0,
+		gpuUsage(0.5),
+		0, 0,
+		0, 0,
+		"llm-prod",
+	)
+	// Model B: GPU $3 × 0.2 = $0.60; CPU/RAM no metrics → $0.60 + $2 + $1 = $3.60
+	modelB := makeAllocationWithUtilisation(
+		"mistral-7b",
+		3.0, 2.0, 1.0,
+		gpuUsage(0.2),
+		0, 0,
+		0, 0,
+		"llm-prod",
+	)
+
+	as := opencost.NewAllocationSet(now.Add(-5*time.Minute), now)
+	as.Set(modelA)
+	as.Set(modelB)
+
+	results, err := c.extractAllocationResults(as, false)
+	if err != nil {
+		t.Fatalf("extractAllocationResults failed: %v", err)
+	}
+
+	if len(results) != 2 {
+		t.Fatalf("expected 2 independent result entries, got %d", len(results))
+	}
+
+	// llama-3: GPU $6 × 0.5 = $3 + CPU $4 + RAM $2 = $9.00
+	keyA := modelNamespaceKey("llama-3", "llm-prod")
+	rA, ok := results[keyA]
+	if !ok {
+		t.Fatal("expected result for llama-3/llm-prod")
+	}
+	if !floatEq(rA.usageTotalCost, 9.00) {
+		t.Errorf("llama-3 usageTotalCost want 9.00 got %.4f", rA.usageTotalCost)
+	}
+
+	// mistral-7b: GPU $3 × 0.2 = $0.60 + CPU $2 + RAM $1 = $3.60
+	keyB := modelNamespaceKey("mistral-7b", "llm-prod")
+	rB, ok := results[keyB]
+	if !ok {
+		t.Fatal("expected result for mistral-7b/llm-prod")
+	}
+	if !floatEq(rB.usageTotalCost, 3.60) {
+		t.Errorf("mistral-7b usageTotalCost want 3.60 got %.4f", rB.usageTotalCost)
+	}
+}
+
 // TestCollector_CombineMetrics_DerivesCachedTokens verifies that combineMetrics
 // passes CachedTokens through directly and derives EffectiveInputTokens correctly.
 func TestCollector_CombineMetrics_DerivesCachedTokens(t *testing.T) {