diff --git a/internal/cli/ctl.go b/internal/cli/ctl.go index c88b3e3..cce20d3 100644 --- a/internal/cli/ctl.go +++ b/internal/cli/ctl.go @@ -172,6 +172,33 @@ func newNodesCmd(client func() *ctlClient, asJSON *bool) *cobra.Command { } } +// modelStatus says what is happening to a model, in the words that decide what +// to do about it. +// +// The previous rendering distinguished "a replica is coming up" from "nothing +// could be placed" by a parenthetical — "0/1 ready (1 total)" against "0/1 +// ready" — and those need opposite responses: one means wait, the other means +// look at explain-placement. On four models the difference is findable; on +// forty it is invisible, and a model that will never start looks exactly like +// one that is thirty seconds away. +// +// A model appearing here before it serves is deliberate: this is the operator's +// view, and a model coming up is precisely what someone wants to see. Clients +// are told separately and only when it is true — /v1/models lists what is +// actually served, never what is merely catalogued. +func modelStatus(m adminapi.ModelView) string { + switch { + case m.Min == 0 && m.Total == 0: + return "stopped" + case m.Ready >= m.Min && m.Min > 0: + return "ready" + case m.Total > m.Ready: + return "starting" + default: + return "not placed" + } +} + // idleColumn shows how long a replica has gone unused. // // "-" for a replica nothing has reported use of, which is emphatically not the @@ -212,14 +239,15 @@ func newModelsCmd(client func() *ctlClient, asJSON *bool) *cobra.Command { } w := tab() - fmt.Fprintln(w, "MODEL\tREPLICAS\tALIASES\tPROFILES\tFALLBACKS") + fmt.Fprintln(w, "MODEL\tSERVING\tREPLICAS\tSTATUS\tALIASES\tPROFILES\tFALLBACKS") for _, m := range models { - replicas := fmt.Sprintf("%d/%d ready", m.Ready, m.Min) - if m.Total > m.Ready { - replicas += fmt.Sprintf(" (%d total)", m.Total) + serving := "no" + if m.Ready > 0 { + serving = "yes" } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", - m.Name, replicas, join(m.Aliases), join(m.Profiles), join(m.Fallbacks)) + fmt.Fprintf(w, "%s\t%s\t%d/%d\t%s\t%s\t%s\t%s\n", + m.Name, serving, m.Ready, m.Min, modelStatus(m), + join(m.Aliases), join(m.Profiles), join(m.Fallbacks)) } return w.Flush() }, diff --git a/internal/cli/status_test.go b/internal/cli/status_test.go index 7c297ef..0e4f007 100644 --- a/internal/cli/status_test.go +++ b/internal/cli/status_test.go @@ -3,6 +3,8 @@ package cli import ( "strings" "testing" + + "gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi" ) func TestBarStaysWithinItsWidth(t *testing.T) { @@ -33,3 +35,27 @@ func TestIdleColumnDistinguishesUnknownFromIdle(t *testing.T) { 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"}, + } { + 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) + } + }) + } +}