feat(providers): add provider-level thinking/reasoning toggle for Ollama (#1355)

Ollama has two separate request-building code paths: OllamaProvider
(native /api/chat, used for num_ctx control) and OpenAIProvider
(OpenAI-compat /v1/chat/completions). An earlier fix disabled thinking
mode by hardcoding think=false, but only in the OpenAI-compat path --
OllamaProvider.buildRequest() never set the think field at all, so
reasoning-capable models (qwq, deepseek-r1) defaulted to visible
chain-of-thought reasoning regardless of that fix. Confirmed live via
a docker-engineer agent streaming full reasoning traces despite the
existing disable.

Replaced the hardcoded always-off behavior with a provider-level
tri-state setting (llm_providers.settings.thinking_enabled: unset =
default off, explicit true/false overrides), configurable via the
provider's Advanced settings dialog. Both OllamaProvider.buildRequest()
and OpenAIProvider.buildRequestBody() now read and respect this same
setting, so the toggle works regardless of which Ollama code path a
given deployment routes through.

Added tests for setting parsing (unset/true/false/malformed) and both
provider request-builders' handling of the override.

Co-authored-by: Bruno Clermont <bruno.clermont@gmail.com>
This commit is contained in:
Bruno ClermontandBruno Clermont authored and GitHub committed 2026-07-05 02:57:18 +07:00
1 parent 6d24147a38
commit 8000a1d0f5
14 files changed
+335 -29

No files matched your search

+5 -2
View File
@@ -317,7 +317,8 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi
host = "http://localhost:11434"
}
numCtx := resolveOllamaNumCtx(&p, config.DockerLocalhost(host), "")
prov := providers.NewOllamaProvider(p.Name, config.DockerLocalhost(host), "llama3.3", numCtx, nil)
prov := providers.NewOllamaProvider(p.Name, config.DockerLocalhost(host), "llama3.3", numCtx, nil).
WithThinkingEnabled(store.ParseThinkingEnabled(p.Settings))
registry.RegisterForTenant(p.TenantID, prov)
slog.Info("registered provider from DB", "name", p.Name)
continue
@@ -397,7 +398,8 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi
base = "https://ollama.com"
}
numCtx := resolveOllamaNumCtx(&p, base, p.APIKey)
prov := providers.NewOllamaProvider(p.Name, base, "llama3.3", numCtx, nil)
prov := providers.NewOllamaProvider(p.Name, base, "llama3.3", numCtx, nil).
WithThinkingEnabled(store.ParseThinkingEnabled(p.Settings))
registry.RegisterForTenant(p.TenantID, prov)
case store.ProviderNovita:
base := p.APIBase
@@ -442,6 +444,7 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi
base, model := openAIProviderDefaults(p.ProviderType, p.APIBase)
prov := providers.NewOpenAIProvider(p.Name, p.APIKey, base, model)
prov.WithProviderType(p.ProviderType)
prov.WithThinkingEnabled(store.ParseThinkingEnabled(p.Settings))
if p.ProviderType == store.ProviderOpenRouter {
prov.WithSiteInfo("https://goclaw.sh", "GoClaw")
}
+5 -2
View File
@@ -255,7 +255,8 @@ func (h *ProvidersHandler) registerInMemory(p *store.LLMProviderData) providerRu
}
dockerHost := config.DockerLocalhost(host)
numCtx := h.resolveOllamaNumCtx(p, dockerHost, "")
prov := providers.NewOllamaProvider(p.Name, dockerHost, "llama3.3", numCtx, nil)
prov := providers.NewOllamaProvider(p.Name, dockerHost, "llama3.3", numCtx, nil).
WithThinkingEnabled(store.ParseThinkingEnabled(p.Settings))
h.providerReg.RegisterForTenant(p.TenantID, prov)
return providerRuntimeRegistered
}
@@ -352,11 +353,13 @@ func (h *ProvidersHandler) registerInMemory(p *store.LLMProviderData) providerRu
base = "https://ollama.com"
}
numCtx := h.resolveOllamaNumCtx(p, base, p.APIKey)
prov := providers.NewOllamaProvider(p.Name, base, "llama3.3", numCtx, nil)
prov := providers.NewOllamaProvider(p.Name, base, "llama3.3", numCtx, nil).
WithThinkingEnabled(store.ParseThinkingEnabled(p.Settings))
h.providerReg.RegisterForTenant(p.TenantID, prov)
default:
base, model := openAIProviderDefaults(p.ProviderType, apiBase)
prov := providers.NewOpenAIProvider(p.Name, p.APIKey, base, model)
prov.WithThinkingEnabled(store.ParseThinkingEnabled(p.Settings))
h.providerReg.RegisterForTenant(p.TenantID, prov)
}
return providerRuntimeRegistered
+29 -1
View File
@@ -22,6 +22,11 @@ type OllamaProvider struct {
numCtx *int
client *ollamaapi.Client
retryConfig RetryConfig
// thinkingEnabled is the provider-level override for whether requests
// should ask Ollama to emit visible reasoning/thinking tokens.
// nil = default off (see buildRequest).
thinkingEnabled *bool
}
// NewOllamaProvider creates an OllamaProvider.
@@ -57,6 +62,20 @@ func NewOllamaProvider(name, apiBase, defaultModel string, numCtx *int, httpClie
}
}
// WithThinkingEnabled sets the provider-level override for whether native
// Ollama chat requests should ask the model to emit visible reasoning
// ("think") tokens. nil (not calling this) preserves the existing default
// of disabling thinking.
func (p *OllamaProvider) WithThinkingEnabled(enabled *bool) *OllamaProvider {
p.thinkingEnabled = enabled
return p
}
// ThinkingEnabled returns the configured provider-level thinking override, or nil if not set.
func (p *OllamaProvider) ThinkingEnabled() *bool {
return p.thinkingEnabled
}
// Name returns the provider identifier.
func (p *OllamaProvider) Name() string { return p.name }
@@ -200,6 +219,16 @@ func (p *OllamaProvider) buildRequest(req ChatRequest, stream bool) *ollamaapi.C
Stream: &stream,
}
// Thinking visibility: default off (models like qwq/deepseek-r1 have
// thinking on by default and goclaw suppresses it to avoid bloated
// chain-of-thought responses), unless the provider config explicitly
// enables it via settings.thinking_enabled=true.
thinkingEnabled := false
if p.thinkingEnabled != nil {
thinkingEnabled = *p.thinkingEnabled
}
ollamaReq.Think = &ollamaapi.ThinkValue{Value: thinkingEnabled}
// Inject tools.
for _, td := range req.Tools {
if td.Type != "function" || td.Function == nil {
@@ -301,4 +330,3 @@ func mapDoneReason(reason string) string {
return "stop"
}
}
+64
View File
@@ -0,0 +1,64 @@
package providers
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestOllamaBuildRequest_ThinkDefaultsToFalse verifies that when no
// provider-level thinking override is configured, buildRequest disables
// thinking by default (matches the OpenAI-compat Ollama path).
func TestOllamaBuildRequest_ThinkDefaultsToFalse(t *testing.T) {
p := NewOllamaProvider("ollama", "http://localhost:11434", "llama3.3", nil, nil)
req := ChatRequest{Messages: []Message{{Role: "user", Content: "hi"}}}
ollamaReq := p.buildRequest(req, false)
require.NotNil(t, ollamaReq.Think)
value, ok := ollamaReq.Think.Value.(bool)
require.True(t, ok, "expected Think.Value to be a bool")
assert.False(t, value)
}
// TestOllamaBuildRequest_ThinkOverride verifies that WithThinkingEnabled
// correctly sets ollamaReq.Think for both true and false overrides.
func TestOllamaBuildRequest_ThinkOverride(t *testing.T) {
trueVal := true
falseVal := false
cases := []struct {
name string
override *bool
want bool
}{
{name: "override true enables thinking", override: &trueVal, want: true},
{name: "override false disables thinking", override: &falseVal, want: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
p := NewOllamaProvider("ollama", "http://localhost:11434", "llama3.3", nil, nil).
WithThinkingEnabled(tc.override)
req := ChatRequest{Messages: []Message{{Role: "user", Content: "hi"}}}
ollamaReq := p.buildRequest(req, false)
require.NotNil(t, ollamaReq.Think)
value, ok := ollamaReq.Think.Value.(bool)
require.True(t, ok, "expected Think.Value to be a bool")
assert.Equal(t, tc.want, value)
})
}
}
// TestOllamaThinkingEnabledAccessor verifies the WithThinkingEnabled /
// ThinkingEnabled getter/setter round-trip.
func TestOllamaThinkingEnabledAccessor(t *testing.T) {
p := NewOllamaProvider("ollama", "http://localhost:11434", "llama3.3", nil, nil)
assert.Nil(t, p.ThinkingEnabled())
trueVal := true
p.WithThinkingEnabled(&trueVal)
require.NotNil(t, p.ThinkingEnabled())
assert.True(t, *p.ThinkingEnabled())
}
+31 -16
View File
@@ -8,22 +8,23 @@ import (
// OpenAIProvider implements Provider for OpenAI-compatible APIs
// (OpenAI, Groq, OpenRouter, DeepSeek, VLLM, etc.)
type OpenAIProvider struct {
name string
apiKey string
apiBase string
chatPath string // defaults to "/chat/completions"
authPrefix string // auth header prefix, defaults to "Bearer " if empty
defaultModel string
providerType string // DB provider_type (e.g. "gemini_native", "openai", "minimax_native")
siteURL string // optional site URL for provider identification (e.g. OpenRouter HTTP-Referer)
siteTitle string // optional site title for provider identification (e.g. OpenRouter X-Title)
extraHeaders map[string]string // static headers set on every outgoing request (e.g. fixed User-Agent for kimi_coding)
client *http.Client
retryConfig RetryConfig
middlewares RequestMiddleware // composed middleware chain (nil = no-op)
registry ModelRegistry // model resolution registry (nil = skip)
noAuthHeader bool // when true, doRequest() skips setting Authorization (e.g. Vertex OAuth transport injects its own)
ollamaNumCtx *int // optional Ollama options.num_ctx override (nil = use queried or default value)
name string
apiKey string
apiBase string
chatPath string // defaults to "/chat/completions"
authPrefix string // auth header prefix, defaults to "Bearer " if empty
defaultModel string
providerType string // DB provider_type (e.g. "gemini_native", "openai", "minimax_native")
siteURL string // optional site URL for provider identification (e.g. OpenRouter HTTP-Referer)
siteTitle string // optional site title for provider identification (e.g. OpenRouter X-Title)
extraHeaders map[string]string // static headers set on every outgoing request (e.g. fixed User-Agent for kimi_coding)
client *http.Client
retryConfig RetryConfig
middlewares RequestMiddleware // composed middleware chain (nil = no-op)
registry ModelRegistry // model resolution registry (nil = skip)
noAuthHeader bool // when true, doRequest() skips setting Authorization (e.g. Vertex OAuth transport injects its own)
ollamaNumCtx *int // optional Ollama options.num_ctx override (nil = use queried or default value)
thinkingEnabled *bool // provider-level override for "think" on Ollama endpoints (nil = default off)
}
func NewOpenAIProvider(name, apiKey, apiBase, defaultModel string) *OpenAIProvider {
@@ -143,6 +144,20 @@ func (p *OpenAIProvider) OllamaNumCtx() *int {
return p.ollamaNumCtx
}
// WithThinkingEnabled sets the provider-level override for whether Ollama
// endpoints should be asked to emit visible reasoning/thinking tokens
// (sets body["think"] in buildRequestBody). nil (not calling this) preserves
// the existing default of disabling thinking on Ollama endpoints.
func (p *OpenAIProvider) WithThinkingEnabled(enabled *bool) *OpenAIProvider {
p.thinkingEnabled = enabled
return p
}
// ThinkingEnabled returns the configured provider-level thinking override, or nil if not set.
func (p *OpenAIProvider) ThinkingEnabled() *bool {
return p.thinkingEnabled
}
func (p *OpenAIProvider) Name() string { return p.name }
func (p *OpenAIProvider) DefaultModel() string { return p.defaultModel }
+11 -3
View File
@@ -298,9 +298,17 @@ func (p *OpenAIProvider) buildRequestBody(model string, req ChatRequest, stream
}
slog.Debug("ollama.request: final request body (first 500 chars)", "provider", p.name, "model", model, "body_prefix", raw)
}
// Disable thinking by default; only enable if caller explicitly requests it.
if level, _ := req.Options[OptThinkingLevel].(string); level == "" || level == "off" {
body["think"] = false
// Thinking visibility: provider-level override (settings.thinking_enabled)
// takes precedence; otherwise disable thinking by default (models like
// qwq/deepseek-r1 have thinking on by default) unless the caller
// explicitly requests a reasoning effort level.
switch {
case p.thinkingEnabled != nil:
body["think"] = *p.thinkingEnabled
default:
if level, _ := req.Options[OptThinkingLevel].(string); level == "" || level == "off" {
body["think"] = false
}
}
}
+48 -1
View File
@@ -293,7 +293,6 @@ func TestBuildRequestBody_MultimodalWithVideoURL(t *testing.T) {
}
}
func TestBuildRequestBody_TogetherDetectedByProviderType(t *testing.T) {
// Together behind reverse proxy — detected by providerType, not URL.
p := NewOpenAIProvider("my-proxy", "key", "https://proxy.internal/v1", "")
@@ -705,3 +704,51 @@ func TestNonOllamaSupportsThinkingTrue(t *testing.T) {
t.Fatal("SupportsThinking() must return true for non-Ollama OpenAI-compat providers")
}
}
func TestBuildRequestBody_OllamaThinkingEnabledOverride(t *testing.T) {
// Provider-level settings.thinking_enabled overrides the default off
// behavior for Ollama endpoints, regardless of OptThinkingLevel.
trueVal := true
falseVal := false
cases := []struct {
name string
override *bool
want bool
}{
{name: "override true enables thinking", override: &trueVal, want: true},
{name: "override false disables thinking", override: &falseVal, want: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
p := NewOpenAIProvider("ollama", "", "http://localhost:11434/v1", "").
WithThinkingEnabled(tc.override)
req := ChatRequest{Messages: []Message{{Role: "user", Content: "hi"}}}
body := p.buildRequestBody("qwq:32b", req, false)
think, ok := body["think"]
if !ok {
t.Fatalf("expected think field in request body")
}
if think != tc.want {
t.Fatalf("think = %v, want %v", think, tc.want)
}
})
}
}
func TestBuildRequestBody_OllamaThinkingEnabledOverrideTakesPrecedenceOverLevel(t *testing.T) {
// Even when OptThinkingLevel requests non-off reasoning, an explicit
// provider-level thinking_enabled=false must still win.
falseVal := false
p := NewOpenAIProvider("ollama", "", "http://localhost:11434/v1", "").
WithThinkingEnabled(&falseVal)
req := ChatRequest{
Messages: []Message{{Role: "user", Content: "reason about this"}},
Options: map[string]any{OptThinkingLevel: "high"},
}
body := p.buildRequestBody("qwq:32b", req, false)
if think, ok := body["think"]; !ok || think != false {
t.Fatalf("expected think=false when provider override is false, got %v (present=%v)", think, ok)
}
}
+18
View File
@@ -194,6 +194,24 @@ func ParseEmbeddingSettings(settings json.RawMessage) *EmbeddingSettings {
return s.Embedding
}
// ParseThinkingEnabled extracts the provider-level override for whether the
// provider should be asked to emit visible reasoning/thinking tokens (e.g.
// Ollama native "think" field, OpenAI-compat "think" for Ollama endpoints).
// Returns nil when unset in settings JSONB, meaning "use provider default"
// (currently off for Ollama). Explicit true/false overrides that default.
func ParseThinkingEnabled(settings json.RawMessage) *bool {
if len(settings) == 0 {
return nil
}
var s struct {
ThinkingEnabled *bool `json:"thinking_enabled"`
}
if json.Unmarshal(settings, &s) != nil {
return nil
}
return s.ThinkingEnabled
}
// ParseChatGPTOAuthProviderSettings extracts provider-level Codex pool defaults from settings JSONB.
func ParseChatGPTOAuthProviderSettings(settings json.RawMessage) *ChatGPTOAuthProviderSettings {
if len(settings) == 0 {
+68
View File
@@ -0,0 +1,68 @@
package store
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseThinkingEnabled(t *testing.T) {
trueVal := true
falseVal := false
tests := []struct {
name string
settings json.RawMessage
want *bool
}{
{
name: "unset settings",
settings: nil,
want: nil,
},
{
name: "empty object",
settings: json.RawMessage(`{}`),
want: nil,
},
{
name: "explicit true",
settings: json.RawMessage(`{"thinking_enabled":true}`),
want: &trueVal,
},
{
name: "explicit false",
settings: json.RawMessage(`{"thinking_enabled":false}`),
want: &falseVal,
},
{
name: "coexists with other settings keys",
settings: json.RawMessage(`{"num_ctx":8192,"thinking_enabled":true}`),
want: &trueVal,
},
{
name: "malformed json",
settings: json.RawMessage(`{not valid json`),
want: nil,
},
{
name: "wrong type is ignored",
settings: json.RawMessage(`{"thinking_enabled":"yes"}`),
want: nil,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := ParseThinkingEnabled(tc.settings)
if tc.want == nil {
assert.Nil(t, got)
return
}
require.NotNil(t, got)
assert.Equal(t, *tc.want, *got)
})
}
}
+6 -1
View File
@@ -141,7 +141,12 @@
"description": "Ollama-specific settings",
"numCtx": "Context Window (tokens)",
"numCtxPlaceholder": "Leave empty to auto-detect from model",
"numCtxHelp": "Maximum number of tokens. Leave empty to auto-detect via Ollama API."
"numCtxHelp": "Maximum number of tokens. Leave empty to auto-detect via Ollama API.",
"thinkingEnabled": "Reasoning / Thinking Tokens",
"thinkingEnabledDefault": "Default (off)",
"thinkingEnabledOn": "On",
"thinkingEnabledOff": "Off",
"thinkingEnabledHelp": "Controls whether this provider asks the model to emit visible reasoning (\"thinking\") tokens. Default is off to avoid bloated chain-of-thought output from models like qwq or deepseek-r1."
},
"oauth": {
"checkingStatus": "Checking authentication status...",
+6 -1
View File
@@ -141,7 +141,12 @@
"description": "Ollama 고유 설정",
"numCtx": "컨텍스트 윈도우(토큰)",
"numCtxPlaceholder": "모델에서 자동 감지하려면 비워두세요",
"numCtxHelp": "최대 토큰 수입니다. Ollama API를 통해 자동 감지하려면 비워두세요."
"numCtxHelp": "최대 토큰 수입니다. Ollama API를 통해 자동 감지하려면 비워두세요.",
"thinkingEnabled": "추론 / 사고 토큰",
"thinkingEnabledDefault": "기본값 (끔)",
"thinkingEnabledOn": "켜짐",
"thinkingEnabledOff": "꺼짐",
"thinkingEnabledHelp": "이 제공자가 모델에게 표시되는 추론(\"사고\") 토큰을 생성하도록 요청할지 여부를 제어합니다. qwq나 deepseek-r1 같은 모델의 장황한 사고 과정 출력을 방지하기 위해 기본값은 꺼짐입니다."
},
"oauth": {
"checkingStatus": "인증 상태 확인 중...",
+6 -1
View File
@@ -146,7 +146,12 @@
"description": "Các cài đặt dành riêng cho Ollama",
"numCtx": "Cửa sổ ngữ cảnh (tokens)",
"numCtxPlaceholder": "Bỏ trống để tự động phát hiện từ mô hình",
"numCtxHelp": "Số lượng token tối đa. Bỏ trống để tự động phát hiện qua API Ollama."
"numCtxHelp": "Số lượng token tối đa. Bỏ trống để tự động phát hiện qua API Ollama.",
"thinkingEnabled": "Token suy luận / tư duy",
"thinkingEnabledDefault": "Mặc định (tắt)",
"thinkingEnabledOn": "Bật",
"thinkingEnabledOff": "Tắt",
"thinkingEnabledHelp": "Kiểm soát việc nhà cung cấp này có yêu cầu mô hình phát ra token suy luận (\"tư duy\") hiển thị hay không. Mặc định là tắt để tránh đầu ra chuỗi suy luận cồng kềnh từ các mô hình như qwq hoặc deepseek-r1."
},
"oauth": {
"checkingStatus": "Đang kiểm tra trạng thái xác thực...",
+6 -1
View File
@@ -155,7 +155,12 @@
"description": "Ollama 特定设置",
"numCtx": "上下文窗口(标记数)",
"numCtxPlaceholder": "留空自动从模型检测",
"numCtxHelp": "最大标记数。留空以通过 Ollama API 自动检测。"
"numCtxHelp": "最大标记数。留空以通过 Ollama API 自动检测。",
"thinkingEnabled": "推理 / 思考标记",
"thinkingEnabledDefault": "默认(关闭)",
"thinkingEnabledOn": "开启",
"thinkingEnabledOff": "关闭",
"thinkingEnabledHelp": "控制该提供商是否要求模型输出可见的推理(“思考”)标记。默认关闭,以避免 qwq、deepseek-r1 等模型产生冗长的思维链输出。"
},
"oauth": {
"checkingStatus": "正在检查认证状态...",
@@ -40,6 +40,10 @@ function deriveState(provider: ProviderData) {
acpPermMode: (s?.perm_mode as string) || "approve-all",
acpWorkDir: (s?.work_dir as string) || "",
numCtx: (s?.num_ctx as string) || "",
thinkingEnabled:
typeof s?.thinking_enabled === "boolean"
? (s.thinking_enabled as boolean)
: null,
};
}
@@ -66,6 +70,7 @@ export function ProviderAdvancedDialog({
const [acpPermMode, setAcpPermMode] = useState(init.acpPermMode);
const [acpWorkDir, setAcpWorkDir] = useState(init.acpWorkDir);
const [numCtx, setNumCtx] = useState(init.numCtx);
const [thinkingEnabled, setThinkingEnabled] = useState(init.thinkingEnabled);
// Re-sync when dialog opens
useEffect(() => {
@@ -78,6 +83,7 @@ export function ProviderAdvancedDialog({
setAcpPermMode(s.acpPermMode);
setAcpWorkDir(s.acpWorkDir);
setNumCtx(s.numCtx);
setThinkingEnabled(s.thinkingEnabled);
}, [open, provider]);
@@ -105,6 +111,7 @@ export function ProviderAdvancedDialog({
if (provider.provider_type === "ollama" || provider.provider_type === "ollama_cloud") {
const settings: Record<string, unknown> = {};
if (numCtx.trim()) settings.num_ctx = parseInt(numCtx.trim(), 10);
if (thinkingEnabled !== null) settings.thinking_enabled = thinkingEnabled;
if (Object.keys(settings).length > 0) data.settings = settings;
}
}
@@ -187,6 +194,31 @@ export function ProviderAdvancedDialog({
/>
<p className="text-xs text-muted-foreground">{t("ollama.numCtxHelp")}</p>
</div>
<div className="space-y-2">
<Label htmlFor="thinkingEnabled">{t("ollama.thinkingEnabled")}</Label>
<Select
value={
thinkingEnabled === null
? "default"
: thinkingEnabled
? "on"
: "off"
}
onValueChange={(v) =>
setThinkingEnabled(v === "default" ? null : v === "on")
}
>
<SelectTrigger id="thinkingEnabled" className="text-base md:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">{t("ollama.thinkingEnabledDefault")}</SelectItem>
<SelectItem value="on">{t("ollama.thinkingEnabledOn")}</SelectItem>
<SelectItem value="off">{t("ollama.thinkingEnabledOff")}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{t("ollama.thinkingEnabledHelp")}</p>
</div>
</>
)}