Files
llmgw/internal/cli/status_test.go
Ilker ManapandClaude Opus 5.5 b66dd9e6a7 An on-demand model that is up reads as ready, not as not placed
ctl models said "not placed" for a model with min 0 woken by a request and
serving; it now says "ready (on demand)". And the docs: pictures on the
cluster's own cards, with engine comfyui behind its gate.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 01:01:55 +02:00

183 lines
7.0 KiB
Go

package cli
import (
"strings"
"testing"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/modelplan"
)
func TestBarStaysWithinItsWidth(t *testing.T) {
// It is drawn from counts that can disagree briefly — a GPU freed between
// two reads — and a bar that overruns its column wrecks the table it is in.
for _, tc := range [][2]int{{0, 0}, {0, 8}, {8, 8}, {9, 8}, {100, 8}} {
got := bar(tc[0], tc[1])
if tc[1] == 0 {
if got != "" {
t.Errorf("bar(%d,%d) = %q, want empty for an empty class", tc[0], tc[1], got)
}
continue
}
if n := strings.Count(got, "#") + strings.Count(got, "."); n != 20 {
t.Errorf("bar(%d,%d) drew %d cells: %q", tc[0], tc[1], n, got)
}
}
}
func TestIdleColumnDistinguishesUnknownFromIdle(t *testing.T) {
// Every replica looks unreported before its first request and after a
// router restart. Showing that as a very large duration would make an
// untouched fleet read as an abandoned one.
if got := idleColumn(-1); got != "-" {
t.Errorf("idleColumn(-1) = %q, want a dash", got)
}
if got := idleColumn(90); got != "1m30s" {
t.Errorf("idleColumn(90) = %q", got)
}
}
func TestModelStatusSeparatesWaitingFromBroken(t *testing.T) {
// These used to differ by a parenthetical — "0/1 ready (1 total)" against
// "0/1 ready" — and they need opposite responses: one means wait, the other
// means look at explain-placement. On forty models that difference is
// invisible.
for _, tc := range []struct {
name string
view adminapi.ModelView
want string
}{
{"serving what it asked for", adminapi.ModelView{Min: 2, Ready: 2, Total: 2}, "ready"},
{"a replica is coming up", adminapi.ModelView{Min: 1, Ready: 0, Total: 1}, "starting"},
{"nothing could be placed", adminapi.ModelView{Min: 1, Ready: 0, Total: 0}, "not placed"},
{"deliberately not running", adminapi.ModelView{Min: 0, Ready: 0, Total: 0}, "stopped"},
{"short but rebuilding", adminapi.ModelView{Min: 2, Ready: 1, Total: 2}, "starting"},
{"woken by a request", adminapi.ModelView{Min: 0, Ready: 1, Total: 1}, "ready (on demand)"},
{"waking", adminapi.ModelView{Min: 0, Ready: 0, Total: 1}, "starting"},
} {
t.Run(tc.name, func(t *testing.T) {
if got := modelStatus(tc.view); got != tc.want {
t.Errorf("modelStatus(%+v) = %q, want %q", tc.view, got, tc.want)
}
})
}
}
func TestHardwarePoolsNeverMergeAcrossFabrics(t *testing.T) {
// A replica may never span two fabrics, so eight free cards split across
// two of them are not eight cards for a model that needs eight. Merging
// them would plan something that starts, passes health checks and hangs on
// its first all-reduce.
nodes := []adminapi.NodeView{
{ID: "a", State: "ready", Classes: []string{"h100-80g"}, Fabrics: []string{"ib-a"}, GPUFree: 4, GPUTotal: 4},
{ID: "b", State: "ready", Classes: []string{"h100-80g"}, Fabrics: []string{"ib-a"}, GPUFree: 4, GPUTotal: 4},
{ID: "c", State: "ready", Classes: []string{"h100-80g"}, Fabrics: []string{"ib-b"}, GPUFree: 4, GPUTotal: 4},
}
hw := hardwareFrom(nodes)
if len(hw) != 2 {
t.Fatalf("expected one pool per fabric, got %d: %+v", len(hw), hw)
}
for _, p := range hw {
switch p.Fabric {
case "ib-a":
if p.Nodes != 2 {
t.Errorf("ib-a has %d nodes, want 2", p.Nodes)
}
case "ib-b":
if p.Nodes != 1 {
t.Errorf("ib-b has %d nodes, want 1", p.Nodes)
}
default:
t.Errorf("unexpected fabric %q", p.Fabric)
}
}
}
func TestUnreadyNodesAreNotCountedAsCapacity(t *testing.T) {
// Planning against a node that is draining or unreachable produces a fit
// that cannot be placed, which is worse than saying nothing fits.
nodes := []adminapi.NodeView{
{ID: "a", State: "ready", Classes: []string{"h100-80g"}, GPUFree: 8, GPUTotal: 8},
{ID: "b", State: "draining", Classes: []string{"h100-80g"}, GPUFree: 8, GPUTotal: 8},
{ID: "c", State: "unreachable", Classes: []string{"h100-80g"}, GPUFree: 8, GPUTotal: 8},
}
hw := hardwareFrom(nodes)
if len(hw) != 1 || hw[0].Nodes != 1 {
t.Fatalf("expected only the ready node, got %+v", hw)
}
}
func TestCardMemoryComesFromTheClassLabel(t *testing.T) {
// The label carries it by construction — "h100-80g" is eighty gigabytes and
// says so — which is why the planner needs no table of card models.
for label, want := range map[string]int{
"h100-80g": 80, "a100-40g": 40, "l40s-48g": 48, "rtx4090-24g": 24,
"nonsense": 0, "": 0, "h100-": 0,
} {
if got := memFromClass(label); got != want {
t.Errorf("memFromClass(%q) = %d, want %d", label, got, want)
}
}
}
func TestACPUOnlyNodeBecomesAMemoryPool(t *testing.T) {
// How this cluster began: a node with no cards is not an empty pool, it is
// a pool measured in RAM.
hw := hardwareFrom([]adminapi.NodeView{
{ID: "cpu1", State: "ready", MemoryGB: 31},
})
if len(hw) != 1 {
t.Fatalf("the CPU node produced %d pools", len(hw))
}
if hw[0].Class != "" || hw[0].HostMemGB != 31 {
t.Errorf("pool = %+v, want a classless pool with 31 GB", hw[0])
}
}
func TestAGPUNodeWithNoFreeCardsIsNotAPool(t *testing.T) {
// Fully allocated hardware is not capacity, and offering it would plan a
// replica that cannot be placed.
hw := hardwareFrom([]adminapi.NodeView{
{ID: "full", State: "ready", Classes: []string{"h100-80g"}, GPUFree: 0, GPUTotal: 8},
})
for _, p := range hw {
if p.Class != "" {
t.Errorf("a node with no free cards became a pool: %+v", p)
}
}
}
func TestTheEntryNamesAnEngineThatCanServeThePrecision(t *testing.T) {
// The defect this prevents, found by reading the tool's own output: it
// chose q8_0 — a GGUF quantisation — and emitted a vLLM profile with no
// quantisation at all. Following that loads full-precision weights, which
// the same plan had just shown do not fit on one card. The tool would have
// caused the out-of-memory it exists to prevent.
m := modelplan.Model{Name: "Qwen2.5-Coder-32B", Params: 32_800_000_000, Layers: 64, KVHeads: 8, HeadDim: 128}
gguf := modelplan.Fit{Precision: modelplan.Q8GGUF, Class: "h100-80g", TP: 1, Nodes: 1}
entry := catalogEntry(m, gguf, "Qwen/Qwen2.5-Coder-32B-Instruct", 32768)
if !strings.Contains(entry, "engine: llamacpp") {
t.Errorf("a GGUF precision produced a non-llamacpp profile:\n%s", entry)
}
if strings.Contains(entry, "source: Qwen/") {
t.Errorf("a GGUF entry points at a repository rather than a file:\n%s", entry)
}
if !strings.Contains(entry, ".gguf") {
t.Errorf("a GGUF entry has no file path:\n%s", entry)
}
fp8 := modelplan.Fit{Precision: modelplan.FP8, Class: "h100-80g", TP: 2, Nodes: 1}
entry = catalogEntry(m, fp8, "Qwen/Qwen2.5-Coder-32B-Instruct", 32768)
if !strings.Contains(entry, "engine: vllm") {
t.Errorf("fp8 did not produce a vLLM profile:\n%s", entry)
}
// And the quantisation has to reach the engine, or vLLM loads bf16.
if !strings.Contains(entry, "--quantization") || !strings.Contains(entry, "fp8") {
t.Errorf("the fp8 quantisation is not passed to the engine:\n%s", entry)
}
if !strings.Contains(entry, "source: Qwen/") {
t.Errorf("a vLLM entry should load from the repository:\n%s", entry)
}
}