mirror of
https://github.com/ollama/ollama.git
synced 2026-10-13 07:06:52 +00:00
Add capabilities support and SystemOne test coverage
This commit is contained in:
parent
3ee5dd7f53
commit
36d46a0c33
24 changed files with 602 additions and 66 deletions
|
|
@ -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"`
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ func TestAPI(t *testing.T) {
|
|||
"api-show-model",
|
||||
"generate-logprobs",
|
||||
"chat-logprobs",
|
||||
"system-one",
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
205
integration/systemone_test.go
Normal file
205
integration/systemone_test.go
Normal file
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
90
server/capabilities_test.go
Normal file
90
server/capabilities_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue