Skip to content

Commit dd0d677

Browse files
yyyCodeclaude
andcommitted
llms/ollama: send think as top-level field so WithThink works
The think flag was serialized inside the "options" object, but the Ollama API defines it as a top-level field of the /api/chat and /api/generate requests. Unknown keys inside "options" are ignored by the server, so WithThink(true) had no effect. Additionally, omitempty on a plain bool dropped WithThink(false) entirely, making it impossible to disable thinking on models (qwen3, deepseek-r1, ...) that reason by default. Move Think out of Options onto ChatRequest and GenerateRequest as a *bool, populate it from the WithThink option and the thinking_config metadata via a resolveThink helper, and update the recorded httprr fixtures to the corrected wire format. Fixes #1514 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8fea3de commit dd0d677

6 files changed

Lines changed: 93 additions & 44 deletions

File tree

llms/ollama/internal/ollamaclient/ollamaclient_test.go

Lines changed: 56 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -310,10 +310,10 @@ func TestClient_GenerateChatWithThink(t *testing.T) {
310310
},
311311
},
312312
Stream: false,
313+
Think: boolPtr(true), // Enable reasoning mode (top-level per Ollama API)
313314
Options: Options{
314315
Temperature: 0.0,
315316
NumPredict: 100,
316-
Think: true, // Enable reasoning mode
317317
},
318318
}
319319

@@ -332,28 +332,63 @@ func TestClient_GenerateChatWithThink(t *testing.T) {
332332
// This test verifies that the parameter is properly serialized
333333
}
334334

335-
func TestOptionsJSONMarshalWithThink(t *testing.T) {
336-
// Test that the think parameter is properly marshaled to JSON
337-
opts := Options{
338-
Temperature: 0.5,
339-
Think: true,
340-
}
335+
func TestChatRequestJSONMarshalWithThink(t *testing.T) {
336+
// The Ollama API defines "think" as a top-level field of the chat request,
337+
// not as part of "options". See https://github.com/tmc/langchaingo/issues/1514.
338+
t.Run("think true is top-level", func(t *testing.T) {
339+
req := ChatRequest{
340+
Model: "qwen3",
341+
Think: boolPtr(true),
342+
Options: Options{
343+
Temperature: 0.5,
344+
},
345+
}
341346

342-
data, err := json.Marshal(opts)
343-
require.NoError(t, err)
347+
data, err := json.Marshal(req)
348+
require.NoError(t, err)
344349

345-
// Check that the JSON contains the think field
346-
var result map[string]interface{}
347-
err = json.Unmarshal(data, &result)
348-
require.NoError(t, err)
350+
var result map[string]any
351+
require.NoError(t, json.Unmarshal(data, &result))
352+
353+
think, exists := result["think"]
354+
assert.True(t, exists, "think field should exist at the top level")
355+
assert.Equal(t, true, think, "think field should be true")
356+
357+
// think must not leak into the nested options object.
358+
opts, ok := result["options"].(map[string]any)
359+
require.True(t, ok, "options object should be present")
360+
_, thinkInOptions := opts["think"]
361+
assert.False(t, thinkInOptions, "think must not be nested inside options")
362+
})
363+
364+
t.Run("think false is preserved", func(t *testing.T) {
365+
// A plain bool with omitempty would drop false; the pointer keeps it so
366+
// callers can explicitly disable thinking on models that think by default.
367+
req := ChatRequest{Model: "qwen3", Think: boolPtr(false)}
349368

350-
// Verify think field exists and is true
351-
think, exists := result["think"]
352-
assert.True(t, exists, "think field should exist in JSON")
353-
assert.Equal(t, true, think, "think field should be true")
369+
data, err := json.Marshal(req)
370+
require.NoError(t, err)
354371

355-
// Verify temperature field for completeness
356-
temp, exists := result["temperature"]
357-
assert.True(t, exists, "temperature field should exist in JSON")
358-
assert.Equal(t, float64(0.5), temp, "temperature should be 0.5")
372+
var result map[string]any
373+
require.NoError(t, json.Unmarshal(data, &result))
374+
375+
think, exists := result["think"]
376+
assert.True(t, exists, "explicit false think field should be preserved")
377+
assert.Equal(t, false, think, "think field should be false")
378+
})
379+
380+
t.Run("think unset is omitted", func(t *testing.T) {
381+
req := ChatRequest{Model: "qwen3"}
382+
383+
data, err := json.Marshal(req)
384+
require.NoError(t, err)
385+
386+
var result map[string]any
387+
require.NoError(t, json.Unmarshal(data, &result))
388+
389+
_, exists := result["think"]
390+
assert.False(t, exists, "unset think field should be omitted")
391+
})
359392
}
393+
394+
func boolPtr(b bool) *bool { return &b }

llms/ollama/internal/ollamaclient/testdata/TestClient_GenerateChatWithThink.httprr

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

llms/ollama/internal/ollamaclient/types.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ type GenerateRequest struct {
3434
Context []int `json:"context,omitempty"`
3535
Stream *bool `json:"stream"`
3636
KeepAlive string `json:"keep_alive,omitempty"`
37+
// Think enables reasoning mode (Ollama 0.9.0+). It is a top-level field per
38+
// the Ollama API, not part of Options. A nil pointer omits the field, so an
39+
// explicit false (disable thinking) is distinguishable from "unset".
40+
Think *bool `json:"think,omitempty"`
3741

3842
Options Options `json:"options"`
3943
}
@@ -52,6 +56,10 @@ type ChatRequest struct {
5256
Stream bool `json:"stream,omitempty"`
5357
Format string `json:"format"`
5458
KeepAlive string `json:"keep_alive,omitempty"`
59+
// Think enables reasoning mode (Ollama 0.9.0+). It is a top-level field per
60+
// the Ollama API, not part of Options. A nil pointer omits the field, so an
61+
// explicit false (disable thinking) is distinguishable from "unset".
62+
Think *bool `json:"think,omitempty"`
5563

5664
Options Options `json:"options"`
5765
}
@@ -167,7 +175,6 @@ type Options struct {
167175
MirostatEta float32 `json:"mirostat_eta,omitempty"`
168176
TopP float32 `json:"top_p,omitempty"`
169177
PenalizeNewline bool `json:"penalize_newline,omitempty"`
170-
Think bool `json:"think,omitempty"` // Ollama 0.9.0+ reasoning mode
171178
}
172179

173180
type PullRequest struct {

llms/ollama/ollamallm.go

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -146,21 +146,18 @@ func (o *LLM) GenerateContent(ctx context.Context, messages []llms.MessageConten
146146
// Get our ollamaOptions from llms.CallOptions
147147
ollamaOptions := makeOllamaOptionsFromOptions(o.options.ollamaOptions, opts)
148148

149-
// Handle thinking mode if specified via metadata
150-
if opts.Metadata != nil {
151-
if config, ok := opts.Metadata["thinking_config"].(*llms.ThinkingConfig); ok {
152-
if config.Mode != llms.ThinkingModeNone && o.SupportsReasoning() {
153-
// Enable thinking for models that support it
154-
ollamaOptions.Think = true
155-
}
156-
}
157-
}
149+
// Resolve the reasoning-mode flag. It is sent as a top-level field per the
150+
// Ollama API (not inside options), and uses a pointer so an explicit false
151+
// (disable thinking) is distinguishable from "unset".
152+
think := resolveThink(o.options.think, opts)
153+
158154
req := &ollamaclient.ChatRequest{
159155
Model: model,
160156
Format: format,
161157
Messages: chatMsgs,
162158
Options: ollamaOptions,
163159
Stream: opts.StreamingFunc != nil,
160+
Think: think,
164161
}
165162

166163
keepAlive := o.options.keepAlive
@@ -231,7 +228,7 @@ func (o *LLM) GenerateContent(ctx context.Context, messages []llms.MessageConten
231228

232229
// Note: Ollama may include thinking in the main content when Think mode is enabled
233230
// Future versions may provide separate thinking content
234-
if ollamaOptions.Think && o.SupportsReasoning() {
231+
if think != nil && *think && o.SupportsReasoning() {
235232
genInfo["ThinkingEnabled"] = true
236233
}
237234

@@ -319,17 +316,21 @@ func makeOllamaOptionsFromOptions(ollamaOptions ollamaclient.Options, opts llms.
319316
ollamaOptions.FrequencyPenalty = float32(opts.FrequencyPenalty)
320317
ollamaOptions.PresencePenalty = float32(opts.PresencePenalty)
321318

322-
// Extract thinking configuration for models that support it
319+
return ollamaOptions
320+
}
321+
322+
// resolveThink determines the value of the top-level Ollama "think" field.
323+
// Per-call thinking configuration (via WithThinkingMode / the "thinking_config"
324+
// metadata) takes precedence over the client-level WithThink option. A nil
325+
// result omits the field entirely, leaving the server default in place.
326+
func resolveThink(clientThink *bool, opts llms.CallOptions) *bool {
323327
if opts.Metadata != nil {
324328
if config, ok := opts.Metadata["thinking_config"].(*llms.ThinkingConfig); ok {
325-
// Enable thinking mode if not explicitly disabled
326-
if config.Mode != llms.ThinkingModeNone {
327-
ollamaOptions.Think = true
328-
}
329+
think := config.Mode != llms.ThinkingModeNone
330+
return &think
329331
}
330332
}
331-
332-
return ollamaOptions
333+
return clientThink
333334
}
334335

335336
// pullModelIfNeeded pulls the model if it's not already available.

llms/ollama/options.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ type options struct {
2020
keepAlive string
2121
pullModel bool
2222
pullTimeout time.Duration
23+
// think is the reasoning-mode flag sent as a top-level field to the Ollama
24+
// API. A nil pointer leaves it unset so an explicit false is distinguishable.
25+
think *bool
2326
}
2427

2528
type Option func(*options)
@@ -268,11 +271,14 @@ func WithPredictPenalizeNewline(val bool) Option {
268271
}
269272
}
270273

271-
// WithThink enables reasoning mode for models that support it (Ollama 0.9.0+).
272-
// When enabled, the model will show its internal reasoning process.
274+
// WithThink enables or disables reasoning mode for models that support it
275+
// (Ollama 0.9.0+). When enabled, the model will show its internal reasoning
276+
// process. Passing false explicitly disables thinking for models (such as
277+
// qwen3 or deepseek-r1) that think by default. The flag is sent as a top-level
278+
// field to the Ollama API.
273279
func WithThink(val bool) Option {
274280
return func(opts *options) {
275-
opts.ollamaOptions.Think = val
281+
opts.think = &val
276282
}
277283
}
278284

llms/ollama/testdata/TestWithThink.httprr

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)