Skip to content
29 changes: 29 additions & 0 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,10 @@ paths:
the form layer, model and variant, and only what the request names moves.
With `reuseSource: true`, it instead reads the live LLMService plan and
merges arbitrary form overrides and optional edit values, without resolving a catalog.

Each of the variant's `inputs` is answered in the `derived` layer by the
site profile's `inputs`, else the input's `default`; `overrides` go over
it. A plan that leaves one without a valid answer is 400.
operationId: composePlan
requestBody:
required: true
Expand Down Expand Up @@ -1788,6 +1792,17 @@ components:
extra:
type: object
description: Values every release on this site gets, as the site layer.
inputs:
type: object
description: |
Answers to variants' `inputs`, by each input's key. Unlike `extra`, an
answer reaches only a variant that declares the key, in the `derived`
layer. A string may use `model.pathTemplate`'s placeholders
(`{{hf}}`, `{{org}}`, `{{name}}`, `{{model}}`). A key `extra` also sets is
refused.
additionalProperties: { type: [string, number, boolean] }
examples:
- storageKVOffload.mooncake.master: mooncake-master.svc:50051

Secrets:
type: object
Expand Down Expand Up @@ -1825,6 +1840,19 @@ components:
minGpuMemoryGiB: { type: number }
rdma: { type: boolean }

Input:
type: object
description: A chart value every deploy must answer. Answered in the `derived` layer by the site profile's `inputs`, else `default`; a deploy's overrides go over it.
required: [key, title, example]
properties:
key: { type: string, description: "The chart value's dotted key.", examples: [storageKVOffload.mooncake.master] }
title: { type: string }
description: { type: string }
type: { type: string, enum: [string, integer, boolean], default: string }
pattern: { type: string, description: "A string answer matches it, whole." }
example: { type: [string, integer, boolean], description: "The form's placeholder; never deployed." }
default: { type: [string, integer, boolean], description: "The answer when the site profile has none." }

ModelVariant:
type: object
required: [id, version, digest, engine, chart, requires]
Expand Down Expand Up @@ -1907,6 +1935,7 @@ components:
tag: { type: string }
digest: { type: string }
requires: { $ref: "#/components/schemas/Requires" }
inputs: { type: array, items: { $ref: "#/components/schemas/Input" } }
values: { type: object, description: "The catalog layer's chart values." }
version: { type: string }
digest: { type: string, description: "sha256 of the file this version pins." }
Expand Down
20 changes: 11 additions & 9 deletions internal/catalog/entry.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,17 @@ type Source struct {
// together, which is why they are one object rather than independent form
// fields: nothing should be able to select --tp-size=8 and a 2-GPU node.
type Variant struct {
ID string `yaml:"id" json:"id"`
Description string `yaml:"description,omitempty" json:"description,omitempty"`
Link string `yaml:"link,omitempty" json:"link,omitempty"`
Default bool `yaml:"default,omitempty" json:"default,omitempty"`
Engine string `yaml:"engine" json:"engine"`
Chart Chart `yaml:"chart" json:"chart"`
Image *Image `yaml:"image,omitempty" json:"image,omitempty"`
Requires Requires `yaml:"requires" json:"requires"`
Values values.Tree `yaml:"values,omitempty" json:"values,omitempty"`
ID string `yaml:"id" json:"id"`
Description string `yaml:"description,omitempty" json:"description,omitempty"`
Link string `yaml:"link,omitempty" json:"link,omitempty"`
Default bool `yaml:"default,omitempty" json:"default,omitempty"`
Engine string `yaml:"engine" json:"engine"`
Chart Chart `yaml:"chart" json:"chart"`
Image *Image `yaml:"image,omitempty" json:"image,omitempty"`
Requires Requires `yaml:"requires" json:"requires"`
// Inputs are chart values every deploy must answer.
Inputs []Input `yaml:"inputs,omitempty" json:"inputs,omitempty"`
Values values.Tree `yaml:"values,omitempty" json:"values,omitempty"`
}

// Chart carries a name and a version, never a repository: a public catalog
Expand Down
159 changes: 159 additions & 0 deletions internal/catalog/inputs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package catalog

import (
"fmt"
"math"
"regexp"
"strings"

"github.com/modelsphere/swiss/internal/values"
)

// Input is one inputs entry. Its rules repeat the catalog's
// hack/lib/inputs.js: the catalog is untrusted where it is used.
type Input struct {
// Key is the chart value's dotted key, as helm --set takes it: storageKVOffload.mooncake.master.
Key string `yaml:"key" json:"key"`
Title string `yaml:"title" json:"title"`
Description string `yaml:"description,omitempty" json:"description,omitempty"`
// Type is string, integer or boolean. Empty is string.
Type string `yaml:"type,omitempty" json:"type,omitempty"`
// Pattern is what a whole string answer matches.
Pattern string `yaml:"pattern,omitempty" json:"pattern,omitempty"`
// Example is the form's placeholder and what catalog CI renders with; never deployed.
Example any `yaml:"example" json:"example"`
// Default answers the input when the site profile does not.
Default any `yaml:"default,omitempty" json:"default,omitempty"`
}

const (
InputString = "string"
InputInteger = "integer"
InputBoolean = "boolean"
)

var inputKey = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_-]*(\.[A-Za-z_][A-Za-z0-9_-]*)*$`)

// ValidInputKey is shared with the site profile, which keys its answers the same way.
func ValidInputKey(k string) bool { return inputKey.MatchString(k) }

func (i Input) TypeOrDefault() string {
if i.Type == "" {
return InputString
}
return i.Type
}

// Check reports why v does not answer i. A whole float64 is an integer: JSON decodes numbers so.
func (i Input) Check(v any) error {
switch t := i.TypeOrDefault(); t {
case InputString:
s, ok := v.(string)
if !ok {
return fmt.Errorf("%v is not a string", v)
}
if i.Pattern == "" {
return nil
}
re, err := i.pattern()
if err != nil {
return err
}
if !re.MatchString(s) {
return fmt.Errorf("%q does not match %s", s, i.Pattern)
}
case InputInteger:
if !isInteger(v) {
return fmt.Errorf("%v is not an integer", v)
}
case InputBoolean:
if _, ok := v.(bool); !ok {
return fmt.Errorf("%v is not a boolean", v)
}
default:
return fmt.Errorf("type %q is not string, integer or boolean", t)
}
return nil
}

func (i Input) pattern() (*regexp.Regexp, error) {
re, err := regexp.Compile(`^(?:` + i.Pattern + `)$`)
if err != nil {
return nil, fmt.Errorf("pattern %s does not compile: %w", i.Pattern, err)
}
return re, nil
}

func isInteger(v any) bool {
switch n := v.(type) {
case int, int64, uint64:
return true
case float64:
return n == math.Trunc(n) && !math.IsInf(n, 0)
}
return false
}

// checkInputs: values leaves every input to compose, which answers it in the derived layer.
func checkInputs(inputs []Input, vals values.Tree) error {
declared := map[string]bool{}
for _, in := range inputs {
where := "inputs " + in.Key
switch {
case !ValidInputKey(in.Key):
return fmt.Errorf("inputs: key %q is not a dotted chart key", in.Key)
case in.Key == "model.localPath":
return fmt.Errorf("%s: swiss answers it from the site's model.pathTemplate, so it is never an input", where)
case declared[in.Key]:
return fmt.Errorf("%s: declared twice", where)
case strings.TrimSpace(in.Title) == "":
return fmt.Errorf("%s: title is required", where)
case in.Pattern != "" && in.TypeOrDefault() != InputString:
return fmt.Errorf("%s: pattern is only for a string input, and this one is %s", where, in.TypeOrDefault())
}
declared[in.Key] = true
if at := setAt(vals, strings.Split(in.Key, ".")); at != "" {
return fmt.Errorf("%s: values.%s sets it; give the value as the input's default", where, at)
}
if in.Pattern != "" {
if _, err := in.pattern(); err != nil {
return fmt.Errorf("%s: %w", where, err)
}
}
if in.Example == nil {
return fmt.Errorf("%s: example is required", where)
}
if err := in.Check(in.Example); err != nil {
return fmt.Errorf("%s: example %w", where, err)
}
if in.Default != nil {
if err := in.Check(in.Default); err != nil {
return fmt.Errorf("%s: default %w", where, err)
}
}
}
return nil
}

// setAt is where vals already says something about keys: the value, under it, or a scalar above it.
func setAt(vals values.Tree, keys []string) string {
node := map[string]any(vals)
for i, k := range keys {
next, ok := node[k]
if !ok {
return ""
}
if i == len(keys)-1 {
return strings.Join(keys, ".")
}
switch m := next.(type) {
case map[string]any:
node = m
case values.Tree:
node = m
default:
return strings.Join(keys[:i+1], ".")
}
}
return ""
}
100 changes: 100 additions & 0 deletions internal/catalog/inputs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package catalog

import (
"context"
"fmt"
"strings"
"testing"

"github.com/modelsphere/swiss/internal/values"
)

var mooncakeMaster = Input{
Key: "storageKVOffload.mooncake.master",
Title: "Mooncake master",
Pattern: `[^\s:/]+:[0-9]+`,
Example: "mooncake-master.example:50051",
}

func inputVariant(inputs []Input, vals values.Tree) Variant {
return Variant{
ID: "a",
Engine: "sglang",
Chart: Chart{Name: "sglang", Version: "0.9.32"},
Requires: Requires{GPUs: 8},
Inputs: inputs,
Values: vals,
}
}

// The same rules as the catalog's validator (hack/lib/inputs.js), because the
// catalog is untrusted where it is used.
func TestAnInputsDefaultAndExampleAreAnswers(t *testing.T) {
withDefault := mooncakeMaster
withDefault.Default = "mc.svc:50051"
for _, in := range []Input{mooncakeMaster, withDefault} {
ok := inputVariant([]Input{in}, values.Tree{"storageKVOffload": map[string]any{"enabled": true, "mooncake": map[string]any{"protocol": "rdma"}}})
if err := ok.validate(); err != nil {
t.Errorf("default %v: %v", in.Default, err)
}
}
one := func(in Input) []Input { return []Input{in} }
at := func(in Input, edit func(*Input)) Input { edit(&in); return in }
for name, c := range map[string]struct {
v Variant
want string
}{
"values sets it": {inputVariant(one(mooncakeMaster), values.Tree{"storageKVOffload": map[string]any{"mooncake": map[string]any{"master": "mc.svc:50051"}}}), "values.storageKVOffload.mooncake.master sets it; give the value as the input's default"},
"a non-map above it": {inputVariant(one(mooncakeMaster), values.Tree{"storageKVOffload": map[string]any{"mooncake": "off"}}), "values.storageKVOffload.mooncake sets it"},
"default off pattern": {inputVariant(one(at(mooncakeMaster, func(i *Input) { i.Default = "no-port" })), nil), `default "no-port" does not match`},
"a blank default": {inputVariant(one(at(mooncakeMaster, func(i *Input) { i.Default = "" })), nil), `default "" does not match`},
"default of a type": {inputVariant(one(Input{Key: "a.b", Title: "t", Type: InputInteger, Example: 1, Default: "8"}), nil), "default 8 is not an integer"},
"declared twice": {inputVariant([]Input{mooncakeMaster, mooncakeMaster}, nil), "declared twice"},
"the model path": {inputVariant(one(Input{Key: "model.localPath", Title: "p", Example: "/m"}), nil), "never an input"},
"not a key": {inputVariant(one(at(mooncakeMaster, func(i *Input) { i.Key = "a..b" })), nil), "not a dotted chart key"},
"no title": {inputVariant(one(at(mooncakeMaster, func(i *Input) { i.Title = " " })), nil), "title is required"},
"no example": {inputVariant(one(at(mooncakeMaster, func(i *Input) { i.Example = nil })), nil), "example is required"},
"example off pattern": {inputVariant(one(at(mooncakeMaster, func(i *Input) { i.Example = "no-port" })), nil), `example "no-port" does not match`},
"pattern compiles": {inputVariant(one(at(mooncakeMaster, func(i *Input) { i.Pattern = "(" })), nil), "does not compile"},
"pattern on integer": {inputVariant(one(Input{Key: "a.b", Title: "t", Type: InputInteger, Pattern: "[0-9]+", Example: 1}), nil), "only for a string input"},
"example of a type": {inputVariant(one(Input{Key: "a.b", Title: "t", Type: InputInteger, Example: "8"}), nil), "8 is not an integer"},
"unknown type": {inputVariant(one(Input{Key: "a.b", Title: "t", Type: "list", Example: "x"}), nil), `type "list" is not`},
} {
if err := c.v.validate(); err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%s: want an error containing %q, got %v", name, c.want, err)
}
}
}

// A number read from JSON is a float64: a whole one answers an integer input.
func TestAnIntegerInputTakesAWholeNumberOfAnyKind(t *testing.T) {
port := Input{Key: "a.port", Title: "Port", Type: InputInteger, Example: 1}
for _, v := range []any{8080, int64(8080), float64(8080)} {
if err := port.Check(v); err != nil {
t.Errorf("%T %v: %v", v, v, err)
}
}
if err := port.Check(80.5); err == nil {
t.Error("80.5 answered an integer input")
}
}

// inputs is a known key of a v2 variant file, read strictly. The index carries
// no inputs: nothing lists or picks a variant by them.
func TestV2InputsAreReadFromTheFile(t *testing.T) {
raw := []byte("apiVersion: catalog.swiss/v2\nkind: Variant\nmodel: m\nid: a\nversion: 1.0.0\nengine: sglang\nchart: {name: sglang, version: \"0.9.32\"}\n" +
"requires:\n gpus: 1\ninputs:\n - {key: storageKVOffload.mooncake.master, title: Mooncake master, example: \"h:1\", default: \"d:1\"}\n")
f := memFetcher{"models/m/a.yaml": raw}
f["index-v2.json"] = []byte(fmt.Sprintf(`{"apiVersion":"catalog.swiss/v2","count":1,"models":[{"name":"m","source":{"hf":"org/m"},"variants":[{"id":"a","version":"1.0.0","path":"models/m/a.yaml","digest":%q,"engine":"sglang","chart":{"name":"sglang","version":"0.9.32"},"requires":{"gpus":1}}]}]}`, contentRef(raw)))
c, err := OpenFetcher(context.Background(), f)
if err != nil {
t.Fatal(err)
}
r, err := c.Resolve(context.Background(), "m", "", "")
if err != nil {
t.Fatal(err)
}
if in := r.Variant.Inputs; len(in) != 1 || in[0].Key != "storageKVOffload.mooncake.master" || in[0].Example != "h:1" || in[0].Default != "d:1" {
t.Errorf("inputs = %+v", in)
}
}
3 changes: 3 additions & 0 deletions internal/catalog/load.go
Original file line number Diff line number Diff line change
Expand Up @@ -352,5 +352,8 @@ func (v Variant) validate() error {
return fmt.Errorf("%s: values.lws.size (%v) != requires.nodes (%d)", where, size, v.Requires.NodesOrDefault())
}
}
if err := checkInputs(v.Inputs, v.Values); err != nil {
return fmt.Errorf("%s: %w", where, err)
}
return nil
}
3 changes: 2 additions & 1 deletion internal/catalog/variant_v2.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ type VariantV2 struct {
Chart Chart `yaml:"chart" json:"chart"`
Image *Image `yaml:"image,omitempty" json:"image,omitempty"`
Requires Requires `yaml:"requires" json:"requires"`
Inputs []Input `yaml:"inputs,omitempty" json:"inputs,omitempty"`
Values values.Tree `yaml:"values,omitempty" json:"values,omitempty"`
// Deprecated comes before deleting a file; a string is the reason.
Deprecated any `yaml:"deprecated,omitempty" json:"deprecated,omitempty"`
Expand Down Expand Up @@ -57,6 +58,6 @@ func ParseVariantV2(raw []byte) (*VariantV2, error) {
func (d *VariantV2) variant(isDefault bool) Variant {
return Variant{
ID: d.ID, Description: d.Description, Link: d.Link, Default: isDefault,
Engine: d.Engine, Chart: d.Chart, Image: d.Image, Requires: d.Requires, Values: d.Values,
Engine: d.Engine, Chart: d.Chart, Image: d.Image, Requires: d.Requires, Inputs: d.Inputs, Values: d.Values,
}
}
Loading
Loading