From b3f78b77328bccc4f5af336d16efee69407dbcb1 Mon Sep 17 00:00:00 2001 From: Daniel Hiltgen Date: Tue, 29 Sep 2026 11:10:25 -0700 Subject: [PATCH] create: support explicit model capabilities (#18708) Add CAPABILITY declarations to Modelfiles and an additive capabilities field to create requests. Preserve declarations across GGUF and safetensors creation, inheritance, and Modelfile export. Require decision capability before scheduling System One requests instead of matching Qwen architecture/renderer metadata. Retain main's GGUF-only scoring restriction until the separate MLX runtime work lands. Extracted from the capability foundation in 36d46a0c3 on system_one_mlx; MLX scoring and manifest-list changes are intentionally separate. --- api/types.go | 3 + cmd/create_safetensors.go | 41 +++++++----- cmd/create_safetensors_remote.go | 1 + cmd/create_safetensors_remote_test.go | 20 +++--- cmd/create_safetensors_test.go | 32 +++++++--- create/manifest.go | 12 ++-- docs/api.md | 1 + docs/modelfile.mdx | 10 +++ docs/openapi.yaml | 5 ++ parser/parser.go | 12 +++- parser/parser_test.go | 38 +++++++++++ server/capabilities_test.go | 90 +++++++++++++++++++++++++++ server/create.go | 8 +++ server/images.go | 7 +++ server/routes.go | 6 +- server/routes_create_test.go | 7 ++- server/systemone_test.go | 25 +++++--- types/model/capability.go | 12 ++++ types/model/config.go | 12 +++- 19 files changed, 285 insertions(+), 57 deletions(-) create mode 100644 server/capabilities_test.go diff --git a/api/types.go b/api/types.go index 30a5990b3..f5b04b14a 100644 --- a/api/types.go +++ b/api/types.go @@ -703,6 +703,9 @@ type CreateRequest struct { // Requires is the minimum version of Ollama required by the model. Requires string `json:"requires,omitempty"` + // Capabilities adds to the model's inherited or inferred capabilities. + Capabilities []string `json:"capabilities,omitempty"` + // Info is a map of additional information for the model Info map[string]any `json:"info,omitempty"` diff --git a/cmd/create_safetensors.go b/cmd/create_safetensors.go index e9b276590..a4e1a375d 100644 --- a/cmd/create_safetensors.go +++ b/cmd/create_safetensors.go @@ -21,15 +21,16 @@ import ( // modelfileConfig holds configuration extracted from a Modelfile. type modelfileConfig struct { - Template string - System string - Licenses []string - Draft string - Parser string - Renderer string - Requires string - Parameters map[string]any - Messages []api.Message + Template string + System string + Licenses []string + Draft string + Parser string + Renderer string + Requires string + Capabilities []string + Parameters map[string]any + Messages []api.Message } var ignoredModelfileParameters = []string{ @@ -66,6 +67,11 @@ func configFromModelfile(modelfile *parser.Modelfile) (string, *modelfileConfig, mfConfig.Parser = cmd.Args case "renderer": mfConfig.Renderer = cmd.Args + case "capability": + if !model.Capability(cmd.Args).IsValid() { + return "", nil, fmt.Errorf("unknown capability: %q", cmd.Args) + } + mfConfig.Capabilities = append(mfConfig.Capabilities, cmd.Args) case "requires": requires := cmd.Args if !strings.HasPrefix(requires, "v") { @@ -364,6 +370,7 @@ func newManifestWriter(opts createOptions) create.ManifestWriter { var license any var parameters map[string]any var messages []api.Message + var capabilities []string if opts.Modelfile != nil { template = opts.Modelfile.Template system = opts.Modelfile.System @@ -373,14 +380,16 @@ func newManifestWriter(opts createOptions) create.ManifestWriter { draftDir = opts.Modelfile.Draft parameters = opts.Modelfile.Parameters messages = opts.Modelfile.Messages + capabilities = opts.Modelfile.Capabilities } return create.NewSafetensorsManifestWriter(create.SafetensorsManifestOptions{ - MinVersion: create.SafetensorsMinOllamaVersion, - DraftDir: draftDir, - Template: template, - System: system, - License: license, - Parameters: parameters, - Messages: messages, + MinVersion: create.SafetensorsMinOllamaVersion, + DraftDir: draftDir, + Template: template, + System: system, + License: license, + Parameters: parameters, + Messages: messages, + Capabilities: capabilities, }) } diff --git a/cmd/create_safetensors_remote.go b/cmd/create_safetensors_remote.go index 2db47bf36..f3ca59186 100644 --- a/cmd/create_safetensors_remote.go +++ b/cmd/create_safetensors_remote.go @@ -287,6 +287,7 @@ func newRemoteCreateRequest(opts createOptions, files []remoteSourceFile) *api.C req.System = opts.Modelfile.System req.Parameters = opts.Modelfile.Parameters req.Messages = opts.Modelfile.Messages + req.Capabilities = opts.Modelfile.Capabilities if len(opts.Modelfile.Licenses) > 0 { req.License = opts.Modelfile.Licenses } diff --git a/cmd/create_safetensors_remote_test.go b/cmd/create_safetensors_remote_test.go index 72bfdae7e..71bf59f00 100644 --- a/cmd/create_safetensors_remote_test.go +++ b/cmd/create_safetensors_remote_test.go @@ -127,14 +127,15 @@ func TestNewRemoteCreateRequest(t *testing.T) { Quantize: "nvfp4", DraftQuantize: "mxfp8", Modelfile: &modelfileConfig{ - Template: "{{ .Prompt }}", - System: "system", - Licenses: []string{"MIT", "Apache-2.0"}, - Parser: "mf-parser", - Renderer: "mf-renderer", - Requires: "0.20.0", - Parameters: map[string]any{"temperature": float32(0.1)}, - Messages: []api.Message{{Role: "user", Content: "hello"}}, + Template: "{{ .Prompt }}", + System: "system", + Licenses: []string{"MIT", "Apache-2.0"}, + Parser: "mf-parser", + Renderer: "mf-renderer", + Requires: "0.20.0", + Capabilities: []string{"decision"}, + Parameters: map[string]any{"temperature": float32(0.1)}, + Messages: []api.Message{{Role: "user", Content: "hello"}}, }, }, []remoteSourceFile{ {logical: "config.json", digest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, @@ -155,6 +156,9 @@ func TestNewRemoteCreateRequest(t *testing.T) { if req.Requires != "0.20.0" { t.Fatalf("Requires = %q, want 0.20.0", req.Requires) } + if !slices.Equal(req.Capabilities, []string{"decision"}) { + t.Fatalf("Capabilities = %v, want decision", req.Capabilities) + } if licenses, ok := req.License.([]string); !ok || !slices.Equal(licenses, []string{"MIT", "Apache-2.0"}) { t.Fatalf("License = %#v, want both licenses", req.License) } diff --git a/cmd/create_safetensors_test.go b/cmd/create_safetensors_test.go index 78a587510..3c94b1f0f 100644 --- a/cmd/create_safetensors_test.go +++ b/cmd/create_safetensors_test.go @@ -108,6 +108,8 @@ LICENSE MIT LICENSE Apache-2.0 PARSER test-parser RENDERER test-renderer +CAPABILITY decision +CAPABILITY tools REQUIRES 0.20.0 MESSAGE user Hello PARAMETER temperature 0.7 @@ -131,14 +133,15 @@ PARAMETER stop ASSISTANT: t.Fatalf("CreateRequest license = %#v, want []string", req.License) } want := &modelfileConfig{ - Template: req.Template, - System: req.System, - Licenses: licenses, - Parser: req.Parser, - Renderer: req.Renderer, - Requires: req.Requires, - Parameters: req.Parameters, - Messages: req.Messages, + Template: req.Template, + System: req.System, + Licenses: licenses, + Parser: req.Parser, + Renderer: req.Renderer, + Requires: req.Requires, + Parameters: req.Parameters, + Messages: req.Messages, + Capabilities: req.Capabilities, } if modelDir != req.From { t.Fatalf("model source = %q, want %q", modelDir, req.From) @@ -148,6 +151,15 @@ PARAMETER stop ASSISTANT: } } +func TestConfigFromModelfileRejectsUnknownCapability(t *testing.T) { + for _, capability := range []string{"", "system-one", "decision tools"} { + mf := &parser.Modelfile{Commands: []parser.Command{{Name: "capability", Args: capability}}} + if _, _, err := configFromModelfile(mf); err == nil || !strings.Contains(err.Error(), "unknown capability") { + t.Fatalf("capability %q: got %v, want unknown capability", capability, err) + } + } +} + func TestConfigFromModelfilePreservesRequiresBelowSafetensorsMinimum(t *testing.T) { modelfile, err := parser.ParseFile(strings.NewReader(` FROM ./model @@ -421,6 +433,7 @@ func TestNewManifestWriter_PopulatesFileTypeFromEffectiveQuantize(t *testing.T) opts := createOptions{ ModelName: "test-quantized", ModelDir: t.TempDir(), + Modelfile: &modelfileConfig{Capabilities: []string{"decision", "completion", "decision"}}, } writer := newManifestWriter(opts) @@ -456,6 +469,9 @@ func TestNewManifestWriter_PopulatesFileTypeFromEffectiveQuantize(t *testing.T) if cfg.FileType != "mxfp8" { t.Fatalf("FileType = %q, want %q", cfg.FileType, "mxfp8") } + if want := []string{"completion", "decision"}; !slices.Equal(cfg.Capabilities, want) { + t.Fatalf("Capabilities = %v, want %v", cfg.Capabilities, want) + } } func TestNewManifestWriterPreservesMultipleLicenses(t *testing.T) { diff --git a/create/manifest.go b/create/manifest.go index 170479108..0e1a283ac 100644 --- a/create/manifest.go +++ b/create/manifest.go @@ -32,11 +32,12 @@ type SafetensorsManifestOptions struct { MinVersion string DraftDir string - Template string - System string - License any - Parameters map[string]any - Messages []api.Message + Template string + System string + License any + Parameters map[string]any + Messages []api.Message + Capabilities []string BeforeWriteManifest func() } @@ -64,6 +65,7 @@ func NewSafetensorsManifestWriter(opts SafetensorsManifestOptions) ManifestWrite } config := info.ModelConfig + config.AddCapabilities(opts.Capabilities...) config.ModelFormat = "safetensors" if config.Requires == "" { config.Requires = opts.MinVersion diff --git a/docs/api.md b/docs/api.md index 7787fcf8b..548e610e8 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1197,6 +1197,7 @@ If you are creating a model from a safetensors directory or from GGUF files, you - `system`: (optional) a string containing the system prompt for the model - `parameters`: (optional) a dictionary of parameters for the model (see [Modelfile](./modelfile.mdx#valid-parameters-and-values) for a list of parameters) - `messages`: (optional) a list of message objects used to create a conversation +- `capabilities`: (optional) capabilities to add without removing inherited or inferred capabilities, such as `["decision"]` for compatible decision models - `stream`: (optional) if `false` the response will be returned as a single response object, rather than a stream of objects - `quantize`: (optional) quantize safetensors model weights for MLX during import diff --git a/docs/modelfile.mdx b/docs/modelfile.mdx index 1c6ef590e..72c8cb434 100644 --- a/docs/modelfile.mdx +++ b/docs/modelfile.mdx @@ -20,6 +20,7 @@ A Modelfile is the blueprint to create and share customized models using Ollama. - [SYSTEM](#system) - [LICENSE](#license) - [MESSAGE](#message) + - [CAPABILITY](#capability) - [Notes](#notes) ## Format @@ -40,6 +41,7 @@ INSTRUCTION arguments | [`LICENSE`](#license) | Specifies the legal license. | | [`MESSAGE`](#message) | Specify message history. | | [`REQUIRES`](#requires) | Specify the minimum version of Ollama required by the model. | +| [`CAPABILITY`](#capability) | Declare an additional model capability. | ## Examples @@ -221,6 +223,14 @@ MESSAGE user Is Ontario in Canada? MESSAGE assistant yes ``` +### CAPABILITY + +Adds a capability to a GGUF or MLX model without removing inherited or inferred capabilities. Repeat the instruction to add more than one. Declare `decision` only for weights trained for System One's decision prompts: + +``` +CAPABILITY decision +``` + ### REQUIRES The `REQUIRES` instruction allows you to specify the minimum version of Ollama required by the model. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 86fc5e9dc..e79419e1f 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -550,6 +550,11 @@ components: requires: type: string description: Minimum Ollama version required by the model + capabilities: + type: array + items: + type: string + description: Capabilities to add without removing inherited or inferred capabilities (e.g. `decision` for compatible decision models) stream: type: boolean default: true diff --git a/parser/parser.go b/parser/parser.go index 23a571061..5ca018f56 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -24,6 +24,7 @@ import ( "golang.org/x/text/transform" "github.com/ollama/ollama/api" + "github.com/ollama/ollama/types/model" ) var ErrModelNotFound = errors.New("no Modelfile or safetensors files found") @@ -132,6 +133,11 @@ func (f Modelfile) CreateRequest(relativeDir string) (*api.CreateRequest, error) req.Renderer = c.Args case "parser": req.Parser = c.Args + case "capability": + if !model.Capability(c.Args).IsValid() { + return nil, fmt.Errorf("unknown capability: %q", c.Args) + } + req.Capabilities = append(req.Capabilities, c.Args) case "requires": // golang.org/x/mod/semver requires "v" prefix requires := c.Args @@ -437,7 +443,7 @@ func (c Command) String() string { switch c.Name { case "model": fmt.Fprintf(&sb, "FROM %s", c.Args) - case "license", "template", "system", "adapter", "renderer", "parser", "requires", "draft": + case "license", "template", "system", "adapter", "renderer", "parser", "requires", "draft", "capability": fmt.Fprintf(&sb, "%s %s", strings.ToUpper(c.Name), quote(c.Args)) case "message": role, message, _ := strings.Cut(c.Args, ": ") @@ -463,7 +469,7 @@ const ( var ( errMissingFrom = errors.New("no FROM line") errInvalidMessageRole = errors.New("message role must be one of \"system\", \"user\", or \"assistant\"") - errInvalidCommand = errors.New("command must be one of \"from\", \"license\", \"template\", \"system\", \"adapter\", \"draft\", \"renderer\", \"parser\", \"parameter\", \"message\", or \"requires\"") + errInvalidCommand = errors.New("command must be one of \"from\", \"license\", \"template\", \"system\", \"adapter\", \"draft\", \"renderer\", \"parser\", \"parameter\", \"message\", \"requires\", or \"capability\"") ) type ParserError struct { @@ -723,7 +729,7 @@ func isValidMessageRole(role string) bool { func isValidCommand(cmd string) bool { switch strings.ToLower(cmd) { - case "from", "license", "template", "system", "adapter", "draft", "renderer", "parser", "parameter", "message", "requires": + case "from", "license", "template", "system", "adapter", "draft", "renderer", "parser", "parameter", "message", "requires", "capability": return true default: return false diff --git a/parser/parser_test.go b/parser/parser_test.go index f295063aa..0257c03c3 100644 --- a/parser/parser_test.go +++ b/parser/parser_test.go @@ -288,6 +288,44 @@ PARSER parser1 assert.Equal(t, []Command{{Name: "model", Args: "foo"}, {Name: "parser", Args: "parser1"}}, modelfile.Commands) } +func TestModelfileCapabilities(t *testing.T) { + for _, input := range []string{ + "FROM base\nCAPABILITY decision\nCAPABILITY tools\n", + "FROM base\ncapability decision\nCAPABILITY \"tools\"", + } { + mf, err := ParseFile(strings.NewReader(input)) + if err != nil { + t.Fatal(err) + } + req, err := mf.CreateRequest(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff([]string{"decision", "tools"}, req.Capabilities); diff != "" { + t.Fatalf("capabilities (-want +got):\n%s", diff) + } + roundtrip, err := ParseFile(strings.NewReader(mf.String())) + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(mf, roundtrip); diff != "" { + t.Fatalf("Modelfile roundtrip (-want +got):\n%s", diff) + } + } +} + +func TestModelfileRejectsUnknownCapability(t *testing.T) { + for _, capability := range []string{"system-one", "decision tools", `""`} { + mf, err := ParseFile(strings.NewReader("FROM base\nCAPABILITY " + capability + "\n")) + if err != nil { + t.Fatal(err) + } + if _, err := mf.CreateRequest(t.TempDir()); err == nil || !strings.Contains(err.Error(), "unknown capability") { + t.Fatalf("capability %q: got %v, want unknown capability", capability, err) + } + } +} + func TestParseFileMessages(t *testing.T) { cases := []struct { input string diff --git a/server/capabilities_test.go b/server/capabilities_test.go new file mode 100644 index 000000000..2d934334f --- /dev/null +++ b/server/capabilities_test.go @@ -0,0 +1,90 @@ +package server + +import ( + "net/http" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/ollama/ollama/api" + "github.com/ollama/ollama/parser" + "github.com/ollama/ollama/types/model" +) + +func TestCreateCapabilities(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, format := range []string{"gguf", "safetensors"} { + t.Run(format, func(t *testing.T) { + t.Setenv("OLLAMA_MODELS", t.TempDir()) + var s Server + baseCaps := []string{"completion", "vision"} + if format == "safetensors" { + createSafetensorsTestModel(t, "base", model.ConfigV2{ + ModelFormat: format, Renderer: "qwen3.5", Capabilities: baseCaps, + }, nil) + } else { + _, digest := createBinFile(t, map[string]any{"general.architecture": "qwen35"}, nil) + w := createRequest(t, s.CreateHandler, api.CreateRequest{ + Model: "base", Files: map[string]string{"model.gguf": digest}, + Capabilities: baseCaps, Stream: &stream, + }) + if w.Code != http.StatusOK { + t.Fatalf("create base: %d %s", w.Code, w.Body) + } + } + + modelfile := "FROM base\nCAPABILITY decision\nCAPABILITY completion\nCAPABILITY decision\n" + for _, name := range []string{"declared", "roundtrip", "inherited"} { + mf, err := parser.ParseFile(strings.NewReader(modelfile)) + if err != nil { + t.Fatal(err) + } + req, err := mf.CreateRequest(t.TempDir()) + if err != nil { + t.Fatal(err) + } + req.Model, req.Stream = name, &stream + // The CLI uploads local files under their base names. + files := make(map[string]string, len(req.Files)) + for path, digest := range req.Files { + files[filepath.Base(path)] = digest + } + req.Files = files + w := createRequest(t, s.CreateHandler, req) + if w.Code != http.StatusOK { + t.Fatalf("create %s: %d %s", name, w.Code, w.Body) + } + cfg := readCreatedModelConfig(t, name) + if want := []string{"completion", "vision", "decision"}; !slices.Equal(cfg.Capabilities, want) { + t.Fatalf("%s capabilities = %v, want %v", name, cfg.Capabilities, want) + } + shown, err := GetModelInfo(api.ShowRequest{Model: name}) + if err != nil { + t.Fatal(err) + } + if !slices.Contains(shown.Capabilities, model.CapabilityDecision) || !strings.Contains(shown.Modelfile, "CAPABILITY decision\n") { + t.Fatalf("show %s lost capability: %+v", name, shown) + } + modelfile = shown.Modelfile + if name == "roundtrip" { + modelfile = "FROM roundtrip\n" + } + } + }) + } +} + +func TestCreateRejectsUnknownCapability(t *testing.T) { + gin.SetMode(gin.TestMode) + var s Server + for _, capability := range []string{"", "system-one", "decision tools"} { + w := createRequest(t, s.CreateHandler, api.CreateRequest{ + Model: "invalid", From: "missing", Capabilities: []string{capability}, Stream: &stream, + }) + if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), "unknown capability") { + t.Fatalf("capability %q: %d %s", capability, w.Code, w.Body) + } + } +} diff --git a/server/create.go b/server/create.go index cb56aa196..7af92681d 100644 --- a/server/create.go +++ b/server/create.go @@ -75,6 +75,12 @@ func (s *Server) CreateHandler(c *gin.Context) { config.Renderer = r.Renderer config.Parser = r.Parser config.Requires = r.Requires + for _, capability := range r.Capabilities { + if !model.Capability(capability).IsValid() { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("unknown capability: %q", capability)}) + return + } + } if err := validateCreateFiles(r.Files); err != nil { c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -256,6 +262,7 @@ func (s *Server) CreateHandler(c *gin.Context) { send(gin.H{"error": err.Error(), "status": http.StatusBadRequest}) return } + config.AddCapabilities(r.Capabilities...) if err := createModel(reqCtx, r, name, baseLayers, config, fn); err != nil { if errors.Is(err, create.ErrBadTemplate) || errors.Is(err, create.ErrInvalidRequires) || errors.Is(err, create.ErrInvalidLicense) || errors.Is(err, errInvalidSplitGGUF) { @@ -377,6 +384,7 @@ func writeSafetensorsManifest(r api.CreateRequest, draftDir string, fn func(resp License: r.License, Parameters: r.Parameters, Messages: r.Messages, + Capabilities: r.Capabilities, BeforeWriteManifest: func() { fn(api.ProgressResponse{Status: "writing manifest"}) }, }) return func(ctx context.Context, modelName string, info create.ManifestInfo) error { diff --git a/server/images.go b/server/images.go index ac4f77e8b..3b24e7d7b 100644 --- a/server/images.go +++ b/server/images.go @@ -533,6 +533,7 @@ func (m *Model) CheckCapabilities(want ...model.Capability) error { model.CapabilityEmbedding: errCapabilityEmbedding, model.CapabilityThinking: errCapabilityThinking, model.CapabilityImage: errCapabilityImage, + model.CapabilityDecision: errors.New("decision"), } for _, cap := range want { @@ -618,6 +619,12 @@ func (m *Model) String() string { Args: m.Config.Parser, }) } + for _, capability := range m.Config.Capabilities { + modelfile.Commands = append(modelfile.Commands, parser.Command{ + Name: "capability", + Args: capability, + }) + } for k, v := range m.Options { switch v := v.(type) { diff --git a/server/routes.go b/server/routes.go index d56c98c69..4347cf51a 100644 --- a/server/routes.go +++ b/server/routes.go @@ -878,11 +878,11 @@ func (s *Server) SystemOneHandler(c *gin.Context) { handleScheduleError(c, req.Model, err) return } - if !m.isGGUF() || m.Config.Renderer != "qwen3.5" && !(m.Config.Renderer == "" && m.Config.ModelFamily == "qwen35") { - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("model %q is not supported by System One; use a local Nimble or Tev GGUF model", req.Model)}) + if !m.isGGUF() { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("model %q is not supported by System One; use a local GGUF model", req.Model)}) return } - r, _, _, err := s.scheduleRunner(c.Request.Context(), m, []model.Capability{model.CapabilityCompletion}, nil, req.KeepAlive, nil) + r, _, _, err := s.scheduleRunner(c.Request.Context(), m, []model.Capability{model.CapabilityCompletion, model.CapabilityDecision}, nil, req.KeepAlive, nil) if err != nil { handleScheduleError(c, req.Model, err) return diff --git a/server/routes_create_test.go b/server/routes_create_test.go index b382034bc..e280449b2 100644 --- a/server/routes_create_test.go +++ b/server/routes_create_test.go @@ -2206,7 +2206,8 @@ func TestCreateSafetensorsRejectsMissingBlob(t *testing.T) { func TestWriteSafetensorsManifestPreservesRequestMetadata(t *testing.T) { t.Setenv("OLLAMA_MODELS", t.TempDir()) r := api.CreateRequest{ - Model: "uploaded-safetensors", + Model: "uploaded-safetensors", + Capabilities: []string{"decision", "completion", "decision"}, Info: map[string]any{ "capabilities": []string{"completion", "thinking"}, }, @@ -2243,8 +2244,8 @@ func TestWriteSafetensorsManifestPreservesRequestMetadata(t *testing.T) { if cfg.Requires != "0.20.0" { t.Fatalf("Requires = %q, want 0.20.0", cfg.Requires) } - if !slices.Contains(cfg.Capabilities, "completion") || !slices.Contains(cfg.Capabilities, "thinking") { - t.Fatalf("Capabilities = %v, want completion and thinking", cfg.Capabilities) + if want := []string{"completion", "thinking", "decision"}; !slices.Equal(cfg.Capabilities, want) { + t.Fatalf("Capabilities = %v, want %v", cfg.Capabilities, want) } mf, err := manifest.ParseNamedManifest(model.ParseName("uploaded-safetensors")) diff --git a/server/systemone_test.go b/server/systemone_test.go index 0bede8e56..e406a2a37 100644 --- a/server/systemone_test.go +++ b/server/systemone_test.go @@ -37,27 +37,32 @@ func (r *systemOneTestRunner) Score(ctx context.Context, input llm.ScoreRequest) func TestSystemOneHandler(t *testing.T) { gin.SetMode(gin.TestMode) t.Setenv("OLLAMA_MODELS", t.TempDir()) - config := model.ConfigV2{ModelFormat: "safetensors", Renderer: "qwen3.5", Capabilities: []string{"completion"}} + config := model.ConfigV2{ModelFormat: "safetensors", Renderer: "qwen3.5", Capabilities: []string{"completion", "decision"}} createSafetensorsTestModel(t, "safetensors-decision", config, nil) for _, modelConfig := range []struct { name, architecture, renderer, system, template string contextLength int + undeclared bool }{ - {"decision-test", "qwen35", "qwen3.5", "Model-specific scoring instructions.", "", 1024}, - {"gguf-decision", "qwen35", "", "Native model scoring instructions.", "", 4096}, - {"go-template", "qwen35", "", "Model-specific scoring instructions.", "custom:{{ range .Messages }}{{ .Role }}:{{ .Content }}\n{{ end }}answer:", 1024}, - {"no-system", "qwen35", "qwen3.5", "", "", 1024}, - {"gguf-wrong-architecture", "llama", "", "", "", 1024}, - {"gguf-wrong-renderer", "qwen35", "gemma4", "", "", 1024}, + {"decision-test", "qwen35", "qwen3.5", "Model-specific scoring instructions.", "", 1024, false}, + {"gguf-decision", "qwen35", "", "Native model scoring instructions.", "", 4096, false}, + {"go-template", "qwen35", "", "Model-specific scoring instructions.", "custom:{{ range .Messages }}{{ .Role }}:{{ .Content }}\n{{ end }}answer:", 1024, false}, + {"no-system", "qwen35", "qwen3.5", "", "", 1024, false}, + {"gguf-undeclared", "qwen35", "qwen3.5", "", "", 1024, true}, + {"gguf-other-architecture", "llama", "", "Model-specific scoring instructions.", "custom:{{ range .Messages }}{{ .Role }}:{{ .Content }}\n{{ end }}answer:", 1024, false}, } { kv := gguftest.KV{"general.architecture": modelConfig.architecture} if modelConfig.template == "" { kv["tokenizer.chat_template"] = "{{ messages }}" } _, digest := createBinFile(t, kv, nil) + caps := []string{"completion", "decision"} + if modelConfig.undeclared { + caps = []string{"completion"} + } configLayer, err := createConfigLayer(model.ConfigV2{ ModelFormat: "gguf", ModelFamily: modelConfig.architecture, - Renderer: modelConfig.renderer, Capabilities: []string{"completion"}, + Renderer: modelConfig.renderer, Capabilities: caps, }) if err != nil { t.Fatal(err) @@ -101,8 +106,8 @@ func TestSystemOneHandler(t *testing.T) { {"Modelfile template", `{"model":"go-template","state":"refund please","questions":{"refund":{"type":"noul","instructions":"Refund requested?"}}}`, nil, 200, 1, false}, {"no system prompt", `{"model":"no-system","state":"refund please","questions":{"refund":{"type":"noul","instructions":"Refund requested?"}}}`, nil, 200, 1, false}, {"template failure", `{"model":"gguf-decision","state":"refund please","questions":{"refund":{"type":"noul","instructions":"Refund requested?"}}}`, errors.New("invalid model template"), 500, 0, false}, - {"GGUF incompatible architecture", `{"model":"gguf-wrong-architecture","state":"x","questions":{"x":{"type":"noul","instructions":"q"}}}`, nil, 400, 0, false}, - {"GGUF conflicting renderer", `{"model":"gguf-wrong-renderer","state":"x","questions":{"x":{"type":"noul","instructions":"q"}}}`, nil, 400, 0, false}, + {"GGUF capability without Qwen architecture", `{"model":"gguf-other-architecture","state":"x","questions":{"refund":{"type":"noul","instructions":"q"}}}`, nil, 200, 1, false}, + {"GGUF missing capability", `{"model":"gguf-undeclared","state":"x","questions":{"x":{"type":"noul","instructions":"q"}}}`, nil, 400, 0, false}, {"runner validation", `{"model":"decision-test","state":"x","questions":{"x":{"type":"noul","instructions":"q"}}}`, api.StatusError{StatusCode: 400, ErrorMessage: "prompt too long"}, 400, 1, false}, {"runner failure", `{"model":"decision-test","state":"x","questions":{"x":{"type":"noul","instructions":"q"}}}`, errors.New("runner failed"), 500, 1, false}, {"runtime OOM", `{"model":"decision-test","state":"x","questions":{"x":{"type":"noul","instructions":"q"}}}`, errors.New("out of memory"), 500, 1, true}, diff --git a/types/model/capability.go b/types/model/capability.go index 4cf628c09..c6ebc7c9d 100644 --- a/types/model/capability.go +++ b/types/model/capability.go @@ -11,8 +11,20 @@ const ( CapabilityThinking = Capability("thinking") CapabilityImage = Capability("image") CapabilityAudio = Capability("audio") + CapabilityDecision = Capability("decision") ) +// IsValid reports whether c is recognized, such as "decision"; empty or unknown labels are invalid. +func (c Capability) IsValid() bool { + switch c { + case CapabilityCompletion, CapabilityTools, CapabilityInsert, CapabilityVision, + CapabilityEmbedding, CapabilityThinking, CapabilityImage, CapabilityAudio, CapabilityDecision: + return true + default: + return false + } +} + func (c Capability) String() string { return string(c) } diff --git a/types/model/config.go b/types/model/config.go index 3912b9862..3aa6bbe47 100644 --- a/types/model/config.go +++ b/types/model/config.go @@ -1,5 +1,7 @@ package model +import "slices" + // ConfigV2 represents the configuration metadata for a model. type ConfigV2 struct { ModelFormat string `json:"model_format"` @@ -17,7 +19,6 @@ type ConfigV2 struct { RemoteHost string `json:"remote_host,omitempty"` RemoteModel string `json:"remote_model,omitempty"` - // used for remotes Capabilities []string `json:"capabilities,omitempty"` ContextLen int `json:"context_length,omitempty"` EmbedLen int `json:"embedding_length,omitempty"` @@ -25,6 +26,15 @@ type ConfigV2 struct { Draft *Draft `json:"draft,omitempty"` } +// AddCapabilities preserves inherited and inferred capabilities. +func (c *ConfigV2) AddCapabilities(capabilities ...string) { + for _, capability := range capabilities { + if !slices.Contains(c.Capabilities, capability) { + c.Capabilities = append(c.Capabilities, capability) + } + } +} + // Draft describes an auxiliary draft model stored in the same manifest. type Draft struct { ModelFormat string `json:"model_format,omitempty"`