Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ heavy inference.
| `axis agent [--auto-approve] [--autonomy MODE] [--plain] [--console] [--live]` | Agentic tool-calling assistant; REPL slash commands `/plan /todo /diff /undo /compact /autonomy /export /fleet`; default cluster context is daemon/disk cache (`LoadCached`), `--live` is an explicit discovery sweep. On an interactive TTY the transcript console is the default; `--plain` selects the legacy line reader (takes precedence over `--console`); `--console` forces the console. Tool approvals use an overlay (`y` yes, `n` no; Enter does not approve; timeout and cancel deny) |
| `axis llm` | Removed; prints `use axis ai route` |
| `axis ai` | Inference backends, roles, dry-run route resolve |
| `axis model` | List/inspect resident instances, dry-run placement planning, start/stop llama-server, await readiness, or query models |
| `axis model` | List/inspect resident instances, dry-run placement planning, `start <model>` on the node that already has the runtime, or pin llama-server, Ollama, and MLX start/stop, await readiness, or query models |
| `axis cluster` | Fleet snapshot: `status`, `summary` |
| `axis node` | This machine: `facts` |
| `axis cortex` | Distributed vector memory / event bus (resolves node via AXIS_CORTEX_NODE, role: cortex, or name cortex/foundry) |
Expand Down
4 changes: 2 additions & 2 deletions cmd/axis/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,12 @@ func newRootCmd() *cobra.Command {
axis cluster status every node (cache-first for 5 minutes; --live to sweep)
axis node facts this machine
axis agent ask questions (advisory)
axis model start llama-server on a named node (--node --weights --port)
axis model start one model name; pick the node that already has the runtime
axis daemon status local cache

axis status, axis facts, axis summary, and axis doctor still work.
axis chat and axis llm were removed; use axis agent and axis ai route.`,
Example: " axis cluster status\n axis node facts\n axis agent\n axis model start --node storage --weights /mnt/models/a.gguf --port 8081",
Example: " axis cluster status\n axis node facts\n axis agent\n axis model start mistral",

PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
ui.Init(noColor)
Expand Down
664 changes: 618 additions & 46 deletions cmd/axis/model.go

Large diffs are not rendered by default.

93 changes: 93 additions & 0 deletions cmd/axis/model_daily.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package main

import (
"context"
"fmt"
"strings"
"time"

"github.com/spf13/cobra"

"github.com/toasterbook88/axis/internal/api"
"github.com/toasterbook88/axis/internal/modellife"
"github.com/toasterbook88/axis/internal/models"
)

func runDailyModelStart(ctx context.Context, cmd *cobra.Command, modelName string) error {
modelName = strings.TrimSpace(modelName)
if modelName == "" {
return fmt.Errorf("model name is required")
}
live, _ := cmd.Flags().GetBool("live")
cacheAddr, _ := cmd.Flags().GetString("cache-addr")
if cacheAddr == "" {
cacheAddr = api.DefaultAddr()
}
format, _ := cmd.Flags().GetString("format")
if format == "" {
format = "text"
}
snap, source, err := loadModelCommandSnapshot(ctx, live, cacheAddr, "start", false)
if err != nil {
return err
}
pick, err := modellife.PickServingNode(snap.Nodes, modelName)
if err != nil {
return err
}
if pick.Already {
return writeDailyPick(cmd, pick, snap, source, format)
}
if pick.Runtime != models.EngineOllama {
return fmt.Errorf("node %s runtime %s is not a load target for %s", pick.Node, pick.Runtime, modelName)
}
if format == "text" {
if _, err := fmt.Fprintln(cmd.OutOrStdout(), pick.Sentence()); err != nil {
return err
}
}
nf, cfgNode, err := resolveModelNodeFromSnapshot(snap, pick.Node)
if err != nil {
return err
}
profile := models.ModelRunProfile{
Schema: models.ModelRunSchema,
Node: pick.Node,
Engine: models.EngineOllama,
ArtifactKind: models.ArtifactOllamaModelName,
OllamaModel: modelName,
BindHost: "127.0.0.1",
}
if snap.Publication != nil {
profile.SnapshotPublicationID = snap.Publication.ID
}
return placeOllamaModel(ctx, cmd, nf, cfgNode, profile, source, snap, time.Now().UTC(), format)
}

func writeDailyPick(cmd *cobra.Command, pick modellife.ServingPick, snap *models.ClusterSnapshot, source, format string) error {
if format == "text" || format == "" {
_, err := fmt.Fprintln(cmd.OutOrStdout(), pick.Sentence())
return err
}
now := time.Now().UTC()
receipt := models.ModelOperationReceipt{
Schema: "axis.model-operation/v1",
ID: models.GenerateID("mo"),
Action: models.ModelOperationStart,
Status: models.ModelOperationCompleted,
Disposition: "already_serving",
Node: pick.Node,
Engine: pick.Runtime,
Model: pick.Model,
SnapshotSource: source,
StartedAt: now,
CompletedAt: now,
}
if snap != nil {
receipt.SnapshotAt = snap.Timestamp
if snap.Publication != nil {
receipt.PublicationID = snap.Publication.ID
}
}
return printOutput(cmd.OutOrStdout(), receipt, format)
}
187 changes: 187 additions & 0 deletions cmd/axis/model_daily_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
package main

import (
"bytes"
"context"
"strings"
"testing"

"github.com/toasterbook88/axis/internal/config"
"github.com/toasterbook88/axis/internal/models"
)

func TestModelStartDailyPathPicksListeningOllama(t *testing.T) {
snap := &models.ClusterSnapshot{Nodes: []models.NodeFacts{
{
Name: "zeta",
Status: models.StatusComplete,
Ollama: &models.OllamaInfo{Listening: true, Models: []string{"mistral:latest"}},
},
{
Name: "alpha",
Status: models.StatusComplete,
Ollama: &models.OllamaInfo{Listening: true, Models: []string{"mistral"}},
},
{Name: "partial", Status: models.StatusPartial, Ollama: &models.OllamaInfo{Listening: true, Models: []string{"mistral"}}},
}}
stubModelSnapshot(t, snap)
stubModelConfig(t, &config.Config{Nodes: []config.NodeConfig{{Name: "alpha"}, {Name: "zeta"}}})
var scripts []string
prevScript := runNodeScript
runNodeScript = func(_ context.Context, node models.NodeFacts, _ *config.NodeConfig, script string) (string, error) {
if node.Name != "alpha" {
t.Fatalf("ssh node=%s", node.Name)
}
scripts = append(scripts, script)
return `{"models":[{"name":"mistral","model":"mistral"}]}`, nil
}
t.Cleanup(func() { runNodeScript = prevScript })
prevRefresh := signalModelDaemonRefresh
signalModelDaemonRefresh = func(context.Context, string, string) error { return nil }
t.Cleanup(func() { signalModelDaemonRefresh = prevRefresh })

cmd := modelStartCmd()
var buf bytes.Buffer
cmd.SetOut(&buf)
cmd.SetArgs([]string{"mistral"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
got := buf.String()
if !strings.Contains(got, "picked alpha: ollama already listening\n") {
t.Fatalf("stdout=%q", got)
}
if !strings.Contains(got, "placed ollama model mistral on alpha operation ") {
t.Fatalf("stdout=%q", got)
}
if len(scripts) != 1 || !strings.Contains(scripts[0], `grep -q '"done_reason":"load"'`) {
t.Fatalf("scripts=%v", scripts)
}
}

func TestModelStartDailyPathReportsResidentModelWithoutSSH(t *testing.T) {
snap := testSnap()
snap.Nodes[0].Status = models.StatusComplete
snap.Nodes[0].ResidentModels = []models.ResidentModel{{Name: "mistral", Runtime: "llama.cpp", Port: 8080}}
stubModelSnapshot(t, snap)
stubModelConfig(t, &config.Config{Nodes: []config.NodeConfig{{Name: "storage"}}})
var calls int
prevScript := runNodeScript
runNodeScript = func(context.Context, models.NodeFacts, *config.NodeConfig, string) (string, error) {
calls++
return "", nil
}
t.Cleanup(func() { runNodeScript = prevScript })
runner := &fakeModelRunner{}
prevRunner := defaultModelRunner
defaultModelRunner = runner
t.Cleanup(func() { defaultModelRunner = prevRunner })

cmd := modelStartCmd()
var buf bytes.Buffer
cmd.SetOut(&buf)
cmd.SetArgs([]string{"mistral", "--format", "text"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if buf.String() != "picked storage: llama.cpp already serving mistral\n" {
t.Fatalf("stdout=%q", buf.String())
}
if calls != 0 || len(runner.started) != 0 {
t.Fatalf("calls=%d started=%v", calls, runner.started)
}
}

func TestModelStartDailyPathJSONReportsAlreadyServing(t *testing.T) {
snap := testSnap()
snap.Nodes[0].Status = models.StatusComplete
snap.Nodes[0].ResidentModels = []models.ResidentModel{{Name: "mistral", Runtime: "ollama"}}
stubModelSnapshot(t, snap)
stubModelConfig(t, &config.Config{Nodes: []config.NodeConfig{{Name: "storage"}}})
cmd := modelStartCmd()
var buf bytes.Buffer
cmd.SetOut(&buf)
cmd.SetArgs([]string{"mistral", "--format", "json"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
got := buf.String()
if !strings.Contains(got, `"disposition": "already_serving"`) || !strings.Contains(got, `"node": "storage"`) || !strings.Contains(got, `"engine": "ollama"`) {
t.Fatalf("receipt=%s", got)
}
}

func TestModelStartDailyPathRefusesAFlagBoard(t *testing.T) {
cmd := modelStartCmd()
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs([]string{"mistral", "--node", "storage", "--ollama-model", "mistral"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "picks the node") || !strings.Contains(err.Error(), "--node") {
t.Fatalf("err=%v", err)
}
}

func TestModelStartDailyPathRefusesWhenNoRuntimeIsPresent(t *testing.T) {
snap := testSnap()
snap.Nodes[0].Status = models.StatusComplete
stubModelSnapshot(t, snap)
stubModelConfig(t, &config.Config{Nodes: []config.NodeConfig{{Name: "storage"}}})
var calls int
prevScript := runNodeScript
runNodeScript = func(context.Context, models.NodeFacts, *config.NodeConfig, string) (string, error) {
calls++
return "", nil
}
t.Cleanup(func() { runNodeScript = prevScript })
cmd := modelStartCmd()
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs([]string{"mistral"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "no complete node already has a runtime") {
t.Fatalf("err=%v", err)
}
if calls != 0 {
t.Fatalf("ssh calls=%d", calls)
}
}

func TestOllamaStartRefusesBeforeSSHWhenTheServerIsNotListening(t *testing.T) {
snap := testSnap()
snap.Nodes[0].Status = models.StatusComplete
stubModelSnapshot(t, snap)
stubModelConfig(t, &config.Config{Nodes: []config.NodeConfig{{Name: "storage"}}})
var calls int
prevScript := runNodeScript
runNodeScript = func(context.Context, models.NodeFacts, *config.NodeConfig, string) (string, error) {
calls++
return `{"models":[{"name":"mistral"}]}`, nil
}
t.Cleanup(func() { runNodeScript = prevScript })
cmd := modelStartCmd()
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs([]string{"--node", "storage", "--ollama-model", "mistral"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "no listening ollama") {
t.Fatalf("err=%v", err)
}
if calls != 0 {
t.Fatalf("ssh calls=%d", calls)
}

snap.Nodes[0].Status = models.StatusPartial
snap.Nodes[0].Ollama = &models.OllamaInfo{Listening: true}
cmd = modelStopCmd()
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs([]string{"--node", "storage", "--ollama-model", "mistral"})
err = cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "refusing ollama ssh") {
t.Fatalf("stop err=%v", err)
}
if calls != 0 {
t.Fatalf("stop ssh calls=%d", calls)
}
}
Loading
Loading