Merge remote-tracking branch 'upstream/main' into release_v0.40.0

# Conflicts:
#	server/routes.go
This commit is contained in:
Daniel Hiltgen 2026-09-29 11:15:31 -07:00
commit eadb68a609
19 changed files with 293 additions and 58 deletions

View file

@ -718,6 +718,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"`

View file

@ -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,
})
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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) {

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)
}
}
}

View file

@ -89,6 +89,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()})
@ -298,6 +304,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) {
@ -414,6 +421,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 {
@ -1064,7 +1072,7 @@ func validateCreateManifestListRequest(r api.CreateRequest) error {
return newManifestListRequestError("manifest list creation cannot be combined with model creation options")
case r.Template != "", r.System != "", r.License != nil, len(r.Parameters) > 0, len(r.Messages) > 0:
return newManifestListRequestError("manifest list creation cannot be combined with model creation options")
case r.Renderer != "", r.Parser != "", r.Requires != "", len(r.Info) > 0:
case r.Renderer != "", r.Parser != "", r.Requires != "", len(r.Info) > 0, len(r.Capabilities) > 0:
return newManifestListRequestError("manifest list creation cannot be combined with model creation options")
case r.Quantize != "", r.Quantization != "", r.DraftQuantize != "":
return newManifestListRequestError("manifest list creation cannot be combined with model creation options")

View file

@ -551,6 +551,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 {
@ -636,6 +637,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) {

View file

@ -929,11 +929,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(), name.String(), "", []model.Capability{model.CapabilityCompletion}, nil, req.KeepAlive, nil)
r, _, _, err := s.scheduleRunner(c.Request.Context(), name.String(), "", []model.Capability{model.CapabilityCompletion, model.CapabilityDecision}, nil, req.KeepAlive, nil)
if err != nil {
handleScheduleError(c, req.Model, err)
return

View file

@ -1262,6 +1262,13 @@ func TestCreateManifestListRejectsInvalidRequests(t *testing.T) {
},
wantError: "cannot be combined",
},
{
name: "capabilities",
req: api.CreateRequest{
List: []string{"test-gguf", "test-safetensors"}, Capabilities: []string{"decision"},
},
wantError: "cannot be combined",
},
} {
t.Run(tt.name, func(t *testing.T) {
err := validateCreateManifestListRequest(tt.req)
@ -2482,7 +2489,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"},
},
@ -2519,8 +2527,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"))

View file

@ -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},

View file

@ -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)
}

View file

@ -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"`