api: expose model thinking levels and defaults (#18473)

This commit is contained in:
Parth Sareen
2026-09-18 14:10:53 -07:00
committed by GitHub
parent 9a44d9d8de
commit d0c8cdb795
52 changed files with 2212 additions and 262 deletions
+18 -13
View File
@@ -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
}
}
}
+68
View File
@@ -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
View File
@@ -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
View File
@@ -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")
}
})
}
}
+4 -1
View File
@@ -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
}
+49
View File
@@ -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
View File
@@ -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
})
+48
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+69 -28
View File
@@ -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)
+36
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+2
View File
@@ -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 {
+24 -2
View File
@@ -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.
+56
View File
@@ -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,
+3 -2
View File
@@ -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
+6 -4
View File
@@ -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
+16
View File
@@ -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)
}
+80
View File
@@ -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)
}
})
}
}
}
+9
View File
@@ -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}
}
+5
View File
@@ -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}
}
+9
View File
@@ -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}
}
+5
View File
@@ -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}
}
+5
View File
@@ -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}
}
+5
View File
@@ -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"}
}
+5
View File
@@ -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}
}
+5
View File
@@ -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}
}
+9
View File
@@ -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}
}
+5
View File
@@ -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
View File
@@ -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}
}
+5
View File
@@ -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}
}
+5
View File
@@ -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}
}
+10 -2
View File
@@ -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}
}
+5 -3
View File
@@ -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 {
+5
View File
@@ -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}
}
+9
View File
@@ -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}
}
+2
View File
@@ -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
}
+5
View File
@@ -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 }
+45
View File
@@ -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}
}
+66
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+175
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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)
+121
View File
@@ -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()
}
+402
View File
@@ -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
View File
@@ -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
}
}
+157
View File
@@ -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)
}
+128
View File
@@ -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")
}
}