diff --git a/api/types.go b/api/types.go index d7a2a0f6e..0a34ac905 100644 --- a/api/types.go +++ b/api/types.go @@ -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"` 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 76ebe7855..ba3adc1eb 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/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 eac5f3dc9..874a196a0 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/integration/reg_groups_test.go b/integration/reg_groups_test.go index 3a2f86563..8306d6f40 100644 --- a/integration/reg_groups_test.go +++ b/integration/reg_groups_test.go @@ -39,6 +39,7 @@ func TestAPI(t *testing.T) { "api-show-model", "generate-logprobs", "chat-logprobs", + "system-one", ) } diff --git a/integration/reg_release_test.go b/integration/reg_release_test.go index 893b6c828..35e4bcdc0 100644 --- a/integration/reg_release_test.go +++ b/integration/reg_release_test.go @@ -79,6 +79,8 @@ func init() { integrationTestCase("api-show-model", "llama3.2", runAPIShowModel), integrationTestCase("generate-logprobs", smol, runAPIGenerateLogprobs), integrationTestCase("chat-logprobs", smol, runAPIChatLogprobs), + // Keep the public tag so manifest lists select the platform's model. + integrationModelTestCase("system-one", defaultTestModel("nimble:latest"), runAPISystemOne), integrationTestCase("blue-sky", smol, runBlueSky), integrationModelTestCase("unicode-input", releaseUnicodeInputModel.Name, runUnicode), diff --git a/integration/systemone_test.go b/integration/systemone_test.go new file mode 100644 index 000000000..573f7a5ac --- /dev/null +++ b/integration/systemone_test.go @@ -0,0 +1,205 @@ +//go:build integration && release + +package integration + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "slices" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/ollama/ollama/api" + "github.com/ollama/ollama/decision" + "github.com/ollama/ollama/types/model" +) + +func postSystemOne(ctx context.Context, endpoint string, input decision.Request) ([]byte, int, error) { + body, err := json.Marshal(input) + if err != nil { + return nil, 0, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://"+endpoint+"/v1/systemone", bytes.NewReader(body)) + if err != nil { + return nil, 0, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + body, err = io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + return body, resp.StatusCode, err +} + +func runAPISystemOne(t *testing.T, modelName string) { + ctx, cancel := context.WithTimeout(t.Context(), apiTestTimeout) + defer cancel() + client, endpoint, cleanup := InitServerConnection(ctx, t) + defer cleanup() + if err := PullIfMissing(ctx, client, modelName); err != nil { + t.Fatal(err) + } + info, err := client.Show(ctx, &api.ShowRequest{Model: modelName}) + if err != nil { + t.Fatal(err) + } + if !slices.Contains(info.Capabilities, model.CapabilityDecision) { + t.Fatalf("model %q does not declare the decision capability", modelName) + } + + var input decision.Request + if err := json.Unmarshal([]byte(`{ + "state":"Returns are allowed within 30 days. This item was bought 12 days ago. It arrived undamaged.", + "questions":{ + "eligible":{"type":"noul","instructions":"Is this item within the store return window?","criteria":{}}, + "department":{"type":"choice","instructions":"Which department handles a return?","criteria":{"returns":"Product returns and refunds","technical":"Software installation"}}, + "damage":{"type":"score","instructions":"How damaged was the item?","criteria":["Undamaged","Minor damage","Destroyed"]} + } + }`), &input); err != nil { + t.Fatal(err) + } + input.Model = modelName + type response struct { + Model string + Answers struct { + Eligible decision.NoulAnswer + Department decision.ChoiceAnswer + Damage decision.ScoreAnswer + } + Usage decision.Usage + } + score := func(t *testing.T, req decision.Request) response { + t.Helper() + body, status, err := postSystemOne(ctx, endpoint, req) + if err != nil { + t.Fatal(err) + } + if status != http.StatusOK { + t.Fatalf("System One returned HTTP %d: %s", status, body) + } + // Missing or null numbers must not pass as legitimate zero values. + result := response{Usage: decision.Usage{InputTokens: -1, OutputTokens: -1}} + result.Answers.Eligible.Noul = math.NaN() + result.Answers.Department.Confidence = math.NaN() + result.Answers.Damage = decision.ScoreAnswer{Score: math.NaN(), Confidence: math.NaN()} + if err := json.Unmarshal(body, &result); err != nil { + t.Fatalf("decode System One response: %v: %s", err, body) + } + if result.Model != modelName { + t.Fatalf("unexpected model or answer fields: %s", body) + } + // MLX scores without generating; llama.cpp can report primer/answer + // tokens. Neither backend may omit input accounting or report negatives. + if result.Usage.InputTokens <= 0 || result.Usage.OutputTokens < 0 { + t.Fatalf("missing or invalid usage: %s", body) + } + answers := result.Answers + if answers.Eligible.Type != "noul" || answers.Department.Type != "choice" || answers.Damage.Type != "score" { + t.Fatalf("unexpected answer types: %s", body) + } + for _, answer := range []struct { + name string + probabilities *decision.Probabilities + confidence float64 + keys []string + }{ + {"department", answers.Department.Probabilities, answers.Department.Confidence, []string{"returns", "technical"}}, + {"damage", answers.Damage.Probabilities, answers.Damage.Confidence, []string{"0", "1", "2"}}, + } { + if answer.probabilities.Len() != len(answer.keys) { + t.Fatalf("%s probabilities: got %v, want keys %v", answer.name, answer.probabilities, answer.keys) + } + var sum float64 + for _, key := range answer.keys { + p, ok := answer.probabilities.Get(key) + if !ok || !(p >= 0 && p <= 1) { + t.Fatalf("%s probability %q missing or invalid: %s", answer.name, key, body) + } + sum += p + } + if math.Abs(sum-1) > 1e-6 || !(answer.confidence >= 0 && answer.confidence <= 1) { + t.Fatalf("%s: probability sum=%v or missing/invalid confidence: %s", answer.name, sum, body) + } + } + if p := answers.Eligible.Noul; !(p >= 0 && p <= 1) { + t.Fatalf("missing or invalid eligible probability: %s", body) + } + if value := answers.Damage.Score; !(value >= 0 && value <= 2) { + t.Fatalf("missing or invalid damage score: %s", body) + } + if diff := cmp.Diff(map[string]string{"0": "Undamaged", "1": "Minor damage", "2": "Destroyed"}, answers.Damage.Legend.ToMap()); diff != "" { + t.Fatalf("damage legend (-want +got):\n%s", diff) + } + return result + } + + baseline := score(t, input) + t.Run("decisions", func(t *testing.T) { + if baseline.Answers.Eligible.Noul <= 0.5 || baseline.Answers.Department.Choice != "returns" || baseline.Answers.Damage.Score >= 0.5 { + t.Fatalf("unexpected decisions for an undamaged, in-window return: %+v", baseline.Answers) + } + late := input + late.State = json.RawMessage(`"Returns are allowed within 30 days. This item was bought 90 days ago. It arrived undamaged."`) + if result := score(t, late); result.Answers.Eligible.Noul >= 0.5 { + t.Fatalf("late return should be ineligible: %+v", result.Answers.Eligible) + } + }) + + unchanged := func(t *testing.T) { + t.Helper() + got := score(t, input) + // Cache/prefill shapes can change BF16 accumulation. Match the runner + // parity test's 0.01 absolute tolerance while requiring identical choices. + if diff := cmp.Diff(baseline.Answers, got.Answers, cmpopts.EquateApprox(0, 0.01), + cmpopts.AcyclicTransformer("probabilities", (*decision.Probabilities).ToMap), + cmpopts.AcyclicTransformer("legend", (*decision.Legend).ToMap), + ); diff != "" { + t.Errorf("scoring changed (-baseline +got):\n%s", diff) + } + if got.Usage.InputTokens != baseline.Usage.InputTokens { + t.Errorf("input token count changed: got %d, want %d", got.Usage.InputTokens, baseline.Usage.InputTokens) + } + } + t.Run("repeat", unchanged) + t.Run("concurrent", func(t *testing.T) { + for i := range 4 { + t.Run(fmt.Sprint(i), func(t *testing.T) { + t.Parallel() + unchanged(t) + }) + } + }) + t.Run("after_generation", func(t *testing.T) { + noStream := false + err := client.Generate(ctx, &api.GenerateRequest{ + Model: modelName, Prompt: "Reply with one word: hello", Stream: &noStream, + Think: &api.ThinkValue{Value: false}, + Options: map[string]any{"temperature": 0, "num_predict": 8}, + }, func(api.GenerateResponse) error { return nil }) + if err != nil { + t.Fatal(err) + } + unchanged(t) + }) + + t.Run("invalid_state", func(t *testing.T) { + invalid := input + invalid.State = json.RawMessage(`""`) + body, status, err := postSystemOne(ctx, endpoint, invalid) + if err != nil { + t.Fatal(err) + } + if status != http.StatusBadRequest { + t.Fatalf("got HTTP %d, want %d: %s", status, http.StatusBadRequest, body) + } + unchanged(t) + }) +} diff --git a/mlxrunner/client_score_test.go b/mlxrunner/client_score_test.go index 594ef0ff6..9a82dbd2b 100644 --- a/mlxrunner/client_score_test.go +++ b/mlxrunner/client_score_test.go @@ -75,3 +75,26 @@ func TestClientScoreTransportError(t *testing.T) { }) } } + +func TestClientScoreCancellation(t *testing.T) { + started := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(io.Discard, r.Body) + close(started) + <-r.Context().Done() + })) + defer srv.Close() + client := &Client{port: srv.Listener.Addr().(*net.TCPAddr).Port, client: srv.Client()} + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + done := make(chan error, 1) + go func() { + _, err := client.Score(ctx, llm.ScoreRequest{}) + done <- err + }() + <-started + cancel() + if err := <-done; !errors.Is(err, context.Canceled) { + t.Fatalf("got %v, want context cancellation", err) + } +} diff --git a/mlxrunner/model/qwen3_5/score_test.go b/mlxrunner/model/qwen3_5/score_test.go index dc592df26..af1e90b7c 100644 --- a/mlxrunner/model/qwen3_5/score_test.go +++ b/mlxrunner/model/qwen3_5/score_test.go @@ -42,3 +42,38 @@ func TestUnembedCandidates(t *testing.T) { }) }) } + +func TestUnembedQuantizedCandidates(t *testing.T) { + for _, mode := range []string{"affine", "nvfp4"} { + mlxtest.RunSubtest(t, mode, func(t *mlxtest.T) { + mlx.Scoped(func() { + values := make([]float32, 32*64) + for i := range values { + values[i] = float32(math.Sin(float64(i))) + } + weight := mlx.FromValues(values, 32, 64).AsType(mlx.DTypeBFloat16) + groupSize := 64 + if mode == "nvfp4" { + groupSize = 16 + } + bias := mlx.FromValues(values[:32], 32).AsType(mlx.DTypeBFloat16) + head := nn.NewQuantizedLinear(weight, bias, groupSize, 4, mode) + if mode == "nvfp4" { + head.GlobalScale = mlx.FromValues([]float32{0.5}, 1) + } + m := &Model{LMHead: head} + hidden := mlx.FromValues(values[:64], 1, 1, 64).AsType(mlx.DTypeBFloat16) + ids := mlx.FromValues([]int32{31, 2, 0}, 3) + got := m.UnembedCandidates(hidden, ids).AsType(mlx.DTypeFloat32) + want := m.Unembed(hidden).Reshape(-1).TakeAxis(ids, 0).AsType(mlx.DTypeFloat32) + mlx.Eval(got, want) + actual, expected := got.Floats(), want.Floats() + for i, v := range actual { + if v != expected[i] { + t.Fatalf("candidate %d: selected=%g full=%g", i, v, expected[i]) + } + } + }) + }) + } +} diff --git a/mlxrunner/score_test.go b/mlxrunner/score_test.go index 4abd68690..40986bb32 100644 --- a/mlxrunner/score_test.go +++ b/mlxrunner/score_test.go @@ -79,10 +79,12 @@ func TestScoreSharedPrefix(t *testing.T) { }{ {"branch lengths and repeated restore", [][]int32{{1, 2, 3, 4}, {1, 2, 5}, {1, 2, 3, 6, 7}, {1, 2, 3, 4}}}, {"identical prompts", [][]int32{{1, 2, 3}, {1, 2, 3}}}, + {"prompt is prefix", [][]int32{{1, 2}, {1, 2, 3, 4}}}, {"empty prefix", [][]int32{{1, 2}, {2, 1, 3}}}, {"one prompt", [][]int32{{1, 2, 3, 4}}}, {"one token", [][]int32{{1}, {1}, {2}}}, {"chunked prefix", [][]int32{append(slices.Repeat([]int32{1}, prefillChunkSize()+3), 2), append(slices.Repeat([]int32{1}, prefillChunkSize()+3), 3)}}, + {"chunked suffix", [][]int32{append([]int32{1, 2}, slices.Repeat([]int32{3}, prefillChunkSize()+3)...), append([]int32{1, 2}, slices.Repeat([]int32{4}, prefillChunkSize()+3)...)}}, } { mlxtest.RunSubtest(t, tt.name, func(t *mlxtest.T) { m := &scoringTestModel{} @@ -159,6 +161,10 @@ func TestScoreCancellation(t *testing.T) { t.Fatal("cancelled request retained cache state") } } + m.cancel = nil + if _, err := r.scoreRows(context.Background(), rows, 2); err != nil { + t.Fatalf("request after cancellation: %v", err) + } }) } } @@ -167,10 +173,15 @@ func TestScoreValidation(t *testing.T) { tok := newTestTokenizer(t, []int32{7}) r := &Runner{Tokenizer: tok, contextLength: 10} for _, input := range []llm.ScoreRequest{ - {}, {MaxTokens: 10}, {MaxTokens: 11, Rows: []llm.ScoreRow{{Prompt: "1", Candidates: []string{"2"}}}}, + {}, + {MaxTokens: 10}, + {MaxTokens: 10, Rows: make([]llm.ScoreRow, 65)}, + {Rows: []llm.ScoreRow{{Prompt: "1", Candidates: []string{"2"}}}}, + {MaxTokens: 11, Rows: []llm.ScoreRow{{Prompt: "1", Candidates: []string{"2"}}}}, {MaxTokens: 1, Rows: []llm.ScoreRow{{Prompt: "12", Candidates: []string{"3"}}}}, {MaxTokens: 10, Rows: []llm.ScoreRow{{Prompt: "", Candidates: []string{"1"}}}}, {MaxTokens: 10, Rows: []llm.ScoreRow{{Prompt: "1"}}}, + {MaxTokens: 10, Rows: []llm.ScoreRow{{Prompt: "1", Candidates: slices.Repeat([]string{"2"}, 27)}}}, {MaxTokens: 10, Rows: []llm.ScoreRow{{Prompt: "1", Candidates: []string{"23"}}}}, {MaxTokens: 10, Rows: []llm.ScoreRow{{Prompt: "1", Candidates: []string{"2", "2"}}}}, } { @@ -183,10 +194,10 @@ func TestScoreValidation(t *testing.T) { } // Optional full-model check against independently prefilling each question. -func TestNimbleModel(t *testing.T) { - name := os.Getenv("OLLAMA_NIMBLE_MODEL") +func TestScoreModel(t *testing.T) { + name := os.Getenv("OLLAMA_TEST_MODEL") if name == "" { - t.Skip("set OLLAMA_NIMBLE_MODEL to an imported Qwen3.5/Nimble model") + t.Skip("set OLLAMA_TEST_MODEL to an imported Nimble or Tev model") } var req decision.Request if err := json.Unmarshal([]byte(`{ @@ -209,6 +220,11 @@ func TestNimbleModel(t *testing.T) { }); err != nil { t.Fatal(err) } + allCandidates := llm.ScoreRow{Prompt: c.Request.Rows[0].Prompt} + for code := 'A'; code <= 'Z'; code++ { + allCandidates.Candidates = append(allCandidates.Candidates, string(code)) + } + c.Request.Rows = append(c.Request.Rows, allCandidates) mlxtest.Run(t, func(t *mlxtest.T) { r := &Runner{} @@ -221,7 +237,22 @@ func TestNimbleModel(t *testing.T) { if err != nil { t.Fatal(err) } + if shared.OutputTokens != 0 { + t.Fatalf("scoring generated %d tokens", shared.OutputTokens) + } + var inputTokens int + for _, row := range c.Request.Rows { + inputTokens += len(r.Tokenizer.Encode(row.Prompt, false)) + } + if shared.InputTokens != inputTokens { + t.Fatalf("input tokens = %d, want %d", shared.InputTokens, inputTokens) + } probabilities := func(logits []float32) []float64 { + for i, x := range logits { + if math.IsNaN(float64(x)) || math.IsInf(float64(x), 0) { + t.Fatalf("candidate %d has non-finite logit %v", i, x) + } + } p := make([]float64, len(logits)) peak := slices.Max(logits) var sum float64 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 8f7d30b13..fd538c903 100644 --- a/server/create.go +++ b/server/create.go @@ -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") diff --git a/server/images.go b/server/images.go index 1e6cf26d5..60c69347d 100644 --- a/server/images.go +++ b/server/images.go @@ -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) { diff --git a/server/routes.go b/server/routes.go index b2d71ce7c..829259303 100644 --- a/server/routes.go +++ b/server/routes.go @@ -929,11 +929,7 @@ 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)}) - 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 diff --git a/server/routes_create_test.go b/server/routes_create_test.go index e83bb38df..60d54f527 100644 --- a/server/routes_create_test.go +++ b/server/routes_create_test.go @@ -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")) diff --git a/server/systemone_test.go b/server/systemone_test.go index 0bede8e56..348204992 100644 --- a/server/systemone_test.go +++ b/server/systemone_test.go @@ -37,27 +37,38 @@ 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"}} - createSafetensorsTestModel(t, "safetensors-decision", config, nil) + config := model.ConfigV2{ModelFormat: "safetensors", Renderer: "qwen3.5", Capabilities: []string{"completion", "decision"}} + params, err := manifest.NewLayer(strings.NewReader(`{"num_ctx":8192}`), "application/vnd.ollama.image.params") + if err != nil { + t.Fatal(err) + } + createSafetensorsTestModel(t, "safetensors-decision", config, []manifest.Layer{params}) + config.Capabilities = []string{"completion"} + createSafetensorsTestModel(t, "safetensors-undeclared", 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,15 +112,16 @@ 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}, {"invalid schema", `{"model":"decision-test","state":"x","questions":{}}`, nil, 400, 0, false}, {"missing model", `{"model":"missing","state":"x","questions":{"x":{"type":"noul","instructions":"q"}}}`, nil, 404, 0, false}, {"cloud", `{"model":"decision:cloud","state":"x","questions":{"x":{"type":"noul","instructions":"q"}}}`, nil, 400, 0, false}, - {"safetensors unsupported", `{"model":"safetensors-decision","state":"x","questions":{"x":{"type":"noul","instructions":"q"}}}`, nil, 400, 0, false}, + {"safetensors success", `{"model":"safetensors-decision","state":"x","questions":{"refund":{"type":"noul","instructions":"q"}}}`, nil, 200, 1, false}, + {"safetensors missing capability", `{"model":"safetensors-undeclared","state":"x","questions":{"x":{"type":"noul","instructions":"q"}}}`, nil, 400, 0, false}, {"bad JSON", `{`, nil, 400, 0, false}, {"at body limit", prefix + strings.Repeat("x", stateLimit) + suffix, nil, 200, 1, false}, {"over body limit", prefix + strings.Repeat("x", stateLimit+1) + suffix, nil, 413, 0, false}, @@ -177,6 +189,9 @@ func TestSystemOneHandler(t *testing.T) { } prompt := runner.request.Rows[0].Prompt wantContext := 1024 + if !ref.model.isGGUF() { + wantContext = 8192 + } if ref.model.HasGoTemplate { if !strings.HasPrefix(prompt, "custom:system:Model-specific scoring instructions.\nuser:") || !strings.HasSuffix(prompt, "answer:") { t.Fatalf("scorer did not receive the Modelfile template output: %q", prompt) diff --git a/types/model/capability.go b/types/model/capability.go index 4cf628c09..be6f0795b 100644 --- a/types/model/capability.go +++ b/types/model/capability.go @@ -11,8 +11,19 @@ const ( CapabilityThinking = Capability("thinking") CapabilityImage = Capability("image") CapabilityAudio = Capability("audio") + CapabilityDecision = Capability("decision") ) +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"`