mirror of
https://github.com/ollama/ollama.git
synced 2026-09-21 05:28:00 -05:00
api: expose model thinking levels and defaults (#18473)
This commit is contained in:
+18
-13
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/ollama/ollama/auth"
|
||||
internalcloud "github.com/ollama/ollama/internal/cloud"
|
||||
"github.com/ollama/ollama/logutil"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
// Error types matching Anthropic API
|
||||
@@ -317,8 +318,9 @@ type StreamErrorEvent struct {
|
||||
Error Error `json:"error"`
|
||||
}
|
||||
|
||||
// FromMessagesRequest converts an Anthropic MessagesRequest to an Ollama api.ChatRequest
|
||||
func FromMessagesRequest(r MessagesRequest) (*api.ChatRequest, error) {
|
||||
// FromMessagesRequest converts an Anthropic MessagesRequest to an Ollama api.ChatRequest.
|
||||
// An optional thinking descriptor preserves model-defined effort names for rendering.
|
||||
func FromMessagesRequest(r MessagesRequest, thinking ...*model.Thinking) (*api.ChatRequest, error) {
|
||||
logutil.Trace("anthropic: converting request", "req", TraceMessagesRequest(r))
|
||||
|
||||
var messages []api.Message
|
||||
@@ -402,14 +404,6 @@ func FromMessagesRequest(r MessagesRequest) (*api.ChatRequest, error) {
|
||||
}
|
||||
|
||||
var think *api.ThinkValue
|
||||
normalizedEffort := ""
|
||||
if r.OutputConfig != nil {
|
||||
normalizedEffort = strings.ToLower(strings.TrimSpace(r.OutputConfig.Effort))
|
||||
if normalizedEffort == "xhigh" {
|
||||
normalizedEffort = "high"
|
||||
}
|
||||
}
|
||||
|
||||
if r.Thinking != nil && r.Thinking.Type == "enabled" {
|
||||
think = &api.ThinkValue{Value: true}
|
||||
}
|
||||
@@ -417,9 +411,20 @@ func FromMessagesRequest(r MessagesRequest) (*api.ChatRequest, error) {
|
||||
think = &api.ThinkValue{Value: false}
|
||||
}
|
||||
if think == nil && r.OutputConfig != nil {
|
||||
switch normalizedEffort {
|
||||
case "high", "medium", "low", "max":
|
||||
think = &api.ThinkValue{Value: normalizedEffort}
|
||||
effort := r.OutputConfig.Effort
|
||||
if len(thinking) > 0 && thinking[0].Valid() {
|
||||
if effort != "" {
|
||||
think = &api.ThinkValue{Value: effort}
|
||||
}
|
||||
} else {
|
||||
effort = strings.ToLower(strings.TrimSpace(effort))
|
||||
if effort == "xhigh" {
|
||||
effort = "high"
|
||||
}
|
||||
legacyThink := &api.ThinkValue{Value: effort}
|
||||
if api.ValidateLegacyThinking(legacyThink) == nil {
|
||||
think = legacyThink
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
func TestThinkingConversionMetadata(t *testing.T) {
|
||||
for _, metadata := range []struct {
|
||||
name string
|
||||
thinking *model.Thinking
|
||||
generic bool
|
||||
}{
|
||||
{"named", &model.Thinking{Values: []any{false, "high", "max"}, Default: "high"}, true},
|
||||
{"nil", nil, false},
|
||||
{"invalid", &model.Thinking{Values: []any{"high"}, Default: "missing"}, false},
|
||||
} {
|
||||
for _, tt := range []struct {
|
||||
name, fields string
|
||||
wantGeneric, wantLegacy any
|
||||
}{
|
||||
{"omitted", ``, nil, nil},
|
||||
{"empty", `,"output_config":{"effort":""}`, nil, nil},
|
||||
{"supported", `,"output_config":{"effort":"max"}`, "max", "max"},
|
||||
{"unsupported", `,"output_config":{"effort":"low"}`, "low", "low"},
|
||||
{"xhigh", `,"output_config":{"effort":"xhigh"}`, "xhigh", "high"},
|
||||
{"minimal", `,"output_config":{"effort":"minimal"}`, "minimal", nil},
|
||||
{"future", `,"output_config":{"effort":"future"}`, "future", nil},
|
||||
{"exact spelling", `,"output_config":{"effort":" HIGH "}`, " HIGH ", "high"},
|
||||
{"adaptive", `,"thinking":{"type":"adaptive"},"output_config":{"effort":"xhigh"}`, "xhigh", "high"},
|
||||
{"enabled precedence", `,"thinking":{"type":"enabled"},"output_config":{"effort":"xhigh"}`, true, true},
|
||||
{"disabled precedence", `,"thinking":{"type":"disabled"},"output_config":{"effort":"xhigh"}`, false, false},
|
||||
} {
|
||||
t.Run(metadata.name+"/"+tt.name, func(t *testing.T) {
|
||||
var req MessagesRequest
|
||||
body := []byte(`{"model":"test","max_tokens":32,"messages":[{"role":"user","content":"hi"}]` + tt.fields + `}`)
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := FromMessagesRequest(req, metadata.thinking)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := json.Marshal(req)
|
||||
if err != nil || string(before) != string(after) {
|
||||
t.Fatalf("conversion mutated input: before=%s after=%s error=%v", before, after, err)
|
||||
}
|
||||
want := tt.wantLegacy
|
||||
if metadata.generic {
|
||||
want = tt.wantGeneric
|
||||
}
|
||||
var got any
|
||||
if result.Think != nil {
|
||||
got = result.Think.Value
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("thinking=%#v, want %#v", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
-122
@@ -102,9 +102,9 @@ type GenerateRequest struct {
|
||||
Options map[string]any `json:"options"`
|
||||
|
||||
// Think controls whether thinking/reasoning models will think before
|
||||
// responding. Can be a boolean (true/false) or a string ("high", "medium", "low")
|
||||
// for supported models. Needs to be a pointer so we can distinguish between false
|
||||
// (request that thinking _not_ be used) and unset (use the old behavior
|
||||
// responding. Can be a boolean (true/false) or a model-defined thinking level.
|
||||
// Needs to be a pointer so we can distinguish between false (request that
|
||||
// thinking _not_ be used) and unset (use the old behavior
|
||||
// before this option was introduced)
|
||||
Think *ThinkValue `json:"think,omitempty"`
|
||||
|
||||
@@ -154,8 +154,7 @@ type ChatRequest struct {
|
||||
Options map[string]any `json:"options"`
|
||||
|
||||
// Think controls whether thinking/reasoning models will think before
|
||||
// responding. Can be a boolean (true/false) or a string ("high", "medium", "low")
|
||||
// for supported models.
|
||||
// responding. Can be a boolean (true/false) or a model-defined thinking level.
|
||||
Think *ThinkValue `json:"think,omitempty"`
|
||||
|
||||
// Truncate is a boolean that, when set to true, truncates the chat history messages
|
||||
@@ -738,6 +737,7 @@ type ShowRequest struct {
|
||||
|
||||
// ShowResponse is the response returned from [Client.Show].
|
||||
type ShowResponse struct {
|
||||
Thinking *model.Thinking `json:"thinking,omitempty"`
|
||||
License string `json:"license,omitempty"`
|
||||
Modelfile string `json:"modelfile,omitempty"`
|
||||
Parameters string `json:"parameters,omitempty"`
|
||||
@@ -827,23 +827,10 @@ type ModelRecommendation struct {
|
||||
Thinking *ModelRecommendationThinking `json:"thinking,omitempty"`
|
||||
}
|
||||
|
||||
// ModelRecommendationThinking advertises the exact values accepted by
|
||||
// Ollama's think field and the model's default. Values may be booleans for
|
||||
// binary thinking controls or strings for adjustable effort levels.
|
||||
type ModelRecommendationThinking struct {
|
||||
Values []any `json:"values,omitempty"`
|
||||
Default any `json:"default,omitempty"`
|
||||
}
|
||||
|
||||
// Clone returns an independent copy.
|
||||
func (t *ModelRecommendationThinking) Clone() *ModelRecommendationThinking {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
clone := *t
|
||||
clone.Values = append([]any(nil), t.Values...)
|
||||
return &clone
|
||||
}
|
||||
// ModelRecommendationThinking advertises the controls a model honors and its
|
||||
// default. Values may be booleans or named effort levels; other strings may
|
||||
// still be accepted by the endpoint and fall back to the default.
|
||||
type ModelRecommendationThinking = model.Thinking
|
||||
|
||||
// ProcessResponse is the response from [Client.Process].
|
||||
type ProcessResponse struct {
|
||||
@@ -1163,111 +1150,21 @@ func DefaultOptions() Options {
|
||||
}
|
||||
}
|
||||
|
||||
// ThinkValue represents a value that can be a boolean or a string ("high", "medium", "low", "max")
|
||||
type ThinkValue struct {
|
||||
// Value can be a bool or string
|
||||
Value interface{}
|
||||
}
|
||||
// ThinkValue represents a boolean or model-defined thinking level.
|
||||
type ThinkValue = model.ThinkValue
|
||||
|
||||
// IsValid checks if the ThinkValue is valid
|
||||
func (t *ThinkValue) IsValid() bool {
|
||||
if t == nil || t.Value == nil {
|
||||
return true // nil is valid (means not set)
|
||||
}
|
||||
|
||||
switch v := t.Value.(type) {
|
||||
case bool:
|
||||
return true
|
||||
case string:
|
||||
return v == "high" || v == "medium" || v == "low" || v == "max"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsBool returns true if the value is a boolean
|
||||
func (t *ThinkValue) IsBool() bool {
|
||||
if t == nil || t.Value == nil {
|
||||
return false
|
||||
}
|
||||
_, ok := t.Value.(bool)
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsString returns true if the value is a string
|
||||
func (t *ThinkValue) IsString() bool {
|
||||
if t == nil || t.Value == nil {
|
||||
return false
|
||||
}
|
||||
_, ok := t.Value.(string)
|
||||
return ok
|
||||
}
|
||||
|
||||
// Bool returns the value as a bool (true if enabled in any way)
|
||||
func (t *ThinkValue) Bool() bool {
|
||||
if t == nil || t.Value == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
switch v := t.Value.(type) {
|
||||
case bool:
|
||||
return v
|
||||
case string:
|
||||
// Any string value ("high", "medium", "low", "max") means thinking is enabled
|
||||
return v == "high" || v == "medium" || v == "low" || v == "max"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// String returns the value as a string
|
||||
func (t *ThinkValue) String() string {
|
||||
if t == nil || t.Value == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch v := t.Value.(type) {
|
||||
case string:
|
||||
return v
|
||||
case bool:
|
||||
if v {
|
||||
return "medium" // Default level when just true
|
||||
}
|
||||
return ""
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler
|
||||
func (t *ThinkValue) UnmarshalJSON(data []byte) error {
|
||||
// Try to unmarshal as bool first
|
||||
var b bool
|
||||
if err := json.Unmarshal(data, &b); err == nil {
|
||||
t.Value = b
|
||||
// ValidateLegacyThinking checks named levels for models without thinking metadata.
|
||||
// Transport types are checked by ThinkValue.UnmarshalJSON or IsValid.
|
||||
func ValidateLegacyThinking(think *ThinkValue) error {
|
||||
if !think.IsString() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try to unmarshal as string
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err == nil {
|
||||
// Validate string values
|
||||
if s != "high" && s != "medium" && s != "low" && s != "max" {
|
||||
return fmt.Errorf("invalid think value: %q (must be \"high\", \"medium\", \"low\", \"max\", true, or false)", s)
|
||||
}
|
||||
t.Value = s
|
||||
switch think.String() {
|
||||
case "low", "medium", "high", "max":
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid think value: %q (must be \"high\", \"medium\", \"low\", \"max\", true, or false)", think.String())
|
||||
}
|
||||
|
||||
return fmt.Errorf("think must be a boolean or string (\"high\", \"medium\", \"low\", \"max\", true, or false)")
|
||||
}
|
||||
|
||||
// MarshalJSON implements json.Marshaler
|
||||
func (t *ThinkValue) MarshalJSON() ([]byte, error) {
|
||||
if t == nil || t.Value == nil {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
return json.Marshal(t.Value)
|
||||
}
|
||||
|
||||
type Duration struct {
|
||||
|
||||
+74
-4
@@ -571,10 +571,9 @@ func TestThinking_UnmarshalJSON(t *testing.T) {
|
||||
expectedThinking: &ThinkValue{Value: "max"},
|
||||
},
|
||||
{
|
||||
name: "invalid_string",
|
||||
input: `{ "think": "invalid" }`,
|
||||
expectedThinking: nil,
|
||||
expectedError: true,
|
||||
name: "unknown_string",
|
||||
input: `{ "think": "future-level" }`,
|
||||
expectedThinking: &ThinkValue{Value: "future-level"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -985,3 +984,74 @@ func TestToolPropertiesMap_NestedProperties(t *testing.T) {
|
||||
assert.Equal(t, expected, string(data))
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateLegacyThinking(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
input string
|
||||
valid bool
|
||||
}{
|
||||
{`null`, true},
|
||||
{`true`, true},
|
||||
{`false`, true},
|
||||
{`"low"`, true},
|
||||
{`"medium"`, true},
|
||||
{`"high"`, true},
|
||||
{`"max"`, true},
|
||||
{`"xhigh"`, false},
|
||||
{`"minimal"`, false},
|
||||
{`"future"`, false},
|
||||
{`""`, false},
|
||||
{`"HIGH"`, false},
|
||||
{`" high "`, false},
|
||||
} {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
var think *ThinkValue
|
||||
if err := json.Unmarshal([]byte(tt.input), &think); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !think.IsValid() {
|
||||
t.Fatal("legacy validation must not restrict transport types")
|
||||
}
|
||||
if err := ValidateLegacyThinking(think); (err == nil) != tt.valid {
|
||||
t.Fatalf("ValidateLegacyThinking(%s) = %v, want valid=%v", tt.input, err, tt.valid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestThinkValueTransportTypes(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
input string
|
||||
want any
|
||||
invalid bool
|
||||
}{
|
||||
{`null`, nil, false},
|
||||
{`true`, true, false},
|
||||
{`false`, false, false},
|
||||
{`"xhigh"`, "xhigh", false},
|
||||
{`"minimal"`, "minimal", false},
|
||||
{`""`, "", false},
|
||||
{`75`, nil, true},
|
||||
{`0.75`, nil, true},
|
||||
{`[]`, nil, true},
|
||||
{`{}`, nil, true},
|
||||
{`tru`, nil, true},
|
||||
} {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
var think ThinkValue
|
||||
err := json.Unmarshal([]byte(tt.input), &think)
|
||||
if (err != nil) != tt.invalid {
|
||||
t.Fatalf("error = %v, invalid = %v", err, tt.invalid)
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if think.Value != tt.want {
|
||||
t.Fatalf("got %#v, want %#v", think.Value, tt.want)
|
||||
}
|
||||
if think.IsString() && !think.Bool() {
|
||||
t.Fatal("named effort must express intent to think")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,7 +365,7 @@ func loadCodexDesktopConnectionModels(ctx context.Context, selected []string) (s
|
||||
return primary, hydrateCodexDesktopModelCapabilities(ctx, models), nil
|
||||
}
|
||||
|
||||
// /api/show supplies capabilities and family metadata without replacing recommended thinking controls.
|
||||
// /api/show refreshes selected model metadata; recommendations remain the fallback.
|
||||
func hydrateCodexDesktopModelCapabilities(ctx context.Context, models []launch.LaunchModel) []launch.LaunchModel {
|
||||
client, err := codexDesktopClientFactory()
|
||||
if err != nil {
|
||||
@@ -384,6 +384,9 @@ func hydrateCodexDesktopModelCapabilities(ctx context.Context, models []launch.L
|
||||
if response.Details.Family != "" || len(response.Details.Families) > 0 {
|
||||
hydrated[i].Details = response.Details
|
||||
}
|
||||
if response.Thinking.Valid() {
|
||||
hydrated[i].Thinking = response.Thinking.Clone()
|
||||
}
|
||||
}
|
||||
return hydrated
|
||||
}
|
||||
|
||||
@@ -1261,6 +1261,55 @@ func TestLoadCodexDesktopModelsHydratesAccountOnlyCloudCapabilities(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCodexDesktopModelsThinkingDiscovery(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
load func(context.Context, []string) (string, []launch.LaunchModel, error)
|
||||
recommendations []api.ModelRecommendation
|
||||
}{
|
||||
{"new connection discovers local controls", loadCodexDesktopConnectionModels, nil},
|
||||
{"update overrides stale recommendation", loadCodexDesktopModels, []api.ModelRecommendation{{Model: "custom-local:latest", Thinking: &api.ModelRecommendationThinking{Values: []any{false, true}, Default: true}}}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
stubCodexDesktopCatalogSources(t, tc.recommendations, proxy.ClaudeDesktopAccessState{Cloud: proxy.ClaudeDesktopCloudOff})
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/tags":
|
||||
fmt.Fprint(w, `{"models":[{"name":"custom-local:latest","capabilities":["completion","tools","thinking"]}]}`)
|
||||
case "/api/show":
|
||||
fmt.Fprint(w, `{"capabilities":["completion","thinking","tools"],"thinking":{"values":[false,"low","medium","xhigh"],"default":"medium"}}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
base, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := api.NewClient(base, server.Client())
|
||||
originalFactory, originalCloudModels := codexDesktopClientFactory, codexDesktopCloudModels
|
||||
t.Cleanup(func() { codexDesktopClientFactory = originalFactory; codexDesktopCloudModels = originalCloudModels })
|
||||
codexDesktopClientFactory = func() (*api.Client, error) { return client, nil }
|
||||
codexDesktopCloudModels = func(context.Context) ([]string, error) { return nil, nil }
|
||||
primary, models, err := tc.load(t.Context(), []string{"custom-local:latest"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if primary != "custom-local:latest" || len(models) != 1 {
|
||||
t.Fatalf("primary=%q models=%+v", primary, models)
|
||||
}
|
||||
thinking := models[0].Thinking
|
||||
if !thinking.Valid() || thinking.Default != "medium" || !slices.Equal(thinking.Values, []any{false, "low", "medium", "xhigh"}) {
|
||||
t.Fatalf("desktop lost discovered controls: %+v", thinking)
|
||||
}
|
||||
if len(tc.recommendations) > 0 && tc.recommendations[0].Thinking.Default != true {
|
||||
t.Fatal("discovery mutated recommendation metadata")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileCodexDesktopModelsDropsUnavailableSavedSelections(t *testing.T) {
|
||||
available := []launch.LaunchModel{
|
||||
{Name: "qwen3:8b"},
|
||||
|
||||
+11
-3
@@ -841,10 +841,8 @@ func RunHandler(cmd *cobra.Command, args []string) error {
|
||||
opts.Think = &api.ThinkValue{Value: true}
|
||||
case "false":
|
||||
opts.Think = &api.ThinkValue{Value: false}
|
||||
case "high", "medium", "low", "max":
|
||||
opts.Think = &api.ThinkValue{Value: thinkStr}
|
||||
default:
|
||||
return fmt.Errorf("invalid value for --think: %q (must be true, false, high, medium, low, or max)", thinkStr)
|
||||
opts.Think = &api.ThinkValue{Value: thinkStr}
|
||||
}
|
||||
} else {
|
||||
opts.Think = nil
|
||||
@@ -1429,6 +1427,16 @@ func showInfo(resp *api.ShowResponse, verbose bool, w io.Writer) error {
|
||||
tableRender("Capabilities", func() (rows [][]string) {
|
||||
for _, capability := range resp.Capabilities {
|
||||
rows = append(rows, []string{"", capability.String()})
|
||||
if capability == model.CapabilityThinking && resp.Thinking.Valid() {
|
||||
values := make([]string, len(resp.Thinking.Values))
|
||||
for i, value := range resp.Thinking.Values {
|
||||
values[i] = fmt.Sprint(value)
|
||||
}
|
||||
rows = append(rows,
|
||||
[]string{"", " levels", strings.Join(values, ", ")},
|
||||
[]string{"", " default", fmt.Sprint(resp.Thinking.Default)},
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
})
|
||||
|
||||
@@ -27,6 +27,54 @@ import (
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
func TestRunThinkingNamesReachServer(t *testing.T) {
|
||||
for _, value := range []string{"xhigh", "minimal", "future", "true", "false"} {
|
||||
t.Run(value, func(t *testing.T) {
|
||||
var got *api.ThinkValue
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/show":
|
||||
json.NewEncoder(w).Encode(api.ShowResponse{Capabilities: []model.Capability{model.CapabilityCompletion, model.CapabilityThinking}})
|
||||
case "/api/generate":
|
||||
var req api.GenerateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
got = req.Think
|
||||
json.NewEncoder(w).Encode(api.GenerateResponse{Done: true})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
t.Setenv("OLLAMA_HOST", server.URL)
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetContext(t.Context())
|
||||
for _, name := range []string{"format", "think", "keepalive"} {
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
for _, name := range []string{"verbose", "insecure", "nowordwrap", "hidethinking"} {
|
||||
cmd.Flags().Bool(name, false, "")
|
||||
}
|
||||
if err := cmd.Flags().Set("think", value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := RunHandler(cmd, []string{"thinking-test", "hi"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var want any = value
|
||||
if value == "true" {
|
||||
want = true
|
||||
} else if value == "false" {
|
||||
want = false
|
||||
}
|
||||
if got == nil || got.Value != want {
|
||||
t.Fatalf("think=%v, want %#v", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShowInfo(t *testing.T) {
|
||||
t.Run("bare details", func(t *testing.T) {
|
||||
var b bytes.Buffer
|
||||
|
||||
+27
-1
@@ -10,6 +10,8 @@ import (
|
||||
|
||||
"github.com/ollama/ollama/cmd/internal/fileutil"
|
||||
"github.com/ollama/ollama/envconfig"
|
||||
"github.com/ollama/ollama/model/renderers"
|
||||
"github.com/ollama/ollama/openai"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
"golang.org/x/mod/semver"
|
||||
@@ -715,6 +717,29 @@ func buildCodexModelEntry(launchModel LaunchModel) map[string]any {
|
||||
truncationMode = "tokens"
|
||||
}
|
||||
|
||||
supportedReasoningLevels := make([]any, 0)
|
||||
var defaultReasoningLevel any
|
||||
if contract, ok := codexAppThinkingContractFromRecommendation(launchModel.Thinking); ok {
|
||||
for _, level := range contract.levels {
|
||||
value := contract.values[level]
|
||||
converted, err := openai.ThinkingFromReasoningEffort(level, launchModel.Thinking)
|
||||
if err != nil || converted == nil {
|
||||
continue
|
||||
}
|
||||
resolved := renderers.ResolveThinking(converted, launchModel.Thinking)
|
||||
if resolved == nil || resolved.Value != value {
|
||||
continue
|
||||
}
|
||||
supportedReasoningLevels = append(supportedReasoningLevels, map[string]any{
|
||||
"effort": level,
|
||||
"description": codexAppThinkingLevelDescription(level),
|
||||
})
|
||||
}
|
||||
if contract.defaultLevel != "" {
|
||||
defaultReasoningLevel = contract.defaultLevel
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"slug": modelName,
|
||||
"display_name": modelName,
|
||||
@@ -730,7 +755,8 @@ func buildCodexModelEntry(launchModel LaunchModel) map[string]any {
|
||||
"default_verbosity": "low",
|
||||
"supports_parallel_tool_calls": false,
|
||||
"supports_reasoning_summaries": false,
|
||||
"supported_reasoning_levels": []any{},
|
||||
"supported_reasoning_levels": supportedReasoningLevels,
|
||||
"default_reasoning_level": defaultReasoningLevel,
|
||||
"experimental_supported_tools": []any{},
|
||||
}
|
||||
}
|
||||
|
||||
+39
-20
@@ -135,7 +135,7 @@ func (c *CodexApp) ConfigureWithModels(primary string, models []LaunchModel) err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeCodexAppConfig(configPath, primary, catalogPath); err != nil {
|
||||
if err := writeCodexAppConfig(configPath, primary, catalogPath, models); err != nil {
|
||||
if createdAuth {
|
||||
if removeErr := removeCodexAppManagedAuth(configPath); removeErr != nil {
|
||||
return errors.Join(err, fmt.Errorf("remove ChatGPT local auth after failed configuration: %w", removeErr))
|
||||
@@ -309,7 +309,7 @@ func codexAppFirstRoutingModel() string {
|
||||
return models[0]
|
||||
}
|
||||
|
||||
func writeCodexAppConfig(configPath, model, modelCatalogPath string) error {
|
||||
func writeCodexAppConfig(configPath, model, modelCatalogPath string, models []LaunchModel) error {
|
||||
baseURL := codexAppProxyBaseURL()
|
||||
|
||||
content, readErr := os.ReadFile(configPath)
|
||||
@@ -330,7 +330,15 @@ func writeCodexAppConfig(configPath, model, modelCatalogPath string) error {
|
||||
text = codexRemoveRootValue(text, codexRootModelProviderKey)
|
||||
text = codexSetRootStringValue(text, codexRootModelCatalogJSONKey, modelCatalogPath)
|
||||
text = codexSetRootStringValue(text, codexRootOpenAIBaseURLKey, baseURL)
|
||||
text = codexAppSetReasoningEfforts(text, codexAppReasoningEffortsForConfig(text))
|
||||
efforts := codexAppReasoningEffortsForConfig(text)
|
||||
for _, selected := range models {
|
||||
for _, level := range codexAppThinkingContractForModel(selected).levels {
|
||||
if !slices.Contains(efforts, level) {
|
||||
efforts = append(efforts, level)
|
||||
}
|
||||
}
|
||||
}
|
||||
text = codexAppSetReasoningEfforts(text, efforts)
|
||||
|
||||
parsed, err := codexParseConfig(text)
|
||||
if err != nil {
|
||||
@@ -1105,9 +1113,10 @@ func writeCodexAppRoutingCatalog(path string, models []LaunchModel, autoReview c
|
||||
return fmt.Errorf("chatgpt routing catalog cannot be empty")
|
||||
}
|
||||
type thinkingMetadata struct {
|
||||
Supported bool `json:"supported"`
|
||||
Levels []string `json:"levels,omitempty"`
|
||||
Values map[string]any `json:"values,omitempty"`
|
||||
Supported bool `json:"supported"`
|
||||
Levels []string `json:"levels,omitempty"`
|
||||
Values map[string]any `json:"values,omitempty"`
|
||||
Controls *modelpkg.Thinking `json:"controls,omitempty"`
|
||||
}
|
||||
type routingEntry struct {
|
||||
Slug string `json:"slug"`
|
||||
@@ -1122,6 +1131,7 @@ func writeCodexAppRoutingCatalog(path string, models []LaunchModel, autoReview c
|
||||
Supported: len(metadata.thinking.levels) > 0,
|
||||
Levels: metadata.thinking.levels,
|
||||
Values: metadata.thinking.values,
|
||||
Controls: metadata.thinking.controls,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1237,6 +1247,7 @@ type codexAppThinkingContract struct {
|
||||
defaultLevel string
|
||||
levels []string
|
||||
values map[string]any
|
||||
controls *modelpkg.Thinking
|
||||
}
|
||||
|
||||
func codexAppDefaultModelMetadata() codexAppModelMetadata {
|
||||
@@ -1281,27 +1292,30 @@ func codexAppThinkingContractForModel(model LaunchModel) codexAppThinkingContrac
|
||||
}
|
||||
}
|
||||
|
||||
// Binary thinking maps "none" to off and "medium" to on.
|
||||
// Binary thinking maps "none" to off and "high" to on.
|
||||
return codexAppThinkingContract{
|
||||
defaultLevel: "medium",
|
||||
levels: []string{"none", "medium"},
|
||||
values: map[string]any{"none": false, "medium": true},
|
||||
defaultLevel: "high",
|
||||
levels: []string{"none", "high"},
|
||||
values: map[string]any{"none": false, "high": true},
|
||||
}
|
||||
}
|
||||
|
||||
func codexAppThinkingContractFromRecommendation(thinking *api.ModelRecommendationThinking) (codexAppThinkingContract, bool) {
|
||||
if thinking == nil || len(thinking.Values) == 0 || thinking.Default == nil {
|
||||
if !thinking.Valid() {
|
||||
return codexAppThinkingContract{}, false
|
||||
}
|
||||
|
||||
contract := codexAppThinkingContract{values: make(map[string]any, len(thinking.Values))}
|
||||
contract := codexAppThinkingContract{values: make(map[string]any, len(thinking.Values)), controls: thinking.Clone()}
|
||||
for _, value := range thinking.Values {
|
||||
if value == true && thinking.Supports("high") {
|
||||
continue
|
||||
}
|
||||
level, ok := codexAppThinkingLevelForOllamaValue(value)
|
||||
if !ok {
|
||||
return codexAppThinkingContract{}, false
|
||||
continue
|
||||
}
|
||||
if _, duplicate := contract.values[level]; duplicate {
|
||||
return codexAppThinkingContract{}, false
|
||||
continue
|
||||
}
|
||||
contract.levels = append(contract.levels, level)
|
||||
contract.values[level] = value
|
||||
@@ -1310,10 +1324,12 @@ func codexAppThinkingContractFromRecommendation(thinking *api.ModelRecommendatio
|
||||
defaultLevel, ok := codexAppThinkingLevelForOllamaValue(thinking.Default)
|
||||
advertisedDefault, advertised := contract.values[defaultLevel]
|
||||
if !ok || !advertised || advertisedDefault != thinking.Default {
|
||||
return codexAppThinkingContract{}, false
|
||||
return contract, true
|
||||
}
|
||||
if len(contract.levels) == 1 && contract.levels[0] == "none" {
|
||||
return codexAppThinkingContract{}, true
|
||||
if len(thinking.Values) == 1 && thinking.Supports(false) {
|
||||
contract.levels = nil
|
||||
contract.values = nil
|
||||
return contract, true
|
||||
}
|
||||
contract.defaultLevel = defaultLevel
|
||||
return contract, true
|
||||
@@ -1323,12 +1339,15 @@ func codexAppThinkingLevelForOllamaValue(value any) (string, bool) {
|
||||
switch value := value.(type) {
|
||||
case bool:
|
||||
if value {
|
||||
return "medium", true
|
||||
return "high", true
|
||||
}
|
||||
return "none", true
|
||||
case string:
|
||||
think := api.ThinkValue{Value: value}
|
||||
return value, think.IsValid()
|
||||
switch value {
|
||||
case "minimal", "low", "medium", "high", "xhigh", "max", "ultra":
|
||||
return value, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -14,9 +14,11 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/cmd/internal/fileutil"
|
||||
"github.com/ollama/ollama/internal/proxy"
|
||||
"github.com/ollama/ollama/model/renderers"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
@@ -2017,10 +2019,10 @@ func TestCodexAppConfigurePopulatesCatalogFromEnrichedModels(t *testing.T) {
|
||||
t.Fatalf("supported_reasoning_levels for %q = %T, want list", slug, model["supported_reasoning_levels"])
|
||||
}
|
||||
if slug == "gemma4" {
|
||||
if model["default_reasoning_level"] != "medium" {
|
||||
t.Fatalf("default_reasoning_level for %q = %v, want medium", slug, model["default_reasoning_level"])
|
||||
if model["default_reasoning_level"] != "high" {
|
||||
t.Fatalf("default_reasoning_level for %q = %v, want high", slug, model["default_reasoning_level"])
|
||||
}
|
||||
wantEfforts := []string{"none", "medium"}
|
||||
wantEfforts := []string{"none", "high"}
|
||||
gotEfforts := make([]string, 0, len(levels))
|
||||
for _, level := range levels {
|
||||
entry, ok := level.(map[string]any)
|
||||
@@ -2093,10 +2095,10 @@ func TestCodexAppConfigurePopulatesCatalogFromEnrichedModels(t *testing.T) {
|
||||
t.Fatalf("routing catalog models = %#v, want 3 selected Ollama models", routingCatalog.Models)
|
||||
}
|
||||
gemmaThinking := routingCatalog.Models[0].Thinking
|
||||
if routingCatalog.Models[0].Slug != "gemma4" || !gemmaThinking.Supported || !slices.Equal(gemmaThinking.Levels, []string{"none", "medium"}) {
|
||||
t.Fatalf("gemma4 routing thinking = %+v, want binary off/medium metadata", gemmaThinking)
|
||||
if routingCatalog.Models[0].Slug != "gemma4" || !gemmaThinking.Supported || !slices.Equal(gemmaThinking.Levels, []string{"none", "high"}) {
|
||||
t.Fatalf("gemma4 routing thinking = %+v, want binary off/high metadata", gemmaThinking)
|
||||
}
|
||||
if gemmaThinking.Values["none"] != false || gemmaThinking.Values["medium"] != true {
|
||||
if gemmaThinking.Values["none"] != false || gemmaThinking.Values["high"] != true {
|
||||
t.Fatalf("gemma4 routing thinking values = %#v, want exact false/true values", gemmaThinking.Values)
|
||||
}
|
||||
for _, routed := range routingCatalog.Models[1:] {
|
||||
@@ -2118,7 +2120,14 @@ func TestCodexAppThinkingLevelsUseRecommendationsThenFallbacks(t *testing.T) {
|
||||
wantValues map[string]any
|
||||
}{
|
||||
{name: "non-thinking model"},
|
||||
{name: "binary fallback", thinking: true, wantInitial: "medium", wantLevels: []string{"none", "medium"}, wantValues: map[string]any{"none": false, "medium": true}},
|
||||
{
|
||||
name: "harmony descriptor without family fallback",
|
||||
recommendation: renderers.ThinkingForRenderer("harmony"),
|
||||
wantInitial: "medium",
|
||||
wantLevels: []string{"low", "medium", "high"},
|
||||
wantValues: map[string]any{"low": "low", "medium": "medium", "high": "high"},
|
||||
},
|
||||
{name: "binary fallback", thinking: true, wantInitial: "high", wantLevels: []string{"none", "high"}, wantValues: map[string]any{"none": false, "high": true}},
|
||||
{
|
||||
name: "recommendation with adjustable strings",
|
||||
recommendation: &api.ModelRecommendationThinking{Values: []any{"low", "high", "max"}, Default: "high"},
|
||||
@@ -2129,16 +2138,39 @@ func TestCodexAppThinkingLevelsUseRecommendationsThenFallbacks(t *testing.T) {
|
||||
{
|
||||
name: "recommendation with mixed boolean and string values",
|
||||
recommendation: &api.ModelRecommendationThinking{Values: []any{false, true, "max"}, Default: true},
|
||||
wantInitial: "medium",
|
||||
wantLevels: []string{"none", "medium", "max"},
|
||||
wantValues: map[string]any{"none": false, "medium": true, "max": "max"},
|
||||
wantInitial: "high",
|
||||
wantLevels: []string{"none", "high", "max"},
|
||||
wantValues: map[string]any{"none": false, "high": true, "max": "max"},
|
||||
},
|
||||
{
|
||||
name: "boolean on is distinct from named medium",
|
||||
recommendation: &api.ModelRecommendationThinking{Values: []any{false, true, "medium"}, Default: true},
|
||||
wantInitial: "high",
|
||||
wantLevels: []string{"none", "high", "medium"},
|
||||
wantValues: map[string]any{"none": false, "high": true, "medium": "medium"},
|
||||
},
|
||||
{
|
||||
name: "recommendation with binary thinking off by default",
|
||||
recommendation: &api.ModelRecommendationThinking{Values: []any{false, true}, Default: false},
|
||||
wantInitial: "none",
|
||||
wantLevels: []string{"none", "medium"},
|
||||
wantValues: map[string]any{"none": false, "medium": true},
|
||||
wantLevels: []string{"none", "high"},
|
||||
wantValues: map[string]any{"none": false, "high": true},
|
||||
},
|
||||
{
|
||||
name: "xhigh is retained",
|
||||
recommendation: &api.ModelRecommendationThinking{Values: []any{false, "low", "medium", "xhigh"}, Default: "medium"},
|
||||
wantInitial: "medium", wantLevels: []string{"none", "low", "medium", "xhigh"},
|
||||
wantValues: map[string]any{"none": false, "low": "low", "medium": "medium", "xhigh": "xhigh"},
|
||||
},
|
||||
{
|
||||
name: "partial catalog keeps controls with unrepresentable default",
|
||||
recommendation: &api.ModelRecommendationThinking{Values: []any{"low", "high", "turbo"}, Default: "turbo"},
|
||||
wantLevels: []string{"low", "high"}, wantValues: map[string]any{"low": "low", "high": "high"},
|
||||
},
|
||||
{
|
||||
name: "literal high takes its label",
|
||||
recommendation: &api.ModelRecommendationThinking{Values: []any{false, true, "high"}, Default: true},
|
||||
wantLevels: []string{"none", "high"}, wantValues: map[string]any{"none": false, "high": "high"},
|
||||
},
|
||||
{
|
||||
name: "explicit non-thinking recommendation",
|
||||
@@ -2149,12 +2181,12 @@ func TestCodexAppThinkingLevelsUseRecommendationsThenFallbacks(t *testing.T) {
|
||||
name: "invalid recommendation uses capability fallback",
|
||||
thinking: true,
|
||||
recommendation: &api.ModelRecommendationThinking{Values: []any{"low", "high"}, Default: "max"},
|
||||
wantInitial: "medium",
|
||||
wantLevels: []string{"none", "medium"},
|
||||
wantValues: map[string]any{"none": false, "medium": true},
|
||||
wantInitial: "high",
|
||||
wantLevels: []string{"none", "high"},
|
||||
wantValues: map[string]any{"none": false, "high": true},
|
||||
},
|
||||
{name: "model name alone does not infer thinking", modelName: "glm-5.3-flash:cloud"},
|
||||
{name: "similar unverified tag uses fallback", modelName: "glm-5.3-flash:custom", thinking: true, wantInitial: "medium", wantLevels: []string{"none", "medium"}},
|
||||
{name: "similar unverified tag uses fallback", modelName: "glm-5.3-flash:custom", thinking: true, wantInitial: "high", wantLevels: []string{"none", "high"}},
|
||||
{name: "GLM 5.3 Flash family fallback", family: "glm5_next", thinking: true, wantInitial: "max", wantLevels: []string{"low", "high", "max"}},
|
||||
{name: "GLM 5.3 family fallback", modelName: "glm-5.3:cloud", family: "glm_dsa_moe", thinking: true, wantInitial: "max", wantLevels: []string{"low", "high", "max"}},
|
||||
{name: "GPT-OSS family", family: "gpt-oss", thinking: true, wantInitial: "medium", wantLevels: []string{"low", "medium", "high"}},
|
||||
@@ -2215,15 +2247,15 @@ func TestCodexAppConfigureWritesRecommendationThinkingContract(t *testing.T) {
|
||||
if len(catalog.Models) == 0 || catalog.Models[0].Slug != model.Name {
|
||||
t.Fatalf("catalog models = %#v, want selected model first", catalog.Models)
|
||||
}
|
||||
if got := catalog.Models[0].DefaultReasoningLevel; got != "medium" {
|
||||
t.Fatalf("default reasoning level = %q, want medium for Ollama true", got)
|
||||
if got := catalog.Models[0].DefaultReasoningLevel; got != "high" {
|
||||
t.Fatalf("default reasoning level = %q, want high for Ollama true", got)
|
||||
}
|
||||
var levels []string
|
||||
for _, level := range catalog.Models[0].SupportedReasoningLevels {
|
||||
levels = append(levels, level.Effort)
|
||||
}
|
||||
if !slices.Equal(levels, []string{"none", "medium", "max"}) {
|
||||
t.Fatalf("reasoning levels = %v, want none/medium/max", levels)
|
||||
if !slices.Equal(levels, []string{"none", "high", "max"}) {
|
||||
t.Fatalf("reasoning levels = %v, want none/high/max", levels)
|
||||
}
|
||||
|
||||
configPath, err := codexConfigPath()
|
||||
@@ -2237,7 +2269,8 @@ func TestCodexAppConfigureWritesRecommendationThinkingContract(t *testing.T) {
|
||||
var routing struct {
|
||||
Models []struct {
|
||||
Thinking struct {
|
||||
Values map[string]any `json:"values"`
|
||||
Values map[string]any `json:"values"`
|
||||
Controls *api.ModelRecommendationThinking `json:"controls"`
|
||||
} `json:"thinking"`
|
||||
} `json:"models"`
|
||||
}
|
||||
@@ -2245,7 +2278,10 @@ func TestCodexAppConfigureWritesRecommendationThinkingContract(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
values := routing.Models[0].Thinking.Values
|
||||
if values["none"] != false || values["medium"] != true || values["max"] != "max" {
|
||||
if diff := cmp.Diff(model.Thinking, routing.Models[0].Thinking.Controls); diff != "" {
|
||||
t.Fatalf("routing lost the model contract: %s", diff)
|
||||
}
|
||||
if values["none"] != false || values["high"] != true || values["max"] != "max" {
|
||||
t.Fatalf("routing thinking values = %#v, want exact endpoint values", values)
|
||||
}
|
||||
}
|
||||
@@ -2548,8 +2584,8 @@ func TestCodexAppRestoreRestoresDesktopReasoningEffortsExactly(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "existing user choices",
|
||||
desktopValue: `enabled-reasoning-efforts = ["minimal", "high", "persistent"]` + "\n",
|
||||
wantOriginal: []string{"minimal", "high", "persistent"},
|
||||
desktopValue: `enabled-reasoning-efforts = ["minimal", "persistent"]` + "\n",
|
||||
wantOriginal: []string{"minimal", "persistent"},
|
||||
},
|
||||
{name: "setting originally absent"},
|
||||
}
|
||||
@@ -2575,10 +2611,10 @@ func TestCodexAppRestoreRestoresDesktopReasoningEffortsExactly(t *testing.T) {
|
||||
}
|
||||
|
||||
app := &CodexApp{}
|
||||
if err := app.ConfigureWithModels("gemma4", []LaunchModel{{
|
||||
Name: "gemma4",
|
||||
Capabilities: []model.Capability{model.CapabilityThinking},
|
||||
}}); err != nil {
|
||||
if err := app.ConfigureWithModels("gemma4", []LaunchModel{
|
||||
{Name: "gemma4", Capabilities: []model.Capability{model.CapabilityThinking}},
|
||||
{Name: "custom-qwen", Thinking: &api.ModelRecommendationThinking{Values: []any{false, "low", "medium", "xhigh"}, Default: "medium"}},
|
||||
}); err != nil {
|
||||
t.Fatalf("ConfigureWithModels returned error: %v", err)
|
||||
}
|
||||
|
||||
@@ -2594,6 +2630,11 @@ func TestCodexAppRestoreRestoresDesktopReasoningEffortsExactly(t *testing.T) {
|
||||
if !ok || !slices.Contains(managedEfforts, "none") || !slices.Contains(managedEfforts, "max") {
|
||||
t.Fatalf("managed reasoning efforts = %v, %v; want none and max enabled", managedEfforts, ok)
|
||||
}
|
||||
for _, level := range append(slices.Clone(tt.wantOriginal), "high", "low", "medium", "xhigh") {
|
||||
if !slices.Contains(managedEfforts, level) {
|
||||
t.Errorf("managed efforts %v hide %q", managedEfforts, level)
|
||||
}
|
||||
}
|
||||
|
||||
if err := app.Restore(); err != nil {
|
||||
t.Fatalf("Restore returned error: %v", err)
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/cmd/internal/fileutil"
|
||||
"github.com/ollama/ollama/model/renderers"
|
||||
modelpkg "github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
@@ -721,3 +722,38 @@ func TestBuildCodexModelEntryContextWindow(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexThinkingControls(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
thinking *api.ModelRecommendationThinking
|
||||
levels []string
|
||||
defaultLevel any
|
||||
}{
|
||||
{"unknown", nil, nil, nil},
|
||||
{"harmony", renderers.ThinkingForRenderer("harmony"), []string{"low", "medium", "high"}, "medium"},
|
||||
{"named", &api.ModelRecommendationThinking{Values: []any{false, "low", "high", "max"}, Default: "high"}, []string{"none", "low", "high", "max"}, "high"},
|
||||
{"binary default on", &api.ModelRecommendationThinking{Values: []any{false, true}, Default: true}, []string{"none", "high"}, "high"},
|
||||
{"binary default off", &api.ModelRecommendationThinking{Values: []any{false, true}, Default: false}, []string{"none", "high"}, "none"},
|
||||
{"mixed", &api.ModelRecommendationThinking{Values: []any{false, true, "medium"}, Default: true}, []string{"none", "high", "medium"}, "high"},
|
||||
{"xhigh", &api.ModelRecommendationThinking{Values: []any{false, "low", "medium", "xhigh"}, Default: "medium"}, []string{"none", "low", "medium", "xhigh"}, "medium"},
|
||||
{"minimal", &api.ModelRecommendationThinking{Values: []any{"minimal", "high"}, Default: "minimal"}, []string{"minimal", "high"}, "minimal"},
|
||||
{"future control", &api.ModelRecommendationThinking{Values: []any{"low", "high", "turbo"}, Default: "high"}, []string{"low", "high"}, "high"},
|
||||
{"future default", &api.ModelRecommendationThinking{Values: []any{"low", "high", "turbo"}, Default: "turbo"}, []string{"low", "high"}, nil},
|
||||
{"on label collision", &api.ModelRecommendationThinking{Values: []any{false, true, "high"}, Default: true}, []string{"none", "high"}, nil},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
entry := buildCodexModelEntry(LaunchModel{Name: "fixture:cloud", Thinking: tt.thinking})
|
||||
var levels []string
|
||||
for _, v := range entry["supported_reasoning_levels"].([]any) {
|
||||
levels = append(levels, v.(map[string]any)["effort"].(string))
|
||||
}
|
||||
if !slices.Equal(levels, tt.levels) {
|
||||
t.Fatalf("levels = %v, want %v", levels, tt.levels)
|
||||
}
|
||||
if entry["default_reasoning_level"] != tt.defaultLevel {
|
||||
t.Fatalf("default = %v, want %v", entry["default_reasoning_level"], tt.defaultLevel)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+28
-7
@@ -9,6 +9,7 @@ import (
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/cmd/config"
|
||||
@@ -737,7 +738,7 @@ func (c *launcherClient) launchSingleIntegration(ctx context.Context, name strin
|
||||
}
|
||||
}
|
||||
|
||||
return launchAfterConfiguration(name, runner, target, c.resolveRunModels(ctx, []string{target}), req)
|
||||
return launchAfterConfiguration(name, runner, target, c.resolveRunModels(ctx, name, []string{target}), req)
|
||||
}
|
||||
|
||||
func (c *launcherClient) launchEditorIntegration(ctx context.Context, name string, runner Runner, editor Editor, saved *config.IntegrationConfig, req IntegrationLaunchRequest) error {
|
||||
@@ -770,12 +771,12 @@ func (c *launcherClient) launchEditorIntegration(ctx context.Context, name strin
|
||||
var launchModels []LaunchModel
|
||||
liveConfigMatches := slices.Equal(editor.Models(), models)
|
||||
if needsConfigure || req.ModelOverride != "" || !savedMatchesModels(saved, models) || !liveConfigMatches {
|
||||
launchModels = c.resolveRunModels(ctx, models)
|
||||
launchModels = c.resolveRunModels(ctx, name, models)
|
||||
if err := prepareEditorIntegration(name, editor, launchModels); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
launchModels = c.resolveRunModels(ctx, models)
|
||||
launchModels = c.resolveRunModels(ctx, name, models)
|
||||
}
|
||||
|
||||
return launchAfterConfiguration(name, runner, models[0], launchModels, req)
|
||||
@@ -802,12 +803,17 @@ func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, nam
|
||||
liveConfigMissing := current == ""
|
||||
liveConfigDrifted := current != "" && target != current
|
||||
configured := false
|
||||
var runModels []LaunchModel
|
||||
if needsConfigure || req.ModelOverride != "" || liveConfigMissing || liveConfigDrifted || !savedMatchesModels(saved, []string{target}) {
|
||||
configureModels, err := c.managedSingleConfigureModels(ctx, managed, target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := prepareManagedSingleIntegration(name, managed, target, c.resolveRunModels(ctx, configureModels)); err != nil {
|
||||
resolvedModels := c.resolveRunModels(ctx, name, configureModels)
|
||||
if primary, ok := findLaunchModel(resolvedModels, target); ok {
|
||||
runModels = []LaunchModel{primary}
|
||||
}
|
||||
if err := prepareManagedSingleIntegration(name, managed, target, resolvedModels); err != nil {
|
||||
return err
|
||||
}
|
||||
if refresher, ok := managed.(ManagedRuntimeRefresher); ok {
|
||||
@@ -837,7 +843,10 @@ func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, nam
|
||||
return nil
|
||||
}
|
||||
|
||||
return runIntegration(runner, target, c.resolveRunModels(ctx, []string{target}), req.ExtraArgs)
|
||||
if len(runModels) == 0 {
|
||||
runModels = c.resolveRunModels(ctx, name, []string{target})
|
||||
}
|
||||
return runIntegration(runner, target, runModels, req.ExtraArgs)
|
||||
}
|
||||
|
||||
func (c *launcherClient) launchManagedAutodiscoveryIntegration(ctx context.Context, name string, runner Runner, autodiscovery ManagedAutodiscoveryIntegration, saved *config.IntegrationConfig, req IntegrationLaunchRequest) error {
|
||||
@@ -879,7 +888,7 @@ func (c *launcherClient) launchManagedAutodiscoveryIntegration(ctx context.Conte
|
||||
return nil
|
||||
}
|
||||
|
||||
return runIntegration(runner, target, c.resolveRunModels(ctx, []string{target}), req.ExtraArgs)
|
||||
return runIntegration(runner, target, c.resolveRunModels(ctx, name, []string{target}), req.ExtraArgs)
|
||||
}
|
||||
|
||||
func (c *launcherClient) managedAutodiscoveryUsable(ctx context.Context, autodiscovery ManagedAutodiscoveryIntegration) bool {
|
||||
@@ -1444,7 +1453,7 @@ func hasLocalModel(inventory []LaunchModel, name string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *launcherClient) resolveRunModels(ctx context.Context, models []string) []LaunchModel {
|
||||
func (c *launcherClient) resolveRunModels(ctx context.Context, integration string, models []string) []LaunchModel {
|
||||
recommendations := c.recommendations(ctx)
|
||||
resolved := c.modelInventory().Resolve(ctx, models)
|
||||
byName := make(map[string]*api.ModelRecommendationThinking, len(recommendations))
|
||||
@@ -1458,6 +1467,18 @@ func (c *launcherClient) resolveRunModels(ctx context.Context, models []string)
|
||||
resolved[i].Thinking = thinking.Clone()
|
||||
}
|
||||
}
|
||||
if integration != "codex" && integration != chatGPTIntegrationName && integration != codexAppIntegrationName {
|
||||
return resolved
|
||||
}
|
||||
showCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
for i := range resolved {
|
||||
if showCtx.Err() == nil {
|
||||
if show, err := c.apiClient.Show(showCtx, &api.ShowRequest{Model: resolved[i].Name}); err == nil && show.Thinking.Valid() {
|
||||
resolved[i].Thinking = show.Thinking.Clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
|
||||
+200
-2
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/cmd/config"
|
||||
"github.com/ollama/ollama/cmd/internal/fileutil"
|
||||
)
|
||||
@@ -64,7 +66,7 @@ func TestResolveRunModelsCarriesRecommendationThinkingMetadata(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
models := client.resolveRunModels(context.Background(), []string{"deepseek-v4-flash:cloud"})
|
||||
models := client.resolveRunModels(context.Background(), "test", []string{"deepseek-v4-flash:cloud"})
|
||||
if len(models) != 1 || models[0].Thinking == nil {
|
||||
t.Fatalf("resolved models = %#v, want recommendation thinking metadata", models)
|
||||
}
|
||||
@@ -73,6 +75,91 @@ func TestResolveRunModelsCarriesRecommendationThinkingMetadata(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRunModelsUsesThinkingDiscovery(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, show string
|
||||
integration string
|
||||
recommendation bool
|
||||
want []any
|
||||
}{
|
||||
{"local custom CLI model", `{"thinking":{"values":[false,true,"medium"],"default":true}}`, "codex", false, []any{false, true, "medium"}},
|
||||
{"local custom desktop model", `{"thinking":{"values":[false,true,"medium"],"default":true}}`, "chatgpt", false, []any{false, true, "medium"}},
|
||||
{"show overrides recommendation", `{"thinking":{"values":[false,true,"medium"],"default":true}}`, "codex", true, []any{false, true, "medium"}},
|
||||
{"invalid metadata preserves recommendation", `{"thinking":{"values":[false,true],"default":"missing"}}`, "codex", true, []any{false, true}},
|
||||
{"missing metadata preserves recommendation", `{}`, "codex", true, []any{false, true}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
showCalls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/experimental/model-recommendations":
|
||||
if tc.recommendation {
|
||||
fmt.Fprint(w, `{"recommendations":[{"model":"custom-local","thinking":{"values":[false,true],"default":true}}]}`)
|
||||
} else {
|
||||
fmt.Fprint(w, `{"recommendations":[]}`)
|
||||
}
|
||||
case "/api/tags":
|
||||
fmt.Fprint(w, `{"models":[{"name":"custom-local:latest","capabilities":["completion","thinking"]}]}`)
|
||||
case "/api/show":
|
||||
showCalls++
|
||||
fmt.Fprint(w, tc.show)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
t.Setenv("OLLAMA_HOST", server.URL)
|
||||
client, err := newLauncherClient(defaultLaunchPolicy(false, false))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
models := client.resolveRunModels(t.Context(), tc.integration, []string{"custom-local"})
|
||||
if len(models) != 1 || models[0].Thinking == nil || !slices.Equal(models[0].Thinking.Values, tc.want) || showCalls != 1 {
|
||||
t.Fatalf("models=%+v showCalls=%d", models, showCalls)
|
||||
}
|
||||
contract := codexAppThinkingContractForModel(models[0])
|
||||
if !slices.Equal(contract.controls.Values, tc.want) {
|
||||
t.Fatalf("desktop controls=%+v, want %v", contract.controls, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type thinkingDeadlineTransport struct {
|
||||
t *testing.T
|
||||
calls int
|
||||
}
|
||||
|
||||
func (transport *thinkingDeadlineTransport) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
transport.calls++
|
||||
deadline, ok := r.Context().Deadline()
|
||||
if !ok || time.Until(deadline) > 5*time.Second {
|
||||
transport.t.Error("thinking discovery request must have a bounded deadline")
|
||||
}
|
||||
return nil, context.DeadlineExceeded
|
||||
}
|
||||
|
||||
func TestResolveRunModelsThinkingDiscoveryTimeout(t *testing.T) {
|
||||
client, err := newLauncherClient(defaultLaunchPolicy(false, false))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
transport := &thinkingDeadlineTransport{t: t}
|
||||
client.apiClient = api.NewClient(&url.URL{Scheme: "http", Host: "thinking.test"}, &http.Client{Transport: transport})
|
||||
client.recommendationsLoaded = true
|
||||
// Seed the existing inventory so this test isolates the new best-effort lookup.
|
||||
client.inventory = newModelInventory(client.apiClient)
|
||||
client.inventory.loaded = true
|
||||
client.inventory.models = []LaunchModel{{Name: "custom-local", Thinking: &api.ModelRecommendationThinking{Values: []any{false, true}, Default: true}}}
|
||||
models := client.resolveRunModels(t.Context(), "codex", []string{"custom-local"})
|
||||
if len(models) != 1 || models[0].Thinking == nil || models[0].Thinking.Default != true {
|
||||
t.Fatalf("failed discovery lost existing metadata: %+v", models)
|
||||
}
|
||||
if transport.calls != 1 {
|
||||
t.Fatalf("show calls=%d, want 1", transport.calls)
|
||||
}
|
||||
}
|
||||
|
||||
type launcherSingleRunner struct {
|
||||
ranModel string
|
||||
}
|
||||
@@ -113,6 +200,7 @@ type launcherManagedRunner struct {
|
||||
currentModel string
|
||||
configured []string
|
||||
ranModel string
|
||||
ranModels []LaunchModel
|
||||
onboarded bool
|
||||
onboardCalls int
|
||||
onboardingComplete bool
|
||||
@@ -123,8 +211,9 @@ type launcherManagedRunner struct {
|
||||
skipModelReadiness bool
|
||||
}
|
||||
|
||||
func (r *launcherManagedRunner) Run(model string, _ []LaunchModel, args []string) error {
|
||||
func (r *launcherManagedRunner) Run(model string, models []LaunchModel, args []string) error {
|
||||
r.ranModel = model
|
||||
r.ranModels = cloneLaunchModels(models)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -171,10 +260,12 @@ func (r *launcherHeadlessManagedRunner) RequiresInteractiveOnboarding() bool { r
|
||||
type launcherManagedListRunner struct {
|
||||
launcherManagedRunner
|
||||
configuredModelLists [][]string
|
||||
configuredModels [][]LaunchModel
|
||||
}
|
||||
|
||||
func (r *launcherManagedListRunner) ConfigureWithModels(primary string, models []LaunchModel) error {
|
||||
r.configuredModelLists = append(r.configuredModelLists, launchModelNames(models))
|
||||
r.configuredModels = append(r.configuredModels, cloneLaunchModels(models))
|
||||
return r.Configure(primary)
|
||||
}
|
||||
|
||||
@@ -551,6 +642,113 @@ func TestLaunchIntegration_ManagedSingleIntegrationConfiguresOnboardsAndRuns(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchManagedSingleIntegrationReusesThinkingDiscovery(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
showStatus int
|
||||
configureOnly bool
|
||||
unchanged bool
|
||||
}{
|
||||
{name: "configure and run", showStatus: http.StatusOK},
|
||||
{name: "failed discovery keeps fallback", showStatus: http.StatusServiceUnavailable},
|
||||
{name: "configure only", showStatus: http.StatusOK, configureOnly: true},
|
||||
{name: "unchanged configuration", showStatus: http.StatusOK, unchanged: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
setLaunchTestHome(t, t.TempDir())
|
||||
withInteractiveSession(t, true)
|
||||
withLauncherHooks(t)
|
||||
DefaultConfirmPrompt = func(string, ConfirmOptions) (bool, error) { return true, nil }
|
||||
|
||||
var primaryCalls, secondaryCalls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/show":
|
||||
var request api.ShowRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Error(err)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
switch request.Model {
|
||||
case "custom-primary:latest":
|
||||
primaryCalls.Add(1)
|
||||
case "custom-secondary:latest":
|
||||
secondaryCalls.Add(1)
|
||||
default:
|
||||
t.Errorf("unexpected show model %q", request.Model)
|
||||
}
|
||||
w.WriteHeader(tc.showStatus)
|
||||
fmt.Fprint(w, `{"thinking":{"values":[false,"medium","xhigh"],"default":"xhigh"}}`)
|
||||
case "/api/status":
|
||||
fmt.Fprint(w, `{"cloud":{"disabled":false}}`)
|
||||
default:
|
||||
t.Errorf("unexpected request %s", r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
t.Setenv("OLLAMA_HOST", server.URL)
|
||||
client, err := newLauncherClient(defaultLaunchPolicy(true, false))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fallback := &api.ModelRecommendationThinking{Values: []any{false, true}, Default: true}
|
||||
client.recommendationsLoaded = true
|
||||
client.recommendationItems = []ModelItem{{Name: "custom-primary", Thinking: fallback}}
|
||||
client.inventory.loaded = true
|
||||
client.inventory.models = []LaunchModel{{Name: "custom-primary:latest"}, {Name: "custom-secondary:latest"}}
|
||||
runner := &launcherManagedListRunner{launcherManagedRunner: launcherManagedRunner{
|
||||
currentModel: "custom-primary",
|
||||
onboardingComplete: true,
|
||||
skipModelReadiness: true,
|
||||
}}
|
||||
saved := &config.IntegrationConfig{Models: []string{"custom-primary"}, Onboarded: true}
|
||||
request := IntegrationLaunchRequest{ModelOverride: "custom-primary", ConfigureOnly: tc.configureOnly}
|
||||
if tc.unchanged {
|
||||
request.ModelOverride = ""
|
||||
}
|
||||
if err := client.launchManagedSingleIntegration(t.Context(), chatGPTIntegrationName, runner, runner, saved, request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := primaryCalls.Load(); got != 1 {
|
||||
t.Fatalf("primary show calls = %d, want 1", got)
|
||||
}
|
||||
wantSecondary := int32(1)
|
||||
if tc.unchanged {
|
||||
wantSecondary = 0
|
||||
}
|
||||
if got := secondaryCalls.Load(); got != wantSecondary {
|
||||
t.Fatalf("secondary show calls = %d, want %d", got, wantSecondary)
|
||||
}
|
||||
wantThinking := &api.ModelRecommendationThinking{Values: []any{false, "medium", "xhigh"}, Default: "xhigh"}
|
||||
if tc.showStatus != http.StatusOK {
|
||||
wantThinking = fallback
|
||||
}
|
||||
if !tc.unchanged {
|
||||
if len(runner.configuredModels) != 1 || len(runner.configuredModels[0]) != 2 {
|
||||
t.Fatalf("configured models = %+v, want both selected models once", runner.configuredModels)
|
||||
}
|
||||
if diff := cmp.Diff(wantThinking, runner.configuredModels[0][0].Thinking); diff != "" {
|
||||
t.Fatalf("configured thinking mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
if tc.configureOnly {
|
||||
if runner.ranModel != "" {
|
||||
t.Fatal("configure-only flow launched the runner")
|
||||
}
|
||||
return
|
||||
}
|
||||
if runner.ranModel != "custom-primary" || len(runner.ranModels) != 1 || runner.ranModels[0].Name != "custom-primary:latest" {
|
||||
t.Fatalf("run model=%q models=%+v, want only the primary model", runner.ranModel, runner.ranModels)
|
||||
}
|
||||
if diff := cmp.Diff(wantThinking, runner.ranModels[0].Thinking); diff != "" {
|
||||
t.Fatalf("run thinking mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchIntegration_ManagedSingleIntegrationReOnboardsWhenSavedFlagIsStale(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setLaunchTestHome(t, tmpDir)
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -574,6 +575,7 @@ type routingThinkingMetadata struct {
|
||||
Supported bool `json:"supported"`
|
||||
Levels []string `json:"levels,omitempty"`
|
||||
Values map[string]json.RawMessage `json:"values,omitempty"`
|
||||
Controls *model.Thinking `json:"controls,omitempty"`
|
||||
}
|
||||
|
||||
type routingModel struct {
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/ollama/ollama/openai"
|
||||
)
|
||||
|
||||
func extractModel(body []byte) (string, bool) {
|
||||
@@ -237,7 +239,7 @@ func normalizeOllamaThinking(body []byte, metadata routingThinkingMetadata) ([]b
|
||||
return body, nil
|
||||
}
|
||||
|
||||
normalizedEffort := normalizeThinkingEffort(effort, metadata.Levels)
|
||||
normalizedEffort := normalizeThinkingEffort(effort, metadata)
|
||||
if normalizedEffort == "" {
|
||||
// Omit stale effort selections so the model can use its default.
|
||||
delete(reasoning, "effort")
|
||||
@@ -260,7 +262,24 @@ func normalizeOllamaThinking(body []byte, metadata routingThinkingMetadata) ([]b
|
||||
return json.Marshal(payload)
|
||||
}
|
||||
|
||||
func normalizeThinkingEffort(effort string, levels []string) string {
|
||||
func normalizeThinkingEffort(effort string, metadata routingThinkingMetadata) string {
|
||||
levels := metadata.Levels
|
||||
if metadata.Controls.Valid() {
|
||||
if slices.Contains(levels, effort) {
|
||||
return effort
|
||||
}
|
||||
think, err := openai.ThinkingFromReasoningEffort(effort, metadata.Controls)
|
||||
if err != nil || think == nil {
|
||||
return ""
|
||||
}
|
||||
for _, level := range levels {
|
||||
var value any
|
||||
if json.Unmarshal(metadata.Values[level], &value) == nil && value == think.Value {
|
||||
return level
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
var normalized string
|
||||
switch effort {
|
||||
case "minimal":
|
||||
@@ -272,6 +291,9 @@ func normalizeThinkingEffort(effort string, levels []string) string {
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
if effort == "medium" && slices.Equal(levels, []string{"none", "high"}) && bytes.Equal(bytes.TrimSpace(metadata.Values["high"]), []byte("true")) {
|
||||
return "high"
|
||||
}
|
||||
|
||||
if slices.Equal(levels, []string{"none", "medium"}) && normalized != "none" {
|
||||
// Binary thinking uses "medium" for on, not an adjustable effort level.
|
||||
|
||||
@@ -18,6 +18,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"github.com/ollama/ollama/model/renderers"
|
||||
"github.com/ollama/ollama/openai"
|
||||
modelpkg "github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
func TestCodexDesktopRoutesCatalogModelToOllamaAndStripsCredentials(t *testing.T) {
|
||||
@@ -308,6 +311,59 @@ func TestNormalizeOllamaThinkingUsesRoutedModelContract(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOllamaDiscoveredThinking(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
controls *modelpkg.Thinking
|
||||
values map[string]any
|
||||
effort string
|
||||
want any
|
||||
}{
|
||||
{"exact xhigh", &modelpkg.Thinking{Values: []any{false, "low", "medium", "xhigh"}, Default: "medium"}, map[string]any{"none": false, "low": "low", "medium": "medium", "xhigh": "xhigh"}, "xhigh", "xhigh"},
|
||||
{"exact minimal", &modelpkg.Thinking{Values: []any{"minimal", "high"}, Default: "high"}, map[string]any{"minimal": "minimal", "high": "high"}, "minimal", "minimal"},
|
||||
{"unsupported xhigh defaults", &modelpkg.Thinking{Values: []any{false, "high", "max"}, Default: "high"}, map[string]any{"none": false, "high": "high", "max": "max"}, "xhigh", "high"},
|
||||
{"saved Boolean medium", &modelpkg.Thinking{Values: []any{false, true}, Default: false}, map[string]any{"none": false, "high": true}, "medium", true},
|
||||
{"Boolean effort alias", &modelpkg.Thinking{Values: []any{false, true}, Default: false}, map[string]any{"none": false, "high": true}, "minimal", true},
|
||||
{"unknown Boolean effort defaults", &modelpkg.Thinking{Values: []any{false, true}, Default: false}, map[string]any{"none": false, "high": true}, "future", false},
|
||||
{"mixed named medium", &modelpkg.Thinking{Values: []any{false, true, "medium"}, Default: false}, map[string]any{"none": false, "high": true, "medium": "medium"}, "medium", "medium"},
|
||||
{"hidden named control prevents Boolean alias", &modelpkg.Thinking{Values: []any{false, true, "turbo"}, Default: false}, map[string]any{"none": false, "high": true}, "medium", false},
|
||||
{"hidden default", &modelpkg.Thinking{Values: []any{"low", "high", "turbo"}, Default: "turbo"}, map[string]any{"low": "low", "high": "high"}, "medium", "turbo"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
metadata := routingThinkingMetadata{Supported: true, Controls: tc.controls, Values: make(map[string]json.RawMessage)}
|
||||
for level, value := range tc.values {
|
||||
metadata.Levels = append(metadata.Levels, level)
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
metadata.Values[level] = encoded
|
||||
}
|
||||
body := []byte(fmt.Sprintf(`{"model":"test","input":"hi","reasoning":{"effort":%q,"summary":"auto"}}`, tc.effort))
|
||||
normalized, err := normalizeOllamaThinking(body, metadata)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var request openai.ResponsesRequest
|
||||
if err := json.Unmarshal(normalized, &request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
converted, err := openai.FromResponsesRequest(request, tc.controls)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved := renderers.ResolveThinking(converted.Think, tc.controls)
|
||||
if resolved == nil || resolved.Value != tc.want {
|
||||
t.Fatalf("resolved=%v, want %#v; request=%s", resolved, tc.want, normalized)
|
||||
}
|
||||
})
|
||||
}
|
||||
legacy := routingThinkingMetadata{Supported: true, Levels: []string{"none", "high"}, Values: map[string]json.RawMessage{"none": json.RawMessage("false"), "high": json.RawMessage("true")}}
|
||||
if got := normalizeThinkingEffort("medium", legacy); got != "high" {
|
||||
t.Fatalf("saved Boolean effort=%q, want high", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOllamaThinkingRejectsMalformedReasoning(t *testing.T) {
|
||||
model := routingModel{Thinking: &routingThinkingMetadata{
|
||||
Supported: true,
|
||||
|
||||
@@ -743,7 +743,7 @@ func (w *WebSearchAnthropicWriter) sendError(errorCode, query string, usage anth
|
||||
}
|
||||
|
||||
// AnthropicMessagesMiddleware handles Anthropic Messages API requests
|
||||
func AnthropicMessagesMiddleware() gin.HandlerFunc {
|
||||
func AnthropicMessagesMiddleware(thinkingLookup ...ThinkingLookup) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
requestCtx := c.Request.Context()
|
||||
|
||||
@@ -769,7 +769,8 @@ func AnthropicMessagesMiddleware() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
chatReq, err := anthropic.FromMessagesRequest(req)
|
||||
thinking := modelThinking(thinkingLookup, req.Model)
|
||||
chatReq, err := anthropic.FromMessagesRequest(req, thinking)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, anthropic.NewError(http.StatusBadRequest, err.Error()))
|
||||
return
|
||||
|
||||
@@ -444,7 +444,7 @@ func EmbeddingsMiddleware() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func ChatMiddleware() gin.HandlerFunc {
|
||||
func ChatMiddleware(thinkingLookup ...ThinkingLookup) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req openai.ChatCompletionRequest
|
||||
err := c.ShouldBindJSON(&req)
|
||||
@@ -460,7 +460,8 @@ func ChatMiddleware() gin.HandlerFunc {
|
||||
|
||||
var b bytes.Buffer
|
||||
|
||||
chatReq, err := openai.FromChatRequest(req)
|
||||
thinking := modelThinking(thinkingLookup, req.Model)
|
||||
chatReq, err := openai.FromChatRequest(req, thinking)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, openai.NewError(http.StatusBadRequest, err.Error()))
|
||||
return
|
||||
@@ -1167,7 +1168,7 @@ func decodeWebSearchResponseError(status int, data []byte) error {
|
||||
return api.StatusError{StatusCode: status, ErrorMessage: response.Error}
|
||||
}
|
||||
|
||||
func ResponsesMiddleware() gin.HandlerFunc {
|
||||
func ResponsesMiddleware(thinkingLookup ...ThinkingLookup) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
requestCtx := c.Request.Context()
|
||||
if c.GetHeader("Content-Encoding") == "zstd" {
|
||||
@@ -1187,7 +1188,8 @@ func ResponsesMiddleware() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
chatReq, err := openai.FromResponsesRequest(req)
|
||||
thinking := modelThinking(thinkingLookup, req.Model)
|
||||
chatReq, err := openai.FromResponsesRequest(req, thinking)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, openai.NewError(http.StatusBadRequest, err.Error()))
|
||||
return
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
// ThinkingLookup identifies models whose generic renderer resolves named efforts.
|
||||
// A nil result keeps the existing compatibility conversion for that model.
|
||||
type ThinkingLookup func(string) *model.Thinking
|
||||
|
||||
func modelThinking(lookups []ThinkingLookup, name string) *model.Thinking {
|
||||
if len(lookups) == 0 || lookups[0] == nil {
|
||||
return nil
|
||||
}
|
||||
return lookups[0](name)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
func TestThinkingCompatibilityScope(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
lookup := func(name string) *model.Thinking {
|
||||
if name == "generic" {
|
||||
return &model.Thinking{Values: []any{false, "medium", "xhigh"}, Default: "medium"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
middleware func(...ThinkingLookup) gin.HandlerFunc
|
||||
extra string
|
||||
wantGeneric, wantLegacy any
|
||||
legacyError bool
|
||||
}{
|
||||
{"chat xhigh", ChatMiddleware, `"messages":[{"role":"user","content":"hi"}],"reasoning_effort":"xhigh"`, "xhigh", "max", false},
|
||||
{"chat minimal", ChatMiddleware, `"messages":[{"role":"user","content":"hi"}],"reasoning_effort":"minimal"`, "minimal", "low", false},
|
||||
{"chat future", ChatMiddleware, `"messages":[{"role":"user","content":"hi"}],"reasoning_effort":"future"`, "future", nil, true},
|
||||
{"chat off", ChatMiddleware, `"messages":[{"role":"user","content":"hi"}],"reasoning_effort":"none"`, false, false, false},
|
||||
{"chat nested precedence", ChatMiddleware, `"messages":[{"role":"user","content":"hi"}],"reasoning_effort":"low","reasoning":{"effort":"xhigh"}`, "xhigh", "max", false},
|
||||
{"responses xhigh", ResponsesMiddleware, `"input":"hi","reasoning":{"effort":"xhigh"}`, "xhigh", "max", false},
|
||||
{"responses future", ResponsesMiddleware, `"input":"hi","reasoning":{"effort":"future"}`, "future", nil, true},
|
||||
{"responses off", ResponsesMiddleware, `"input":"hi","reasoning":{"effort":"none"}`, false, false, false},
|
||||
{"responses native precedence", ResponsesMiddleware, `"input":"hi","think":false,"reasoning":{"effort":"xhigh"}`, false, false, false},
|
||||
{"anthropic adaptive", AnthropicMessagesMiddleware, `"messages":[{"role":"user","content":"hi"}],"max_tokens":64,"thinking":{"type":"adaptive"},"output_config":{"effort":"xhigh"}`, "xhigh", "high", false},
|
||||
{"anthropic unknown", AnthropicMessagesMiddleware, `"messages":[{"role":"user","content":"hi"}],"max_tokens":64,"output_config":{"effort":"future"}`, "future", nil, false},
|
||||
{"anthropic off precedence", AnthropicMessagesMiddleware, `"messages":[{"role":"user","content":"hi"}],"max_tokens":64,"thinking":{"type":"disabled"},"output_config":{"effort":"xhigh"}`, false, false, false},
|
||||
} {
|
||||
for _, name := range []string{"generic", "legacy"} {
|
||||
t.Run(tt.name+"/"+name, func(t *testing.T) {
|
||||
var captured api.ChatRequest
|
||||
router := gin.New()
|
||||
router.POST("/", tt.middleware(lookup), func(c *gin.Context) {
|
||||
if err := json.NewDecoder(c.Request.Body).Decode(&captured); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"model":"`+name+`",`+tt.extra+`}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if name == "legacy" && tt.legacyError {
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
return
|
||||
}
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
want := tt.wantGeneric
|
||||
if name == "legacy" {
|
||||
want = tt.wantLegacy
|
||||
}
|
||||
var got any
|
||||
if captured.Think != nil {
|
||||
got = captured.Think.Value
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("think=%#v, want %#v", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
type CogitoRenderer struct {
|
||||
@@ -131,3 +132,11 @@ func (r *CogitoRenderer) Render(messages []api.Message, tools []api.Tool, thinkV
|
||||
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func (r *CogitoRenderer) Thinking() *model.Thinking {
|
||||
values := []any{false}
|
||||
if r.isThinking {
|
||||
values = append(values, true)
|
||||
}
|
||||
return &model.Thinking{Values: values, Default: false}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
// CohereRenderer renders the Cohere North / Command A 2026 chat template
|
||||
@@ -231,3 +232,7 @@ func (r *CohereRenderer) Render(messages []api.Message, tools []api.Tool, think
|
||||
func toolCallID(tc api.ToolCall) string {
|
||||
return tc.ID
|
||||
}
|
||||
|
||||
func (r *CohereRenderer) Thinking() *model.Thinking {
|
||||
return &model.Thinking{Values: []any{false, true}, Default: true}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
type DeepSeek3Variant int
|
||||
@@ -159,3 +160,11 @@ func (r *DeepSeek3Renderer) Render(messages []api.Message, tools []api.Tool, thi
|
||||
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func (r *DeepSeek3Renderer) Thinking() *model.Thinking {
|
||||
values := []any{false}
|
||||
if r.IsThinking {
|
||||
values = append(values, true)
|
||||
}
|
||||
return &model.Thinking{Values: values, Default: false}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
type FunctionGemmaRenderer struct{}
|
||||
@@ -289,3 +290,7 @@ func (r *FunctionGemmaRenderer) formatArrayValue(arr []any) string {
|
||||
sb.WriteString("]")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (r *FunctionGemmaRenderer) Thinking() *model.Thinking {
|
||||
return &model.Thinking{Values: []any{false}, Default: false}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
// Gemma4Renderer renders prompts using Gemma 4's chat format with
|
||||
@@ -835,3 +836,7 @@ func (r *Gemma4Renderer) formatArrayValue(arr []any) string {
|
||||
sb.WriteString("]")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (r *Gemma4Renderer) Thinking() *model.Thinking {
|
||||
return &model.Thinking{Values: []any{false, true}, Default: false}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -366,3 +367,7 @@ func (r *GlimmerRenderer) Render(messages []api.Message, tools []api.Tool, think
|
||||
sb.WriteString("assistant")
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func (r *GlimmerRenderer) Thinking() *model.Thinking {
|
||||
return &model.Thinking{Values: []any{false, "low", "medium", "high", "max"}, Default: "high"}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
// GLM47Renderer renders messages for GLM-4.7 models.
|
||||
@@ -172,3 +173,7 @@ func formatGLM47ToolJSON(raw []byte) string {
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (r *GLM47Renderer) Thinking() *model.Thinking {
|
||||
return &model.Thinking{Values: []any{false, true}, Default: true}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
type GlmOcrRenderer struct {
|
||||
@@ -126,3 +127,7 @@ func renderGlmOcrToolArguments(args api.ToolCallFunctionArguments) string {
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (r *GlmOcrRenderer) Thinking() *model.Thinking {
|
||||
return &model.Thinking{Values: []any{false}, Default: false}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"unicode"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -280,3 +281,11 @@ func formatLagunaToolCallArgument(value any) string {
|
||||
|
||||
return formatToolCallArgument(value)
|
||||
}
|
||||
|
||||
func (r *LagunaRenderer) Thinking() *model.Thinking {
|
||||
return &model.Thinking{Values: []any{false, true}, Default: false}
|
||||
}
|
||||
|
||||
func (r *LagunaV8Renderer) Thinking() *model.Thinking {
|
||||
return &model.Thinking{Values: []any{false, true}, Default: false}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
type LFM2Renderer struct {
|
||||
@@ -334,3 +335,7 @@ func (r *LFM2Renderer) Render(messages []api.Message, tools []api.Tool, thinkVal
|
||||
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func (r *LFM2Renderer) Thinking() *model.Thinking {
|
||||
return &model.Thinking{Values: []any{r.IsThinking}, Default: r.IsThinking}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
type Nemotron3NanoRenderer struct {
|
||||
@@ -487,3 +488,11 @@ func (r *Nemotron3NanoRenderer) pythonJSON(v any) string {
|
||||
return r.pythonJSON(generic)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Nemotron3NanoRenderer) Thinking() *model.Thinking {
|
||||
values := []any{false, true}
|
||||
if r.v35 {
|
||||
values = append(values, "medium")
|
||||
}
|
||||
return &model.Thinking{Values: values, Default: true}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -157,3 +158,7 @@ func (r *Olmo3Renderer) Render(messages []api.Message, tools []api.Tool, _ *api.
|
||||
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func (r *Olmo3Renderer) Thinking() *model.Thinking {
|
||||
return &model.Thinking{Values: []any{false}, Default: false}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
type Olmo3ThinkVariant int
|
||||
@@ -86,3 +87,7 @@ func (r *Olmo3ThinkRenderer) Render(messages []api.Message, _ []api.Tool, _ *api
|
||||
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func (r *Olmo3ThinkRenderer) Thinking() *model.Thinking {
|
||||
return &model.Thinking{Values: []any{true}, Default: true}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -126,10 +127,10 @@ func qwen38ReasoningInstructions(think *api.ThinkValue) (string, error) {
|
||||
return qwen38LowReasoningInstructions, nil
|
||||
case "medium":
|
||||
return "", nil
|
||||
case "high", "max":
|
||||
case "xhigh":
|
||||
return qwen38XHighReasoningInstructions, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported Qwen3.8 reasoning effort %q", think.String())
|
||||
return qwen38XHighReasoningInstructions, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,3 +359,10 @@ func (r *Qwen35Renderer) Render(messages []api.Message, tools []api.Tool, think
|
||||
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func (r *Qwen35Renderer) Thinking() *model.Thinking {
|
||||
if r.variant == qwen35Renderer38 {
|
||||
return &model.Thinking{Values: []any{false, "low", "medium", "xhigh"}, Default: "xhigh"}
|
||||
}
|
||||
return &model.Thinking{Values: []any{false, true}, Default: r.isThinking}
|
||||
}
|
||||
|
||||
@@ -369,7 +369,7 @@ Montréal
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwen38RendererReasoningEffortMapping(t *testing.T) {
|
||||
func TestQwen38RendererReasoningEffort(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
think *api.ThinkValue
|
||||
@@ -380,8 +380,10 @@ func TestQwen38RendererReasoningEffortMapping(t *testing.T) {
|
||||
{name: "false", think: &api.ThinkValue{Value: false}, want: ""},
|
||||
{name: "low", think: &api.ThinkValue{Value: "low"}, want: qwen38RefLow},
|
||||
{name: "medium", think: &api.ThinkValue{Value: "medium"}, want: ""},
|
||||
{name: "high", think: &api.ThinkValue{Value: "high"}, want: qwen38RefXHigh},
|
||||
{name: "max", think: &api.ThinkValue{Value: "max"}, want: qwen38RefXHigh},
|
||||
{name: "xhigh", think: &api.ThinkValue{Value: "xhigh"}, want: qwen38RefXHigh},
|
||||
{name: "future uses renderer default", think: &api.ThinkValue{Value: "future"}, want: qwen38RefXHigh},
|
||||
{name: "high uses renderer default", think: &api.ThinkValue{Value: "high"}, want: qwen38RefXHigh},
|
||||
{name: "max uses renderer default", think: &api.ThinkValue{Value: "max"}, want: qwen38RefXHigh},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -240,3 +241,7 @@ func formatToolDefinitionType(tp api.PropertyType) string {
|
||||
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
func (r *Qwen3CoderRenderer) Thinking() *model.Thinking {
|
||||
return &model.Thinking{Values: []any{false}, Default: false}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
type Qwen3VLRenderer struct {
|
||||
@@ -147,3 +148,11 @@ func (r *Qwen3VLRenderer) Render(messages []api.Message, tools []api.Tool, think
|
||||
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func (r *Qwen3VLRenderer) Thinking() *model.Thinking {
|
||||
values := []any{false}
|
||||
if r.isThinking {
|
||||
values = append(values, true)
|
||||
}
|
||||
return &model.Thinking{Values: values, Default: r.isThinking}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
type Renderer interface {
|
||||
Thinking() *model.Thinking
|
||||
Render(messages []api.Message, tools []api.Tool, think *api.ThinkValue) (string, error)
|
||||
LeadingBOS() string
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
type mockRenderer struct{}
|
||||
@@ -17,6 +18,7 @@ func (m *mockRenderer) LeadingBOS() string {
|
||||
}
|
||||
|
||||
func TestRegisterCustomRenderer(t *testing.T) {
|
||||
t.Cleanup(func() { delete(registry.renderers, "custom-renderer") })
|
||||
// Register a custom renderer
|
||||
Register("custom-renderer", func() Renderer {
|
||||
return &mockRenderer{}
|
||||
@@ -89,6 +91,7 @@ func TestLeadingBOSForRenderer(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestOverrideBuiltInRenderer(t *testing.T) {
|
||||
t.Cleanup(func() { delete(registry.renderers, "qwen3-coder") })
|
||||
// Override the built-in renderer
|
||||
Register("qwen3-coder", func() Renderer {
|
||||
return &mockRenderer{}
|
||||
@@ -110,3 +113,5 @@ func TestUnknownRendererReturnsError(t *testing.T) {
|
||||
t.Error("expected error for unknown renderer")
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockRenderer) Thinking() *model.Thinking { return nil }
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package renderers
|
||||
|
||||
import (
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
// ThinkingForRenderer returns the controls of the selected renderer variant.
|
||||
// Unknown or invalid descriptors are omitted.
|
||||
func ThinkingForRenderer(name string) *model.Thinking {
|
||||
if name == "harmony" {
|
||||
return &model.Thinking{
|
||||
Values: []any{"low", "medium", "high"},
|
||||
Default: "medium",
|
||||
}
|
||||
}
|
||||
r := rendererForName(name)
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
thinking := r.Thinking()
|
||||
if !thinking.Valid() {
|
||||
return nil
|
||||
}
|
||||
return thinking.Clone()
|
||||
}
|
||||
|
||||
// ResolveThinking preserves explicit booleans and supported names. Omission or
|
||||
// an unsupported name uses the default. Unknown metadata preserves legacy handling.
|
||||
func ResolveThinking(requestedThink *api.ThinkValue, thinking *model.Thinking) *api.ThinkValue {
|
||||
if !thinking.Valid() {
|
||||
return requestedThink
|
||||
}
|
||||
if requestedThink != nil {
|
||||
switch value := requestedThink.Value.(type) {
|
||||
case bool:
|
||||
return requestedThink
|
||||
case string:
|
||||
if thinking.Supports(value) {
|
||||
return requestedThink
|
||||
}
|
||||
}
|
||||
}
|
||||
return &api.ThinkValue{Value: thinking.Default}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package renderers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
func TestResolveThinking(t *testing.T) {
|
||||
mixed := &model.Thinking{Values: []any{false, "high", "max"}, Default: "high"}
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
descriptor *model.Thinking
|
||||
requested, want any
|
||||
}{
|
||||
{"omission", mixed, nil, "high"},
|
||||
{"exact", mixed, "max", "max"},
|
||||
{"unsupported low", mixed, "low", "high"},
|
||||
{"unsupported xhigh", mixed, "xhigh", "high"},
|
||||
{"future level", mixed, "future", "high"},
|
||||
{"empty level", mixed, "", "high"},
|
||||
{"case sensitive", mixed, "MAX", "high"},
|
||||
{"whitespace", mixed, " max ", "high"},
|
||||
{"off", mixed, false, false},
|
||||
{"boolean on retains behavior", mixed, true, true},
|
||||
{"unknown retains omission", nil, nil, nil},
|
||||
{"unknown retains name", nil, "xhigh", "xhigh"},
|
||||
{"invalid metadata retains request", &model.Thinking{Values: []any{false}, Default: 75}, "xhigh", "xhigh"},
|
||||
{"toggle unsupported uses on", &model.Thinking{Values: []any{false, true}, Default: true}, "low", true},
|
||||
{"off default is not lost", &model.Thinking{Values: []any{false, true}, Default: false}, "low", false},
|
||||
{"fixed thinking preserves explicit off", &model.Thinking{Values: []any{true}, Default: true}, false, false},
|
||||
{"known nonthinking default", &model.Thinking{Values: []any{false}, Default: false}, nil, false},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var requestedThink *api.ThinkValue
|
||||
if tt.requested != nil {
|
||||
requestedThink = &api.ThinkValue{Value: tt.requested}
|
||||
}
|
||||
think := ResolveThinking(requestedThink, tt.descriptor)
|
||||
var got any
|
||||
if think != nil {
|
||||
got = think.Value
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("got %#v, want %#v", got, tt.want)
|
||||
}
|
||||
if requestedThink != nil && requestedThink.Value != tt.requested {
|
||||
t.Fatal("mutated request")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestThinkingRendererVariants(t *testing.T) {
|
||||
for _, name := range []string{"harmony", "qwen3.5", "qwen3.8", "ornith", "qwen3-coder", "qwen3-vl-instruct", "qwen3-vl-thinking", "cogito", "deepseek3.1", "olmo3", "olmo3.1", "olmo3-think", "olmo3-32b-think", "nemotron-3-nano", "nemotron-3.5-nano", "gemma4", "gemma4-small", "gemma4-large", "functiongemma", "glm-4.7", "glm-ocr", "lfm2", "lfm2-thinking", "laguna", "poolside-v1", "cohere", "glimmer"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if !ThinkingForRenderer(name).Valid() {
|
||||
t.Fatal("invalid or absent descriptor")
|
||||
}
|
||||
})
|
||||
}
|
||||
if ThinkingForRenderer("unknown") != nil {
|
||||
t.Fatal("unknown renderer must not invent metadata")
|
||||
}
|
||||
}
|
||||
+32
-20
@@ -533,33 +533,45 @@ func ToModel(r api.ShowResponse, m string) Model {
|
||||
}
|
||||
}
|
||||
|
||||
// thinkFromReasoningEffort converts an OpenAI reasoning effort to the equivalent
|
||||
// Ollama think value. An empty effort leaves thinking at the model's default.
|
||||
//
|
||||
// OpenAI's scale extends past both ends of Ollama's ("minimal" below "low",
|
||||
// "xhigh" above "high"), and clients built on it add tiers of their own
|
||||
// ("ultra"). Clamp those to the nearest Ollama tier rather than rejecting the
|
||||
// request, since the alternative is a 400 for an effort the client considers
|
||||
// perfectly valid.
|
||||
func thinkFromReasoningEffort(effort string) (*api.ThinkValue, error) {
|
||||
// ThinkingFromReasoningEffort preserves model-defined names when metadata is present.
|
||||
// Boolean-only models retain the OpenAI on/off controls; models without metadata
|
||||
// retain the legacy effort aliases.
|
||||
func ThinkingFromReasoningEffort(effort string, thinking ...*model.Thinking) (*api.ThinkValue, error) {
|
||||
switch effort {
|
||||
case "":
|
||||
return nil, nil
|
||||
case "none":
|
||||
return &api.ThinkValue{Value: false}, nil
|
||||
case "minimal":
|
||||
return &api.ThinkValue{Value: "low"}, nil
|
||||
case "low", "medium", "high", "max":
|
||||
return &api.ThinkValue{Value: effort}, nil
|
||||
case "xhigh", "ultra":
|
||||
return &api.ThinkValue{Value: "max"}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid reasoning value: %q (must be \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\", \"ultra\", \"max\", or \"none\")", effort)
|
||||
}
|
||||
requestedEffort := effort
|
||||
switch effort {
|
||||
case "minimal":
|
||||
effort = "low"
|
||||
case "xhigh", "ultra":
|
||||
effort = "max"
|
||||
}
|
||||
think := &api.ThinkValue{Value: effort}
|
||||
err := api.ValidateLegacyThinking(think)
|
||||
if len(thinking) > 0 && thinking[0].Valid() {
|
||||
if err == nil && thinking[0].Supports(true) {
|
||||
for _, value := range thinking[0].Values {
|
||||
if _, named := value.(string); named {
|
||||
return &api.ThinkValue{Value: requestedEffort}, nil
|
||||
}
|
||||
}
|
||||
return &api.ThinkValue{Value: true}, nil
|
||||
}
|
||||
return &api.ThinkValue{Value: requestedEffort}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid reasoning value: %q (must be \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\", \"ultra\", \"max\", or \"none\")", requestedEffort)
|
||||
}
|
||||
return think, nil
|
||||
}
|
||||
|
||||
// FromChatRequest converts a ChatCompletionRequest to api.ChatRequest
|
||||
func FromChatRequest(r ChatCompletionRequest) (*api.ChatRequest, error) {
|
||||
// FromChatRequest converts a ChatCompletionRequest to api.ChatRequest.
|
||||
// An optional thinking descriptor preserves model-defined effort names for rendering.
|
||||
func FromChatRequest(r ChatCompletionRequest, thinking ...*model.Thinking) (*api.ChatRequest, error) {
|
||||
var messages []api.Message
|
||||
for _, msg := range r.Messages {
|
||||
toolName := ""
|
||||
@@ -715,7 +727,7 @@ func FromChatRequest(r ChatCompletionRequest) (*api.ChatRequest, error) {
|
||||
effort = *r.ReasoningEffort
|
||||
}
|
||||
|
||||
think, err := thinkFromReasoningEffort(effort)
|
||||
think, err := ThinkingFromReasoningEffort(effort, thinking...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
+10
-3
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
// ResponsesContent is a discriminated union for input content types.
|
||||
@@ -551,8 +552,9 @@ type ResponsesRequest struct {
|
||||
Stream *bool `json:"stream,omitempty"`
|
||||
}
|
||||
|
||||
// FromResponsesRequest converts a ResponsesRequest to api.ChatRequest
|
||||
func FromResponsesRequest(r ResponsesRequest) (*api.ChatRequest, error) {
|
||||
// FromResponsesRequest converts a ResponsesRequest to api.ChatRequest.
|
||||
// An optional thinking descriptor preserves model-defined effort names for rendering.
|
||||
func FromResponsesRequest(r ResponsesRequest, thinking ...*model.Thinking) (*api.ChatRequest, error) {
|
||||
var messages []api.Message
|
||||
availableTools := responsesRequestTools(r)
|
||||
|
||||
@@ -749,8 +751,13 @@ func FromResponsesRequest(r ResponsesRequest) (*api.ChatRequest, error) {
|
||||
if !think.IsValid() {
|
||||
return nil, fmt.Errorf("invalid think value")
|
||||
}
|
||||
if len(thinking) == 0 || !thinking[0].Valid() {
|
||||
if err := api.ValidateLegacyThinking(think); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
converted, err := thinkFromReasoningEffort(r.Reasoning.Effort)
|
||||
converted, err := ThinkingFromReasoningEffort(r.Reasoning.Effort, thinking...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
func TestThinkingConversionMetadata(t *testing.T) {
|
||||
for _, metadata := range []struct {
|
||||
name string
|
||||
thinking *model.Thinking
|
||||
generic bool
|
||||
}{
|
||||
{"named", &model.Thinking{Values: []any{false, "high", "max"}, Default: "high"}, true},
|
||||
{"nonthinking", &model.Thinking{Values: []any{false}, Default: false}, true},
|
||||
{"nil", nil, false},
|
||||
{"invalid", &model.Thinking{Values: []any{"high"}, Default: "missing"}, false},
|
||||
} {
|
||||
for _, protocol := range []string{"chat", "responses"} {
|
||||
for _, tt := range []struct {
|
||||
name, fields string
|
||||
wantGeneric, wantLegacy any
|
||||
legacyError bool
|
||||
}{
|
||||
{"omitted", ``, nil, nil, false},
|
||||
{"empty", `,"reasoning":{"effort":""}`, nil, nil, false},
|
||||
{"none", `,"reasoning":{"effort":"none"}`, false, false, false},
|
||||
{"supported", `,"reasoning":{"effort":"max"}`, "max", "max", false},
|
||||
{"unsupported", `,"reasoning":{"effort":"low"}`, "low", "low", false},
|
||||
{"xhigh", `,"reasoning":{"effort":"xhigh"}`, "xhigh", "max", false},
|
||||
{"minimal", `,"reasoning":{"effort":"minimal"}`, "minimal", "low", false},
|
||||
{"future", `,"reasoning":{"effort":"future"}`, "future", nil, true},
|
||||
{"exact spelling", `,"reasoning":{"effort":" HIGH "}`, " HIGH ", nil, true},
|
||||
{"nested precedence", `,"reasoning_effort":"low","reasoning":{"effort":"xhigh"}`, "xhigh", "max", false},
|
||||
{"empty nested precedence", `,"reasoning_effort":"low","reasoning":{"effort":""}`, nil, nil, false},
|
||||
} {
|
||||
t.Run(metadata.name+"/"+protocol+"/"+tt.name, func(t *testing.T) {
|
||||
body := []byte(`{"model":"test","messages":[{"role":"user","content":"hi"}],"input":"hi"` + tt.fields + `}`)
|
||||
var request any
|
||||
if protocol == "chat" {
|
||||
request = &ChatCompletionRequest{}
|
||||
} else {
|
||||
request = &ResponsesRequest{}
|
||||
}
|
||||
if err := json.Unmarshal(body, request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var result *api.ChatRequest
|
||||
switch req := request.(type) {
|
||||
case *ChatCompletionRequest:
|
||||
result, err = FromChatRequest(*req, metadata.thinking)
|
||||
case *ResponsesRequest:
|
||||
result, err = FromResponsesRequest(*req, metadata.thinking)
|
||||
}
|
||||
after, marshalErr := json.Marshal(request)
|
||||
if marshalErr != nil || string(before) != string(after) {
|
||||
t.Fatalf("conversion mutated input: before=%s after=%s error=%v", before, after, marshalErr)
|
||||
}
|
||||
if !metadata.generic && tt.legacyError {
|
||||
if err == nil {
|
||||
t.Fatal("expected legacy effort error")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := tt.wantLegacy
|
||||
if metadata.generic {
|
||||
want = tt.wantGeneric
|
||||
}
|
||||
var got any
|
||||
if result.Think != nil {
|
||||
got = result.Think.Value
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("thinking=%#v, want %#v", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesThinkingOverrideWithMetadata(t *testing.T) {
|
||||
for _, metadata := range []struct {
|
||||
name string
|
||||
thinking *model.Thinking
|
||||
}{
|
||||
{"named", &model.Thinking{Values: []any{false, "high", "max"}, Default: "high"}},
|
||||
{"nil", nil},
|
||||
{"invalid", &model.Thinking{Values: []any{"high"}, Default: "missing"}},
|
||||
} {
|
||||
for _, value := range []any{false, true, "low", "high", "max", "", "xhigh", "minimal", "future", 75} {
|
||||
t.Run(metadata.name+"/"+fmt.Sprint(value), func(t *testing.T) {
|
||||
req := ResponsesRequest{Input: ResponsesInput{Text: "hi"}, Think: &api.ThinkValue{Value: value}}
|
||||
req.Reasoning.Effort = "xhigh"
|
||||
got, err := FromResponsesRequest(req, metadata.thinking)
|
||||
wantErr := value == 75 || (!metadata.thinking.Valid() && (value == "" || value == "xhigh" || value == "minimal" || value == "future"))
|
||||
if wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("invalid override must fail")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Think == nil || got.Think.Value != value {
|
||||
t.Fatalf("thinking=%v, want explicit override %#v", got.Think, value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestThinkingBooleanOpenAIControls(t *testing.T) {
|
||||
for _, metadata := range []struct {
|
||||
name string
|
||||
thinking *model.Thinking
|
||||
boolean bool
|
||||
}{
|
||||
{"default off", &model.Thinking{Values: []any{false, true}, Default: false}, true},
|
||||
{"default on", &model.Thinking{Values: []any{false, true}, Default: true}, true},
|
||||
{"always on", &model.Thinking{Values: []any{true}, Default: true}, true},
|
||||
{"off only", &model.Thinking{Values: []any{false}, Default: false}, false},
|
||||
{"mixed", &model.Thinking{Values: []any{false, true, "medium"}, Default: true}, false},
|
||||
} {
|
||||
for _, protocol := range []string{"chat", "responses"} {
|
||||
for _, effort := range []string{"", "none", "low", "medium", "high", "max", "minimal", "xhigh", "ultra", "future", " HIGH "} {
|
||||
t.Run(metadata.name+"/"+protocol+"/"+effort, func(t *testing.T) {
|
||||
var got *api.ChatRequest
|
||||
var err error
|
||||
if protocol == "chat" {
|
||||
got, err = FromChatRequest(ChatCompletionRequest{Model: "test", ReasoningEffort: &effort}, metadata.thinking)
|
||||
} else {
|
||||
req := ResponsesRequest{Model: "test", Input: ResponsesInput{Text: "hi"}}
|
||||
req.Reasoning.Effort = effort
|
||||
got, err = FromResponsesRequest(req, metadata.thinking)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var want any = effort
|
||||
switch effort {
|
||||
case "":
|
||||
want = nil
|
||||
case "none":
|
||||
want = false
|
||||
case "future", " HIGH ":
|
||||
default:
|
||||
if metadata.boolean {
|
||||
want = true
|
||||
}
|
||||
}
|
||||
var actual any
|
||||
if got.Think != nil {
|
||||
actual = got.Think.Value
|
||||
}
|
||||
if actual != want {
|
||||
t.Fatalf("thinking=%#v, want %#v", actual, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -81,7 +81,8 @@ type Model struct {
|
||||
GenerationDefaults model.GenerationDefaults
|
||||
Messages []api.Message
|
||||
|
||||
Template *template.Template
|
||||
Template *template.Template
|
||||
templateDigest string
|
||||
|
||||
// Metadata of the model blob and of each projector, read from their
|
||||
// metadata files when the model is loaded.
|
||||
@@ -741,6 +742,7 @@ func GetModel(name string) (*Model, error) {
|
||||
case "application/vnd.ollama.image.prompt",
|
||||
"application/vnd.ollama.image.template":
|
||||
m.HasGoTemplate = true
|
||||
m.templateDigest = layer.Digest
|
||||
bts, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -598,6 +598,7 @@ func cloneShowResponse(in *api.ShowResponse) *api.ShowResponse {
|
||||
}
|
||||
|
||||
out := *in
|
||||
out.Thinking = in.Thinking.Clone()
|
||||
out.Details.Families = slices.Clone(in.Details.Families)
|
||||
out.Messages = cloneMessages(in.Messages)
|
||||
out.Capabilities = slices.Clone(in.Capabilities)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/ollama/ollama/internal/cloud"
|
||||
"github.com/ollama/ollama/model/renderers"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
func (s *Server) thinkingInputError(ctx context.Context, name string, inputErr error) error {
|
||||
ref, err := parseAndValidateModelRef(name)
|
||||
if err != nil {
|
||||
return inputErr
|
||||
}
|
||||
var thinking *model.Thinking
|
||||
if ref.Source == modelSourceCloud {
|
||||
if disabled, _ := cloud.Status(); disabled {
|
||||
return inputErr
|
||||
}
|
||||
cache := newModelShowCache()
|
||||
if s.modelCaches != nil && s.modelCaches.show != nil {
|
||||
cache = s.modelCaches.show
|
||||
}
|
||||
key := modelShowCloudKeyForModel(ref.Base, false)
|
||||
info, ok := cache.getCloud(key)
|
||||
if !ok {
|
||||
info, err = cache.fetchCloudShow(ctx, ref.Base, false)
|
||||
if err != nil {
|
||||
return inputErr
|
||||
}
|
||||
cache.setCloud(key, info)
|
||||
}
|
||||
thinking = info.Thinking
|
||||
} else if name, err := getExistingName(ref.Name); err == nil {
|
||||
if m, err := GetModel(name.String()); err == nil {
|
||||
thinking = m.Thinking()
|
||||
}
|
||||
}
|
||||
if !thinking.Valid() {
|
||||
return inputErr
|
||||
}
|
||||
values, _ := json.Marshal(thinking.Values)
|
||||
return fmt.Errorf("%w; supported values: %s", inputErr, values)
|
||||
}
|
||||
|
||||
// Exact template identities keep legacy metadata from leaking to custom templates.
|
||||
// These entries describe the existing local endpoint behavior, without migrations.
|
||||
var legacyThinking = map[string]model.Thinking{
|
||||
// Qwen3 template with explicit /think and /no_think controls.
|
||||
"sha256:ae370d884f108d16e7cc8fd5259ebc5773a0afa6e078b11f4ed7e39a27e0dfc4": {Values: []any{false, true}, Default: true},
|
||||
// Older Qwen3 template always opens a thinking block.
|
||||
"sha256:2d54db2b9bb29ce7db54fea63a891f5859603813c555b1f88b5e0994652897f9": {Values: []any{true}, Default: true},
|
||||
// Llama3.2 tool-use template.
|
||||
"sha256:966de95ca8a62200913e3f8bfbf84c8494536f1b94b49166851e76644e966396": {Values: []any{false}, Default: false},
|
||||
}
|
||||
|
||||
// Thinking returns the effective local serving contract. Remote models obtain
|
||||
// their current contract from the remote show response.
|
||||
func (m *Model) Thinking() *model.Thinking {
|
||||
if m == nil || m.Config.RemoteHost != "" {
|
||||
return nil
|
||||
}
|
||||
if shouldUseHarmony(m) {
|
||||
return renderers.ThinkingForRenderer("harmony")
|
||||
}
|
||||
if name := resolveRendererName(m); name != "" {
|
||||
thinking := renderers.ThinkingForRenderer(name)
|
||||
if thinking == nil {
|
||||
return nil
|
||||
}
|
||||
// Discovery must match the controls accepted by the serving boundary.
|
||||
if !slices.Contains(m.Capabilities(), model.CapabilityThinking) {
|
||||
return &model.Thinking{Values: []any{false}, Default: false}
|
||||
}
|
||||
// Preserve the local endpoint's historical default-on behavior.
|
||||
if thinking.Default == false && thinking.Supports(true) {
|
||||
thinking.Default = true
|
||||
}
|
||||
// true currently reaches Qwen3.8 as medium via ThinkValue.String().
|
||||
if name == "qwen3.8" {
|
||||
thinking.Default = "medium"
|
||||
}
|
||||
return thinking
|
||||
}
|
||||
if shouldUseGoTemplate(m) {
|
||||
if thinking, ok := legacyThinking[m.templateDigest]; ok {
|
||||
return thinking.Clone()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// genericThinking excludes Harmony and template-only paths from new fallback rules.
|
||||
func (m *Model) genericThinking() *model.Thinking {
|
||||
if m == nil || m.Config.Renderer == "" || shouldUseHarmony(m) {
|
||||
return nil
|
||||
}
|
||||
return m.Thinking()
|
||||
}
|
||||
|
||||
// lookupThinking lets compatibility middleware preserve named efforts only for
|
||||
// local generic renderers. Other models keep the existing protocol conversions.
|
||||
func lookupThinking(name string) *model.Thinking {
|
||||
ref, err := parseAndValidateModelRef(name)
|
||||
if err != nil || ref.Source == modelSourceCloud {
|
||||
return nil
|
||||
}
|
||||
canonical, err := getExistingName(ref.Name)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
m, err := GetModel(canonical.String())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return m.genericThinking()
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/llm"
|
||||
"github.com/ollama/ollama/middleware"
|
||||
"github.com/ollama/ollama/template"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
func TestThinkingInputErrors(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
setTestHome(t, t.TempDir())
|
||||
t.Setenv("OLLAMA_MODELS", t.TempDir())
|
||||
t.Setenv("OLLAMA_NO_CLOUD", "")
|
||||
s := &Server{modelCaches: &modelCaches{show: newModelShowCache()}}
|
||||
createMinimalGGUFModel(t, s, "thinking-base", nil, "{{ .Prompt }}", nil)
|
||||
createMinimalGGUFModel(t, s, "thinking-harmony", nil, "<|start|>{{ .Prompt }}<|end|>", map[string]any{"model_family": "gptoss", "capabilities": []any{"completion", "thinking"}})
|
||||
w := createRequest(t, s.CreateHandler, api.CreateRequest{Model: "thinking-qwen", From: "thinking-base", Renderer: "qwen3.8", Parser: "qwen3.5", Stream: &stream})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatal(w.Body.String())
|
||||
}
|
||||
|
||||
showCalls := 0
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/show" {
|
||||
t.Errorf("invalid thinking request reached %s", r.URL.Path)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
showCalls++
|
||||
json.NewEncoder(w).Encode(api.ShowResponse{Thinking: &model.Thinking{Values: []any{false, "high", "max"}, Default: "high"}})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
withCloudProxyBaseURL(t, upstream.URL)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name, values string
|
||||
}{
|
||||
{"thinking-qwen", `[false,"low","medium","xhigh"]`},
|
||||
{"thinking-qwen:local", `[false,"low","medium","xhigh"]`},
|
||||
{" THINKING-QWEN ", `[false,"low","medium","xhigh"]`},
|
||||
{"thinking-cloud:cloud", `[false,"high","max"]`},
|
||||
{"thinking-harmony", `["low","medium","high"]`},
|
||||
{"thinking-base", ""},
|
||||
{"missing", ""},
|
||||
} {
|
||||
for _, endpoint := range []struct {
|
||||
name string
|
||||
handler gin.HandlerFunc
|
||||
}{{"chat", s.ChatHandler}, {"generate", s.GenerateHandler}} {
|
||||
for _, value := range []string{"75", "0.5", "{}", "[]"} {
|
||||
for _, body := range []string{
|
||||
fmt.Sprintf(`{"model":%q,"think":%s}`, tc.name, value),
|
||||
fmt.Sprintf(`{"think":%s,"model":%q}`, value, tc.name),
|
||||
} {
|
||||
t.Run(tc.name+"/"+endpoint.name+"/"+body, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/api/"+endpoint.name, strings.NewReader(body))
|
||||
endpoint.handler(c)
|
||||
var response struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "think must be a boolean or string"
|
||||
if tc.values != "" {
|
||||
want += "; supported values: " + tc.values
|
||||
}
|
||||
if w.Code != 400 || response.Error != want {
|
||||
t.Fatalf("status=%d error=%q, want 400 %q", w.Code, response.Error, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if showCalls != 1 {
|
||||
t.Fatalf("cloud show calls = %d, want one cold fetch then cached reads", showCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelThinking(t *testing.T) {
|
||||
t.Setenv("OLLAMA_GO_TEMPLATE", "1")
|
||||
known := "sha256:ae370d884f108d16e7cc8fd5259ebc5773a0afa6e078b11f4ed7e39a27e0dfc4"
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
m Model
|
||||
want *model.Thinking
|
||||
}{
|
||||
{"local gemma default on", Model{Config: model.ConfigV2{Renderer: "gemma4", Parser: "gemma4"}}, &model.Thinking{Values: []any{false, true}, Default: true}},
|
||||
{"local qwen38 default medium", Model{Config: model.ConfigV2{Renderer: "qwen3.8", Parser: "qwen3.5"}}, &model.Thinking{Values: []any{false, "low", "medium", "xhigh"}, Default: "medium"}},
|
||||
{"renderer without thinking capability", Model{Config: model.ConfigV2{Renderer: "gemma4"}}, &model.Thinking{Values: []any{false}, Default: false}},
|
||||
{"unknown renderer", Model{Config: model.ConfigV2{Renderer: "unknown"}}, nil},
|
||||
{"nonthinking", Model{Config: model.ConfigV2{Renderer: "qwen3-coder"}}, &model.Thinking{Values: []any{false}, Default: false}},
|
||||
{"known template", Model{HasGoTemplate: true, templateDigest: known}, &model.Thinking{Values: []any{false, true}, Default: true}},
|
||||
{"custom template", Model{HasGoTemplate: true, templateDigest: "custom"}, nil},
|
||||
{"inactive template", Model{templateDigest: known}, nil},
|
||||
{"remote", Model{Config: model.ConfigV2{RemoteHost: "https://ollama.com", Renderer: "qwen3.8", Parser: "qwen3.5"}}, nil},
|
||||
{"unknown", Model{}, nil},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.m.Thinking()
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("got %#v, want %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("backfill stays off the Jinja path", func(t *testing.T) {
|
||||
t.Setenv("OLLAMA_GO_TEMPLATE", "0")
|
||||
m := &Model{HasGoTemplate: true, templateDigest: known}
|
||||
if m.Thinking() != nil {
|
||||
t.Fatal("inactive Go template must not supply backfill")
|
||||
}
|
||||
})
|
||||
tmpl, err := template.Parse("<|start|>{{ .Prompt }}<|end|>")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, renderer := range []string{"", "harmony"} {
|
||||
harmony := &Model{Template: tmpl, Config: model.ConfigV2{ModelFamily: "gptoss", Renderer: renderer}}
|
||||
want := &model.Thinking{Values: []any{"low", "medium", "high"}, Default: "medium"}
|
||||
if got := harmony.Thinking(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("Harmony discovery = %#v, want %#v", got, want)
|
||||
}
|
||||
if harmony.genericThinking() != nil {
|
||||
t.Fatal("Harmony must retain legacy inference behavior")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestThinkingShowFollowsRendererChanges(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("OLLAMA_MODELS", t.TempDir())
|
||||
var s Server
|
||||
createMinimalGGUFModel(t, &s, "thinking-base", nil, "{{ .Prompt }}", nil)
|
||||
for _, tt := range []struct {
|
||||
name, from, renderer, parser string
|
||||
want *model.Thinking
|
||||
}{
|
||||
{"thinking-generic", "thinking-base", "qwen3.8", "qwen3.5", &model.Thinking{Values: []any{false, "low", "medium", "xhigh"}, Default: "medium"}},
|
||||
{"thinking-inherited", "thinking-generic", "", "", &model.Thinking{Values: []any{false, "low", "medium", "xhigh"}, Default: "medium"}},
|
||||
{"thinking-changed", "thinking-generic", "qwen3-coder", "qwen3-coder", &model.Thinking{Values: []any{false}, Default: false}},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
w := createRequest(t, s.CreateHandler, api.CreateRequest{Model: tt.name, From: tt.from, Renderer: tt.renderer, Parser: tt.parser, Stream: &stream})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("create: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
info, err := GetModelInfo(api.ShowRequest{Model: tt.name})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(info.Thinking, tt.want) {
|
||||
t.Fatalf("show descriptor %#v, want %#v", info.Thinking, tt.want)
|
||||
}
|
||||
clone := cloneShowResponse(info)
|
||||
clone.Thinking.Values[0] = "changed"
|
||||
if info.Thinking.Values[0] == "changed" {
|
||||
t.Fatal("show cache clone shares thinking")
|
||||
}
|
||||
})
|
||||
}
|
||||
info, err := GetModelInfo(api.ShowRequest{Model: "thinking-base"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := json.Marshal(info)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(data), `"thinking":`) {
|
||||
t.Fatal("unknown metadata must be omitted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestThinkingResolvedBeforeRenderAndParse(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("OLLAMA_MODELS", t.TempDir())
|
||||
t.Setenv("OLLAMA_CONTEXT_LENGTH", "4096")
|
||||
mock := mockRunner{CompletionResponse: llm.CompletionResponse{Content: "reason</think>answer", Done: true, DoneReason: llm.DoneReasonStop}}
|
||||
s := newServerWithMockRunner(t, &mock)
|
||||
createMinimalGGUFModel(t, s, "thinking-base", nil, "{{ .Prompt }}", nil)
|
||||
w := createRequest(t, s.CreateHandler, api.CreateRequest{Model: "thinking-qwen", From: "thinking-base", Renderer: "qwen3.8", Parser: "qwen3.5", Stream: &stream})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("create: %s", w.Body.String())
|
||||
}
|
||||
for _, requested := range []any{nil, true, false, "low", "medium", "xhigh", "high", "max", "minimal", "future"} {
|
||||
for _, endpoint := range []string{"chat", "generate"} {
|
||||
label, _ := json.Marshal(requested)
|
||||
t.Run(endpoint+"/"+string(label), func(t *testing.T) {
|
||||
var think *api.ThinkValue
|
||||
if requested != nil {
|
||||
think = &api.ThinkValue{Value: requested}
|
||||
}
|
||||
var content, reasoning string
|
||||
if endpoint == "chat" {
|
||||
w = createRequest(t, s.ChatHandler, api.ChatRequest{Model: "thinking-qwen", Messages: []api.Message{{Role: "user", Content: "hello"}}, Think: think, Stream: &stream})
|
||||
var resp api.ChatResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, reasoning = resp.Message.Content, resp.Message.Thinking
|
||||
} else {
|
||||
w = createRequest(t, s.GenerateHandler, api.GenerateRequest{Model: "thinking-qwen", Prompt: "hello", Think: think, Stream: &stream})
|
||||
var resp api.GenerateResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, reasoning = resp.Response, resp.Thinking
|
||||
}
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
prompt := mock.CompletionRequest.Prompt
|
||||
if strings.Contains(prompt, "Reasoning effort is set to xhigh") != (requested == "xhigh") {
|
||||
t.Fatalf("xhigh mismatch for %#v: %s", requested, prompt)
|
||||
}
|
||||
if strings.Contains(prompt, "Reasoning effort is set to low") != (requested == "low") {
|
||||
t.Fatalf("low mismatch for %#v: %s", requested, prompt)
|
||||
}
|
||||
if requested != false && (content != "answer" || reasoning != "reason") {
|
||||
t.Fatalf("parser mismatch: content=%q thinking=%q", content, reasoning)
|
||||
}
|
||||
if requested == false && reasoning != "" {
|
||||
t.Fatalf("off produced thinking %q", reasoning)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestThinkingNonthinkingFallback(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("OLLAMA_MODELS", t.TempDir())
|
||||
t.Setenv("OLLAMA_CONTEXT_LENGTH", "4096")
|
||||
mock := mockRunner{CompletionResponse: llm.CompletionResponse{Content: "answer", Done: true, DoneReason: llm.DoneReasonStop}}
|
||||
s := newServerWithMockRunner(t, &mock)
|
||||
createMinimalGGUFModel(t, s, "thinking-base", nil, "{{ .Prompt }}", nil)
|
||||
for _, config := range []struct{ name, renderer, parser string }{
|
||||
{"thinking-coder", "qwen3-coder", "qwen3-coder"},
|
||||
{"thinking-no-parser", "gemma4", ""},
|
||||
} {
|
||||
w := createRequest(t, s.CreateHandler, api.CreateRequest{Model: config.name, From: "thinking-base", Renderer: config.renderer, Parser: config.parser, Stream: &stream})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatal(w.Body.String())
|
||||
}
|
||||
show, err := GetModelInfo(api.ShowRequest{Model: config.name})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(show.Thinking, &model.Thinking{Values: []any{false}, Default: false}) {
|
||||
t.Fatalf("%s advertised rejected controls: %+v", config.name, show.Thinking)
|
||||
}
|
||||
for _, value := range []any{nil, false, true, "", "high", "future"} {
|
||||
for _, endpoint := range []string{"chat", "generate"} {
|
||||
t.Run(fmt.Sprintf("%s/%s/%v", config.name, endpoint, value), func(t *testing.T) {
|
||||
var think *api.ThinkValue
|
||||
if value != nil {
|
||||
think = &api.ThinkValue{Value: value}
|
||||
}
|
||||
if endpoint == "chat" {
|
||||
w = createRequest(t, s.ChatHandler, api.ChatRequest{Model: config.name, Messages: []api.Message{{Role: "user", Content: "hello"}}, Think: think, Stream: &stream})
|
||||
} else {
|
||||
w = createRequest(t, s.GenerateHandler, api.GenerateRequest{Model: config.name, Prompt: "hello", Think: think, Stream: &stream})
|
||||
}
|
||||
want := http.StatusOK
|
||||
if value == true {
|
||||
want = http.StatusBadRequest
|
||||
}
|
||||
if w.Code != want {
|
||||
t.Fatalf("status=%d, want %d: %s", w.Code, want, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestThinkingLookupModelReferences(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("OLLAMA_MODELS", t.TempDir())
|
||||
s := &Server{}
|
||||
createMinimalGGUFModel(t, s, "thinking-base", nil, "{{ .Prompt }}", nil)
|
||||
createMinimalGGUFModel(t, s, "thinking-harmony", nil, "<|start|>{{ .Prompt }}<|end|>", map[string]any{"model_family": "gptoss", "capabilities": []any{"completion", "thinking"}})
|
||||
w := createRequest(t, s.CreateHandler, api.CreateRequest{Model: "thinking-qwen", From: "thinking-base", Renderer: "qwen3.8", Parser: "qwen3.5", Stream: &stream})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatal(w.Body.String())
|
||||
}
|
||||
for _, protocol := range []struct {
|
||||
name, fields string
|
||||
middleware gin.HandlerFunc
|
||||
}{
|
||||
{"chat", `"messages":[{"role":"user","content":"hi"}],"reasoning_effort":"xhigh"`, middleware.ChatMiddleware(lookupThinking)},
|
||||
{"responses", `"input":"hi","reasoning":{"effort":"xhigh"}`, middleware.ResponsesMiddleware(lookupThinking)},
|
||||
{"anthropic", `"messages":[{"role":"user","content":"hi"}],"max_tokens":64,"output_config":{"effort":"xhigh"}`, middleware.AnthropicMessagesMiddleware(lookupThinking)},
|
||||
} {
|
||||
for _, name := range []string{"thinking-qwen", "thinking-qwen:local", " THINKING-QWEN ", "thinking-harmony"} {
|
||||
t.Run(protocol.name+"/"+name, func(t *testing.T) {
|
||||
var req api.ChatRequest
|
||||
router := gin.New()
|
||||
router.POST("/", protocol.middleware, func(c *gin.Context) {
|
||||
if err := json.NewDecoder(c.Request.Body).Decode(&req); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
r := httptest.NewRequest("POST", "/", strings.NewReader(fmt.Sprintf(`{"model":%q,%s}`, name, protocol.fields)))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, r)
|
||||
want := "xhigh"
|
||||
if name == "thinking-harmony" {
|
||||
want = "max"
|
||||
if protocol.name == "anthropic" {
|
||||
want = "high"
|
||||
}
|
||||
}
|
||||
if w.Code != http.StatusOK || req.Think == nil || req.Think.Value != want {
|
||||
t.Fatalf("status=%d think=%v: %s", w.Code, req.Think, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"thinking-qwen:cloud", "missing", ""} {
|
||||
if got := lookupThinking(name); got != nil {
|
||||
t.Errorf("%q lookup=%+v, want no local metadata", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestThinkingHarmonyDiscoveryPreservesInference(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("OLLAMA_MODELS", t.TempDir())
|
||||
t.Setenv("OLLAMA_CONTEXT_LENGTH", "4096")
|
||||
mock := mockRunner{CompletionResponse: llm.CompletionResponse{Done: true, DoneReason: llm.DoneReasonStop}}
|
||||
s := newServerWithMockRunner(t, &mock)
|
||||
createMinimalGGUFModel(t, s, "thinking-harmony", nil, "<|start|><|end|>Reasoning: {{ .ThinkLevel }} {{ .Prompt }}", map[string]any{"model_family": "gptoss", "capabilities": []any{"completion", "thinking"}})
|
||||
w := createRequest(t, s.ShowHandler, api.ShowRequest{Model: "thinking-harmony"})
|
||||
var show api.ShowResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &show); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := &model.Thinking{Values: []any{"low", "medium", "high"}, Default: "medium"}
|
||||
if w.Code != http.StatusOK || !reflect.DeepEqual(show.Thinking, want) {
|
||||
t.Fatalf("show status=%d thinking=%#v, want %#v", w.Code, show.Thinking, want)
|
||||
}
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
think *api.ThinkValue
|
||||
want string
|
||||
badRequest bool
|
||||
}{
|
||||
{"omitted", nil, "medium", false},
|
||||
{"low", &api.ThinkValue{Value: "low"}, "low", false},
|
||||
{"medium", &api.ThinkValue{Value: "medium"}, "medium", false},
|
||||
{"high", &api.ThinkValue{Value: "high"}, "high", false},
|
||||
{"max", &api.ThinkValue{Value: "max"}, "high", false},
|
||||
{"true", &api.ThinkValue{Value: true}, "medium", false},
|
||||
{"false", &api.ThinkValue{Value: false}, "", false},
|
||||
{"xhigh", &api.ThinkValue{Value: "xhigh"}, "", true},
|
||||
{"future", &api.ThinkValue{Value: "future"}, "", true},
|
||||
} {
|
||||
for _, endpoint := range []string{"chat", "generate"} {
|
||||
t.Run(endpoint+"/"+tt.name, func(t *testing.T) {
|
||||
think := tt.think
|
||||
if think != nil {
|
||||
think = &api.ThinkValue{Value: think.Value}
|
||||
}
|
||||
var w *httptest.ResponseRecorder
|
||||
if endpoint == "chat" {
|
||||
w = createRequest(t, s.ChatHandler, api.ChatRequest{Model: "thinking-harmony", Messages: []api.Message{{Role: "user", Content: "hello"}}, Think: think, Stream: &stream})
|
||||
} else {
|
||||
w = createRequest(t, s.GenerateHandler, api.GenerateRequest{Model: "thinking-harmony", Prompt: "hello", Think: think, Stream: &stream})
|
||||
}
|
||||
if tt.badRequest {
|
||||
if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), "invalid think value") {
|
||||
t.Fatalf("expected legacy validation error: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
return
|
||||
}
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(mock.CompletionRequest.Prompt, "Reasoning: "+tt.want+" ") {
|
||||
t.Fatalf("expected reasoning %s: %s", tt.want, mock.CompletionRequest.Prompt)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
-24
@@ -46,7 +46,7 @@ import (
|
||||
"github.com/ollama/ollama/model/parsers"
|
||||
"github.com/ollama/ollama/model/renderers"
|
||||
"github.com/ollama/ollama/template"
|
||||
"github.com/ollama/ollama/thinking"
|
||||
thinkingparser "github.com/ollama/ollama/thinking"
|
||||
"github.com/ollama/ollama/tools"
|
||||
"github.com/ollama/ollama/types/errtypes"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
@@ -253,7 +253,11 @@ func signinURL() (string, error) {
|
||||
func (s *Server) GenerateHandler(c *gin.Context) {
|
||||
checkpointStart := time.Now()
|
||||
var req api.GenerateRequest
|
||||
if err := c.ShouldBindJSON(&req); errors.Is(err, io.EOF) {
|
||||
body := struct {
|
||||
*api.GenerateRequest
|
||||
Think json.RawMessage `json:"think"`
|
||||
}{GenerateRequest: &req}
|
||||
if err := c.ShouldBindJSON(&body); errors.Is(err, io.EOF) {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "missing request body"})
|
||||
return
|
||||
} else if err != nil {
|
||||
@@ -261,6 +265,14 @@ func (s *Server) GenerateHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(body.Think) > 0 {
|
||||
if err := json.Unmarshal(body.Think, &req.Think); err != nil {
|
||||
err = s.thinkingInputError(c.Request.Context(), req.Model, err)
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if req.TopLogprobs < 0 || req.TopLogprobs > 20 {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "top_logprobs must be between 0 and 20"})
|
||||
return
|
||||
@@ -432,6 +444,14 @@ func (s *Server) GenerateHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
thinking := m.genericThinking()
|
||||
if thinking == nil {
|
||||
if err := api.ValidateLegacyThinking(req.Think); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var builtinParser parsers.Parser
|
||||
if shouldUseHarmony(m) {
|
||||
// harmony's Reasoning field only understands low/medium/high; map "max" to "high"
|
||||
@@ -445,7 +465,7 @@ func (s *Server) GenerateHandler(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
if !req.Raw && m.Config.Parser != "" {
|
||||
if thinking == nil && !req.Raw && m.Config.Parser != "" {
|
||||
builtinParser = parsers.ParserForName(m.Config.Parser)
|
||||
if builtinParser != nil {
|
||||
// no tools or last message for generate endpoint
|
||||
@@ -458,10 +478,13 @@ func (s *Server) GenerateHandler(c *gin.Context) {
|
||||
caps = append(caps, model.CapabilityInsert)
|
||||
}
|
||||
|
||||
requestedThink := req.Think
|
||||
think := renderers.ResolveThinking(requestedThink, thinking)
|
||||
req.Think = think
|
||||
modelCaps := m.Capabilities()
|
||||
if slices.Contains(modelCaps, model.CapabilityThinking) {
|
||||
caps = append(caps, model.CapabilityThinking)
|
||||
if req.Think == nil {
|
||||
if req.Think == nil && thinking == nil {
|
||||
req.Think = &api.ThinkValue{Value: true}
|
||||
}
|
||||
} else {
|
||||
@@ -471,6 +494,13 @@ func (s *Server) GenerateHandler(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
if thinking != nil && !req.Raw && m.Config.Parser != "" {
|
||||
builtinParser = parsers.ParserForName(m.Config.Parser)
|
||||
if builtinParser != nil {
|
||||
builtinParser.Init(nil, nil, think)
|
||||
}
|
||||
}
|
||||
|
||||
r, m, opts, err := s.scheduleRunner(c.Request.Context(), m, caps, req.Options, req.KeepAlive, req.Shift)
|
||||
if errors.Is(err, errCapabilityCompletion) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("%q does not support generate", req.Model)})
|
||||
@@ -640,16 +670,16 @@ func (s *Server) GenerateHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var thinkingState *thinking.Parser
|
||||
var thinkTagParser *thinkingparser.Parser
|
||||
if builtinParser == nil {
|
||||
openingTag, closingTag := thinking.InferTags(m.Template.Template)
|
||||
openingTag, closingTag := thinkingparser.InferTags(m.Template.Template)
|
||||
if req.Think != nil && req.Think.Bool() && openingTag != "" && closingTag != "" {
|
||||
thinkingState = &thinking.Parser{
|
||||
thinkTagParser = &thinkingparser.Parser{
|
||||
OpeningTag: openingTag,
|
||||
ClosingTag: closingTag,
|
||||
}
|
||||
if strings.HasSuffix(strings.TrimSpace(prompt), openingTag) {
|
||||
thinkingState.AddContent(openingTag)
|
||||
thinkTagParser.AddContent(openingTag)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -703,8 +733,8 @@ func (s *Server) GenerateHandler(c *gin.Context) {
|
||||
if cr.Done && len(toolCalls) > 0 {
|
||||
res.ToolCalls = toolCalls
|
||||
}
|
||||
} else if thinkingState != nil {
|
||||
thinking, content := thinkingState.AddContent(cr.Content)
|
||||
} else if thinkTagParser != nil {
|
||||
thinking, content := thinkTagParser.AddContent(cr.Content)
|
||||
res.Thinking = thinking
|
||||
res.Response = content
|
||||
}
|
||||
@@ -1466,6 +1496,7 @@ func GetModelInfo(req api.ShowRequest) (*api.ShowResponse, error) {
|
||||
Details: modelDetails,
|
||||
Messages: msgs,
|
||||
Capabilities: m.Capabilities(),
|
||||
Thinking: m.Thinking(),
|
||||
ModifiedAt: mf.FileInfo().ModTime(),
|
||||
Requires: m.Config.Requires,
|
||||
// Several integrations crash on a nil/omitempty+empty ModelInfo, so by
|
||||
@@ -1901,18 +1932,18 @@ func (s *Server) GenerateRoutes() (http.Handler, error) {
|
||||
// Inference (OpenAI compatibility)
|
||||
// TODO(cloud-stage-a): apply Modelfile overlay deltas for local models with cloud
|
||||
// parents on v1 request families while preserving this explicit :cloud passthrough.
|
||||
r.POST("/v1/chat/completions", s.withInferenceRequestLogging("/v1/chat/completions", cloudPassthroughMiddleware(cloudErrRemoteInferenceUnavailable), middleware.ChatMiddleware(), s.ChatHandler)...)
|
||||
r.POST("/v1/chat/completions", s.withInferenceRequestLogging("/v1/chat/completions", cloudPassthroughMiddleware(cloudErrRemoteInferenceUnavailable), middleware.ChatMiddleware(lookupThinking), s.ChatHandler)...)
|
||||
r.POST("/v1/completions", s.withInferenceRequestLogging("/v1/completions", cloudPassthroughMiddleware(cloudErrRemoteInferenceUnavailable), middleware.CompletionsMiddleware(), s.GenerateHandler)...)
|
||||
r.POST("/v1/embeddings", cloudPassthroughMiddleware(cloudErrRemoteInferenceUnavailable), middleware.EmbeddingsMiddleware(), s.EmbedHandler)
|
||||
r.GET("/v1/models", middleware.ListMiddleware(), s.ListHandler)
|
||||
r.GET("/v1/models/:model", cloudModelPathPassthroughMiddleware(cloudErrRemoteModelDetailsUnavailable), middleware.RetrieveMiddleware(), s.ShowHandler)
|
||||
r.POST("/v1/responses", s.withInferenceRequestLogging("/v1/responses", s.responsesCompactionMiddleware(), cloudPassthroughMiddleware(cloudErrRemoteInferenceUnavailable), middleware.ResponsesMiddleware(), s.ChatHandler)...)
|
||||
r.POST("/v1/responses", s.withInferenceRequestLogging("/v1/responses", s.responsesCompactionMiddleware(), cloudPassthroughMiddleware(cloudErrRemoteInferenceUnavailable), middleware.ResponsesMiddleware(lookupThinking), s.ChatHandler)...)
|
||||
r.POST("/v1/responses/compact", s.ResponsesCompactHandler)
|
||||
// OpenAI-compatible audio endpoint
|
||||
r.POST("/v1/audio/transcriptions", middleware.TranscriptionMiddleware(), s.ChatHandler)
|
||||
|
||||
// Inference (Anthropic compatibility)
|
||||
r.POST("/v1/messages", s.withInferenceRequestLogging("/v1/messages", cloudPassthroughMiddleware(cloudErrRemoteInferenceUnavailable), middleware.AnthropicMessagesMiddleware(), s.ChatHandler)...)
|
||||
r.POST("/v1/messages", s.withInferenceRequestLogging("/v1/messages", cloudPassthroughMiddleware(cloudErrRemoteInferenceUnavailable), middleware.AnthropicMessagesMiddleware(lookupThinking), s.ChatHandler)...)
|
||||
|
||||
return r, nil
|
||||
}
|
||||
@@ -2435,7 +2466,11 @@ func (s *Server) ChatHandler(c *gin.Context) {
|
||||
checkpointStart := time.Now()
|
||||
|
||||
var req api.ChatRequest
|
||||
if err := c.ShouldBindJSON(&req); errors.Is(err, io.EOF) {
|
||||
body := struct {
|
||||
*api.ChatRequest
|
||||
Think json.RawMessage `json:"think"`
|
||||
}{ChatRequest: &req}
|
||||
if err := c.ShouldBindJSON(&body); errors.Is(err, io.EOF) {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "missing request body"})
|
||||
return
|
||||
} else if err != nil {
|
||||
@@ -2443,6 +2478,14 @@ func (s *Server) ChatHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(body.Think) > 0 {
|
||||
if err := json.Unmarshal(body.Think, &req.Think); err != nil {
|
||||
err = s.thinkingInputError(c.Request.Context(), req.Model, err)
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if req.TopLogprobs < 0 || req.TopLogprobs > 20 {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "top_logprobs must be between 0 and 20"})
|
||||
return
|
||||
@@ -2601,15 +2644,26 @@ func (s *Server) ChatHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
thinking := m.genericThinking()
|
||||
if thinking == nil {
|
||||
if err := api.ValidateLegacyThinking(req.Think); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
caps := []model.Capability{model.CapabilityCompletion}
|
||||
if len(req.Tools) > 0 {
|
||||
caps = append(caps, model.CapabilityTools)
|
||||
}
|
||||
|
||||
requestedThink := req.Think
|
||||
think := renderers.ResolveThinking(requestedThink, thinking)
|
||||
req.Think = think
|
||||
modelCaps := m.Capabilities()
|
||||
if slices.Contains(modelCaps, model.CapabilityThinking) {
|
||||
caps = append(caps, model.CapabilityThinking)
|
||||
if req.Think == nil {
|
||||
if req.Think == nil && thinking == nil {
|
||||
req.Think = &api.ThinkValue{Value: true}
|
||||
}
|
||||
} else {
|
||||
@@ -2711,16 +2765,16 @@ func (s *Server) ChatHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var thinkingState *thinking.Parser
|
||||
openingTag, closingTag := thinking.InferTags(m.Template.Template)
|
||||
var thinkTagParser *thinkingparser.Parser
|
||||
openingTag, closingTag := thinkingparser.InferTags(m.Template.Template)
|
||||
if req.Think != nil && req.Think.Bool() && openingTag != "" && closingTag != "" {
|
||||
thinkingState = &thinking.Parser{
|
||||
thinkTagParser = &thinkingparser.Parser{
|
||||
OpeningTag: openingTag,
|
||||
ClosingTag: closingTag,
|
||||
}
|
||||
|
||||
if strings.HasSuffix(strings.TrimSpace(prompt), openingTag) {
|
||||
thinkingState.AddContent(openingTag)
|
||||
thinkTagParser.AddContent(openingTag)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2757,7 +2811,7 @@ func (s *Server) ChatHandler(c *gin.Context) {
|
||||
// parsed non-thinking content as the signal to turn constraining on
|
||||
|
||||
forceImmediate := builtinParser != nil && builtinParser.HasThinkingSupport() && req.Think != nil && !req.Think.Bool()
|
||||
if req.Format != nil && structuredOutputsState == structuredOutputsState_None && !forceImmediate && ((builtinParser != nil || thinkingState != nil) && slices.Contains(m.Capabilities(), model.CapabilityThinking)) {
|
||||
if req.Format != nil && structuredOutputsState == structuredOutputsState_None && !forceImmediate && ((builtinParser != nil || thinkTagParser != nil) && slices.Contains(m.Capabilities(), model.CapabilityThinking)) {
|
||||
currentFormat = nil
|
||||
}
|
||||
includeIntermediateMetrics := req.Format != nil && currentFormat == nil
|
||||
@@ -2851,8 +2905,8 @@ func (s *Server) ChatHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if thinkingState != nil {
|
||||
thinkingContent, remainingContent := thinkingState.AddContent(res.Message.Content)
|
||||
if thinkTagParser != nil {
|
||||
thinkingContent, remainingContent := thinkTagParser.AddContent(res.Message.Content)
|
||||
if thinkingContent == "" && remainingContent == "" && !r.Done {
|
||||
// need to accumulate more to decide what to send
|
||||
return
|
||||
@@ -3149,11 +3203,11 @@ func filterThinkTags(msgs []api.Message, m *Model) []api.Message {
|
||||
// change the user output), we should probably perform this filtering
|
||||
// for all thinking models (not just qwen3 & deepseek-r1) since it tends
|
||||
// to save tokens and improve quality.
|
||||
thinkingState := &thinking.Parser{
|
||||
thinkTagParser := &thinkingparser.Parser{
|
||||
OpeningTag: "<think>",
|
||||
ClosingTag: "</think>",
|
||||
}
|
||||
_, content := thinkingState.AddContent(msg.Content)
|
||||
_, content := thinkTagParser.AddContent(msg.Content)
|
||||
msgs[i].Content = content
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// Thinking describes explicit thinking controls and the default used on omission.
|
||||
// Values are booleans or named strings. A nil descriptor means unknown.
|
||||
type Thinking struct {
|
||||
Values []any `json:"values"`
|
||||
Default any `json:"default"`
|
||||
}
|
||||
|
||||
// Valid reports whether the descriptor has distinct controls and a supported default.
|
||||
func (t *Thinking) Valid() bool {
|
||||
if t == nil || len(t.Values) == 0 {
|
||||
return false
|
||||
}
|
||||
for i, value := range t.Values {
|
||||
switch v := value.(type) {
|
||||
case bool:
|
||||
case string:
|
||||
if v == "" {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
if slices.Contains(t.Values[:i], value) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return t.Supports(t.Default)
|
||||
}
|
||||
|
||||
// Supports reports whether a boolean or named level is explicitly advertised.
|
||||
// Other names may still be accepted by the endpoint and resolve to the default.
|
||||
func (t *Thinking) Supports(value any) bool {
|
||||
if t == nil {
|
||||
return false
|
||||
}
|
||||
switch value.(type) {
|
||||
case bool, string:
|
||||
return slices.Contains(t.Values, value)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Clone returns an independent copy.
|
||||
func (t *Thinking) Clone() *Thinking {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
return &Thinking{Values: slices.Clone(t.Values), Default: t.Default}
|
||||
}
|
||||
|
||||
// ThinkValue represents a boolean or model-defined thinking level.
|
||||
type ThinkValue struct {
|
||||
// Value can be a bool or string
|
||||
Value any
|
||||
}
|
||||
|
||||
// IsValid checks the transport type. The model resolves supported effort names.
|
||||
func (t *ThinkValue) IsValid() bool {
|
||||
if t == nil || t.Value == nil {
|
||||
return true // nil is valid (means not set)
|
||||
}
|
||||
|
||||
switch t.Value.(type) {
|
||||
case bool:
|
||||
return true
|
||||
case string:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsBool returns true if the value is a boolean
|
||||
func (t *ThinkValue) IsBool() bool {
|
||||
if t == nil || t.Value == nil {
|
||||
return false
|
||||
}
|
||||
_, ok := t.Value.(bool)
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsString returns true if the value is a string
|
||||
func (t *ThinkValue) IsString() bool {
|
||||
if t == nil || t.Value == nil {
|
||||
return false
|
||||
}
|
||||
_, ok := t.Value.(string)
|
||||
return ok
|
||||
}
|
||||
|
||||
// Bool returns the value as a bool (true if enabled in any way)
|
||||
func (t *ThinkValue) Bool() bool {
|
||||
if t == nil || t.Value == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
switch v := t.Value.(type) {
|
||||
case bool:
|
||||
return v
|
||||
case string:
|
||||
// Named levels request thinking; the renderer resolves the actual level.
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// String returns the value as a string
|
||||
func (t *ThinkValue) String() string {
|
||||
if t == nil || t.Value == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch v := t.Value.(type) {
|
||||
case string:
|
||||
return v
|
||||
case bool:
|
||||
if v {
|
||||
return "medium" // Default level when just true
|
||||
}
|
||||
return ""
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler
|
||||
func (t *ThinkValue) UnmarshalJSON(data []byte) error {
|
||||
var value any
|
||||
if err := json.Unmarshal(data, &value); err != nil {
|
||||
return err
|
||||
}
|
||||
switch value.(type) {
|
||||
case nil, bool, string:
|
||||
t.Value = value
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("think must be a boolean or string")
|
||||
}
|
||||
}
|
||||
|
||||
// MarshalJSON implements json.Marshaler
|
||||
func (t *ThinkValue) MarshalJSON() ([]byte, error) {
|
||||
if t == nil || t.Value == nil {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
return json.Marshal(t.Value)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestThinkingDescriptor(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name, json string
|
||||
valid bool
|
||||
}{
|
||||
{"toggle", `{"values":[false,true],"default":true}`, true},
|
||||
{"mixed", `{"values":[false,"medium","xhigh"],"default":"medium"}`, true},
|
||||
{"nonthinking", `{"values":[false],"default":false}`, true},
|
||||
{"unknown", `null`, false},
|
||||
{"empty", `{"values":[],"default":false}`, false},
|
||||
{"integer", `{"values":[75],"default":75}`, false},
|
||||
{"object", `{"values":[{}],"default":false}`, false},
|
||||
{"missing default", `{"values":[false,true]}`, false},
|
||||
{"unlisted default", `{"values":["high"],"default":"low"}`, false},
|
||||
{"duplicate", `{"values":[false,false],"default":false}`, false},
|
||||
{"empty name", `{"values":[""],"default":""}`, false},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var thinking *Thinking
|
||||
if err := json.Unmarshal([]byte(tt.json), &thinking); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := thinking.Valid(); got != tt.valid {
|
||||
t.Fatalf("Valid() = %v, want %v", got, tt.valid)
|
||||
}
|
||||
if tt.valid {
|
||||
encoded, err := json.Marshal(*thinking)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(encoded) != tt.json {
|
||||
t.Fatalf("JSON = %s, want %s", encoded, tt.json)
|
||||
}
|
||||
clone := thinking.Clone()
|
||||
clone.Values[0] = "changed"
|
||||
if thinking.Values[0] == "changed" {
|
||||
t.Fatal("clone shares values")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestThinkValueJSON(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
value *ThinkValue
|
||||
want string
|
||||
}{
|
||||
{"false", &ThinkValue{Value: false}, `false`},
|
||||
{"true", &ThinkValue{Value: true}, `true`},
|
||||
{"level", &ThinkValue{Value: "xhigh"}, `"xhigh"`},
|
||||
{"unset", &ThinkValue{}, `null`},
|
||||
{"nil pointer", (*ThinkValue)(nil), `null`},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := json.Marshal(tt.value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != tt.want {
|
||||
t.Fatalf("JSON = %s, want %s", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestThinkValueAccessors(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
value *ThinkValue
|
||||
valid, isBool, isString, on bool
|
||||
level string
|
||||
}{
|
||||
{"nil", nil, true, false, false, false, ""},
|
||||
{"unset", &ThinkValue{}, true, false, false, false, ""},
|
||||
{"off", &ThinkValue{Value: false}, true, true, false, false, ""},
|
||||
{"on", &ThinkValue{Value: true}, true, true, false, true, "medium"},
|
||||
{"named", &ThinkValue{Value: "xhigh"}, true, false, true, true, "xhigh"},
|
||||
{"empty name", &ThinkValue{Value: ""}, true, false, true, true, ""},
|
||||
{"integer", &ThinkValue{Value: 75}, false, false, false, false, ""},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.value.IsValid() != tt.valid || tt.value.IsBool() != tt.isBool || tt.value.IsString() != tt.isString || tt.value.Bool() != tt.on || tt.value.String() != tt.level {
|
||||
t.Fatalf("unexpected accessors for %#v", tt.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestThinkingSupports(t *testing.T) {
|
||||
thinking := &Thinking{Values: []any{false, true, "medium", "xhigh"}, Default: true}
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
value any
|
||||
want bool
|
||||
}{
|
||||
{"off", false, true},
|
||||
{"on", true, true},
|
||||
{"named medium", "medium", true},
|
||||
{"named xhigh", "xhigh", true},
|
||||
{"unknown", "future", false},
|
||||
{"string is not boolean", "false", false},
|
||||
{"case is exact", "XHIGH", false},
|
||||
{"whitespace is exact", " medium ", false},
|
||||
{"nil", nil, false},
|
||||
{"number", 75, false},
|
||||
{"array", []any{false}, false},
|
||||
{"object", map[string]any{"value": false}, false},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := thinking.Supports(tt.value); got != tt.want {
|
||||
t.Fatalf("Supports(%#v) = %v, want %v", tt.value, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
var unknown *Thinking
|
||||
if unknown.Supports(false) {
|
||||
t.Fatal("unknown metadata must not advertise controls")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user