Skip to content
Draft
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
102 changes: 73 additions & 29 deletions cmd/spinloop/cloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -831,6 +831,22 @@ func modelPresetKey(runner string) string {
}
}

// modelSourceFlags names the canonical flag keys under which a PARAM (or a
// preset, through the same keys) may name a runner's model — llamacpp's
// hf-repo/model/hf-file, mtplx's model. A runner that takes its model
// positionally has none, so a PARAM cannot stand in for a MODEL there and the
// error that names the ways a model may be given does not name one either.
func modelSourceFlags(runner string) []string {
switch runner {
case "llamacpp":
return []string{"hf-repo", "model", "hf-file"}
case "mtplx":
return []string{"model"}
default:
return nil
}
}

// contextPresetKey names the preset key holding the context window, in that
// runner's own vocabulary.
func contextPresetKey(runner string) string {
Expand Down Expand Up @@ -876,9 +892,10 @@ func dropOwned(owned func(string) bool, params []preset.Param) []preset.Param {

// deployConfigFor turns a Spinloop (plus its preset, if any) into the config the
// deploy Lambda accepts. The Spinloop is the single source of truth: PROVIDER
// picks the runner, MODEL or the preset's hf names the weights, CONTEXT the
// window, ALIAS the served name, and whatever else the preset sets becomes the
// runner's own flags.
// picks the runner, MODEL, the preset's model reference, or a PARAM naming the
// model names the weights, CONTEXT the window, ALIAS the served name, and
// whatever the preset and the PARAMs set becomes the runner's own flags, a
// PARAM standing over the preset where both set a flag.
//
// A context size is required, because a cloud instance has to be sized before
// it is provisioned. deployConfigWithoutContext is the same derivation for a
Expand Down Expand Up @@ -932,12 +949,13 @@ func deployConfig(sel spinloop.Selection, spinloopPath string, target deployTarg
}
dc.Runner = runner

// The preset supplies the model and the runner flags when the Spinloop does
// not state them, so a single preset can drive both serve and deploy. The
// [*] globals are a separate layer that the chosen section overrides —
// exactly as preset.Args does for a local serve — so settings written there
// (commonly ngl and jinja) are not lost.
var global, params []preset.Param
// The preset and the PARAMs supply the model and the runner flags when the
// Spinloop does not state them, so a single Spinloop can drive both serve
// and deploy. The [*] globals are a separate layer that the chosen section
// overrides, and the PARAMs stand over the preset — the same layering a
// local serve gives them — so settings written there (commonly ngl and
// jinja) are not lost.
var global, params, paramLayer []preset.Param
if sel.Preset != "" {
presetPath, err := resolvePresetPath(sel.Preset, spinloopPath)
if err != nil {
Expand All @@ -958,14 +976,35 @@ func deployConfig(sel spinloop.Selection, spinloopPath string, target deployTarg
global, params = pre.Global, sec.Params
}

// The Spinloop's own PARAMs are the layer between the preset and its typed
// instructions: they supply the model, the context window, and the slot
// count the preset's keys do, and ride into ServeArgs with the preset's
// own layers, with the same owned-flag dropping.
paramLayer = selectionParams(sel)

// The model falls back in the Spinloop's own order: MODEL, then a PARAM
// naming the model for a runner that takes it as a flag, then the preset's
// model key — a PARAM standing over the preset, exactly as one does for a
// local serve. The PARAM is read in the runner's own dialect, through the
// engine: its spelling of the model key is not the preset's (an MTPLX
// preset's `m` is not a model key, for instance).
model := sel.Model
if model == "" {
engine, err := engineFor(dc.Runner)
if err != nil {
return dc, err
}
model, _ = paramModel(engine, paramLayer)
}
if model == "" {
model = presetValue(modelPresetKey(dc.Runner), global, params)
}
if model == "" {
return dc, fmt.Errorf(
"nothing to deploy: set MODEL in %s, or %s in its preset",
spinloopPath, modelPresetKey(dc.Runner))
fix := fmt.Sprintf("set MODEL in %s, or %s in its preset", spinloopPath, modelPresetKey(dc.Runner))
if len(modelSourceFlags(dc.Runner)) > 0 {
fix += ", or a PARAM naming the model"
}
return dc, fmt.Errorf("nothing to deploy: %s", fix)
}
// A local model path is refused only where the destination fetches the
// weights itself — the cloud, which cannot ship a file. A node has the file
Expand All @@ -981,10 +1020,10 @@ func deployConfig(sel spinloop.Selection, spinloopPath string, target deployTarg

context := sel.Context
if context == "" {
context = presetValue(contextPresetKey(dc.Runner), global, params)
context = presetValue(contextPresetKey(dc.Runner), global, params, paramLayer)
}
if context == "" && target.requireContext {
return dc, fmt.Errorf("no context size: set CONTEXT in %s, or %s in its preset", spinloopPath, contextPresetKey(dc.Runner))
return dc, fmt.Errorf("no context size: set CONTEXT in %s, or %s in its preset, or a PARAM naming it", spinloopPath, contextPresetKey(dc.Runner))
}
if context != "" {
n, err := contextsize.Parse(context)
Expand All @@ -994,16 +1033,16 @@ func deployConfig(sel spinloop.Selection, spinloopPath string, target deployTarg
dc.ContextSize = n
}

// Parallel falls back to the preset's own parallelism key the same way
// context falls back to ctx-size, since that value is about to be dropped
// from serveArgs below (it is cloud-owned) — capturing it here is what
// keeps it from being silently lost rather than re-emitted as the
// Parallel falls back to the preset's and PARAMs' own parallelism key the
// same way context falls back to ctx-size, since that value is about to be
// dropped from serveArgs below (it is cloud-owned) — capturing it here is
// what keeps it from being silently lost rather than re-emitted as the
// deployment's own computed flag. The key is the runner's own spelling:
// reading only llama.cpp's would drop a vLLM preset's max-num-seqs on the
// floor, since dropOwned removes it either way.
parallel := sel.Parallel
if key := parallelPresetKey(dc.Runner); parallel == "" && key != "" {
parallel = presetValue(key, global, params)
parallel = presetValue(key, global, params, paramLayer)
}
if parallel != "" {
n, err := parseParallel(parallel)
Expand All @@ -1020,20 +1059,25 @@ func deployConfig(sel spinloop.Selection, spinloopPath string, target deployTarg
dc.ServedModelName = dc.ModelID
}

// Companion weights are read from the same preset keys that drive a local
// serve, so one preset still works both ways. Only where the destination
// fetches the weights itself: it pulls them from the model's own repo, so
// only the filename travels and the local path the user downloaded to is
// meaningless there. A node already has its files, and keeps its own path.
// Companion weights are read from the same preset and PARAM keys that
// drive a local serve, so one Spinloop still works both ways. Only where
// the destination fetches the weights itself: it pulls them from the
// model's own repo, so only the filename travels and the local path the
// user downloaded to is meaningless there. A node already has its files,
// and keeps its own path.
if target.seedsWeights {
dc.Companions = companionsFrom(global, params)
dc.Companions = companionsFrom(global, params, paramLayer)
}

// The Spinloop's own bind is the final layer, where it travels at all: it
// wins over the preset's host and port exactly as it does for a local
// serve, so a woken engine listens wherever the Spinloop says rather than
// on the engine's default.
layers := [][]preset.Param{dropOwned(target.owns, global), dropOwned(target.owns, params)}
// wins over the preset's and PARAMs' host and port exactly as it does for
// a local serve, so a woken engine listens wherever the Spinloop says
// rather than on the engine's default.
layers := [][]preset.Param{
dropOwned(target.owns, global),
dropOwned(target.owns, params),
dropOwned(target.owns, paramLayer),
}
if target.carriesBaseURL {
bind, err := bindAddressParams(sel)
if err != nil {
Expand Down
207 changes: 207 additions & 0 deletions cmd/spinloop/cloud_deploy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,213 @@ func TestDeployConfigFor_Rejects(t *testing.T) {
}
}

// A PARAM is a layer of its own in the deployment: it supplies the model, the
// window, and the slot count when the Spinloop does not state them, and its
// remaining values ride into ServeArgs with the same owned-flag dropping a
// preset gets — so a Spinloop with no preset file at all can still be
// deployed, on the same terms `spinloop serve` runs it.
func TestDeployConfigFor_FromParams(t *testing.T) {
dc := deployConfigFrom(t,
"PROVIDER llamacpp\nALIAS qwen3.8-27b\n"+
"PARAM hf = unsloth/Qwen3.8-27B-GGUF:UD-Q5_K_XL\n"+
"PARAM ctx-size = 131072\n"+
"PARAM np = 2\n"+
"PARAM ngl = 99\n"+
"PARAM fa = on\n", "")

if dc.Runner != "llamacpp" {
t.Errorf("runner = %q, want llamacpp", dc.Runner)
}
if dc.ModelID != "unsloth/Qwen3.8-27B-GGUF" || dc.Quant != "UD-Q5_K_XL" {
t.Errorf("model = %q quant = %q, want the PARAM's repo and quant split", dc.ModelID, dc.Quant)
}
if dc.ContextSize != 131072 {
t.Errorf("contextSize = %d, want the PARAM's ctx-size", dc.ContextSize)
}
if dc.Parallel != 2 {
t.Errorf("parallel = %d, want the PARAM's np", dc.Parallel)
}
if dc.ServedModelName != "qwen3.8-27b" {
t.Errorf("servedModelName = %q, want the ALIAS", dc.ServedModelName)
}
args := strings.Join(dc.ServeArgs, " ")
// The PARAMs the deployment does not compute itself ride through.
for _, want := range []string{"--n-gpu-layers 99", "--flash-attn on"} {
if !strings.Contains(args, want) {
t.Errorf("serveArgs %q is missing the PARAM's %q", args, want)
}
}
// Set by the cloud, so the PARAMs' values must not be echoed back — the
// model travels as ModelID and Quant, not as a flag.
for _, unwanted := range []string{"--hf-repo", "--ctx-size", "--parallel", "unsloth/Qwen3.8-27B-GGUF"} {
if strings.Contains(args, unwanted) {
t.Errorf("serveArgs %q should not carry the cloud-owned %q", args, unwanted)
}
}
}

// Where a flag is set both in the preset and as a PARAM, the Spinloop's own
// PARAM stands over the preset — the same precedence a local serve gives it —
// and a preset value the PARAM does not set still rides through.
func TestDeployConfigFor_ParamBeatsPreset(t *testing.T) {
preset := "[*]\n" +
"ngl = 10\n" +
"\n" +
"[q]\n" +
"hf = org/model:Q4_K_M\n" +
"ctx-size = 4096\n" +
"np = 4\n" +
"temp = 0.7\n"
dc := deployConfigFrom(t,
"PROVIDER llamacpp\nALIAS q\n"+
"PARAM hf = org/other:Q8_0\n"+
"PARAM ctx-size = 8192\n"+
"PARAM temp = 1.0\n"+
"PRESET ./preset.ini\n", preset)

// The model falls back MODEL, then PARAM, then the preset: the PARAM's
// repo deploys.
if dc.ModelID != "org/other" || dc.Quant != "Q8_0" {
t.Errorf("the PARAM's hf should win over the preset's, got %q:%q", dc.ModelID, dc.Quant)
}
// The window: the PARAM stands over the preset's ctx-size.
if dc.ContextSize != 8192 {
t.Errorf("contextSize = %d, want the PARAM's ctx-size, not the preset's", dc.ContextSize)
}
// The slot count: the PARAM does not set it, so the preset's np stands.
if dc.Parallel != 4 {
t.Errorf("parallel = %d, want the preset's np (no PARAM sets it)", dc.Parallel)
}
args := strings.Join(dc.ServeArgs, " ")
if !strings.Contains(args, "--temp 1.0") || strings.Contains(args, "0.7") {
t.Errorf("the PARAM's temp should stand over the preset's, got %q", args)
}
// A preset value no PARAM sets still rides through, from the [*] globals.
if !strings.Contains(args, "--n-gpu-layers 10") {
t.Errorf("the preset's ngl should ride through, got %q", args)
}
}

// The owned-flag drop is per-destination, and the PARAM layer goes through it
// exactly as the preset's layers do: a PARAM's bind is dropped for a cloud
// deploy (the cloud assigns its own) and stands for a node wake (the machine
// is the operator's), and the node wake derives the model, the window, and the
// slot count from PARAMs the same way a deploy does.
func TestDeployConfigForNode_ParamsLikePreset(t *testing.T) {
spinloopPath := writeDeploySpinloop(t,
"PROVIDER llamacpp\nALIAS qwen\n"+
"PARAM hf = org/model:Q4_K_M\n"+
"PARAM ctx-size = 4096\n"+
"PARAM np = 4\n"+
"PARAM host = 0.0.0.0\n"+
"PARAM port = 9090\n", "")
sel, _, err := readSpinloop("test", spinloopPath)
if err != nil {
t.Fatal(err)
}

node, err := deployConfigForNode(sel, spinloopPath)
if err != nil {
t.Fatalf("deployConfigForNode: %v", err)
}
if node.ModelID != "org/model" || node.Quant != "Q4_K_M" {
t.Errorf("model = %q:%q, want the PARAM's hf", node.ModelID, node.Quant)
}
if node.ContextSize != 4096 {
t.Errorf("contextSize = %d, want the PARAM's ctx-size", node.ContextSize)
}
if node.Parallel != 4 {
t.Errorf("parallel = %d, want the PARAM's np", node.Parallel)
}
nodeArgs := strings.Join(node.ServeArgs, " ")
for _, want := range []string{"--host 0.0.0.0", "--port 9090"} {
if !strings.Contains(nodeArgs, want) {
t.Errorf("a node's serve args should keep the PARAM's bind %q, got: %s", want, nodeArgs)
}
}
for _, unwanted := range []string{"--hf-repo", "--ctx-size", "--parallel"} {
if strings.Contains(nodeArgs, unwanted) {
t.Errorf("a node's serve args should not repeat %q, got: %s", unwanted, nodeArgs)
}
}

// The cloud still drops the same bind: it assigns its own.
sel.Context = "4096" // the cloud requires one
cloud, err := deployConfigFor(sel, spinloopPath)
if err != nil {
t.Fatal(err)
}
cloudArgs := strings.Join(cloud.ServeArgs, " ")
for _, unwanted := range []string{"--host", "--port", "0.0.0.0", "9090"} {
if strings.Contains(cloudArgs, unwanted) {
t.Errorf("the cloud sets its own bind, so %q should be dropped, got: %s", unwanted, cloudArgs)
}
}
}

// A PARAM naming the model may be a local path, the way a MODEL may be: the
// cloud refuses it (it cannot ship a file), and a node wake carries it, the
// machine having the file the Spinloop points at.
func TestDeployConfigFor_ParamNamesLocalModelPath(t *testing.T) {
spinloopPath := writeDeploySpinloop(t,
"PROVIDER llamacpp\nCONTEXT 32k\nPARAM hf = ./models/qwen.gguf\n", "")
sel, _, err := readSpinloop("test", spinloopPath)
if err != nil {
t.Fatal(err)
}

node, err := deployConfigForNode(sel, spinloopPath)
if err != nil {
t.Fatalf("a node wake with a PARAM's local model path should succeed: %v", err)
}
if node.ModelID != "./models/qwen.gguf" {
t.Errorf("modelId = %q, want the local path", node.ModelID)
}

// The cloud still refuses the same file: it downloads from Hugging Face.
if _, err := deployConfigFor(sel, spinloopPath); err == nil ||
!strings.Contains(err.Error(), "cannot deploy the local model file") {
t.Errorf("the cloud should refuse a PARAM's local model path, got %v", err)
}
}

// vLLM takes its model positionally, so a PARAM naming one is not a model to
// it: the error stays MODEL-and-preset, unlike the flag-taking engines whose
// error names a PARAM as a third way.
func TestDeployConfigFor_VllmParamsAreNotAModel(t *testing.T) {
spinloopPath := writeDeploySpinloop(t,
"PROVIDER vllm\nCONTEXT 32k\nPARAM hf = org/model\n", "")
sel, _, err := readSpinloop("test", spinloopPath)
if err != nil {
t.Fatal(err)
}
_, err = deployConfigFor(sel, spinloopPath)
if err == nil || !strings.Contains(err.Error(), "nothing to deploy") {
t.Fatalf("want a nothing-to-deploy error, got %v", err)
}
if strings.Contains(err.Error(), "PARAM") {
t.Errorf("vLLM takes its model positionally, so the error should not name a PARAM: %v", err)
}
}

// The flag-taking engines' error does name the PARAM, as the third way a model
// may be given.
func TestDeployConfigFor_NoModelErrorNamesAParam(t *testing.T) {
spinloopPath := writeDeploySpinloop(t,
"PROVIDER llamacpp\nCONTEXT 32k\n", "")
sel, _, err := readSpinloop("test", spinloopPath)
if err != nil {
t.Fatal(err)
}
_, err = deployConfigFor(sel, spinloopPath)
if err == nil || !strings.Contains(err.Error(), "nothing to deploy") {
t.Fatalf("want a nothing-to-deploy error, got %v", err)
}
if !strings.Contains(err.Error(), "a PARAM naming the model") {
t.Errorf("the error should name a PARAM as an alternative: %v", err)
}
}

func TestSplitModelQuant(t *testing.T) {
for _, tc := range []struct{ in, repo, quant string }{
{"org/model:Q4_K_M", "org/model", "Q4_K_M"},
Expand Down
Loading
Loading