Files
Bruno ClermontandBruno Clermont 8000a1d0f5 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>
2026-07-05 02:57:18 +07:00

225 lines
8.0 KiB
Go

package providers
import (
"net/http"
"strings"
)
// 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)
thinkingEnabled *bool // provider-level override for "think" on Ollama endpoints (nil = default off)
}
func NewOpenAIProvider(name, apiKey, apiBase, defaultModel string) *OpenAIProvider {
if apiBase == "" {
apiBase = "https://api.openai.com/v1"
}
apiBase = strings.TrimRight(apiBase, "/")
return &OpenAIProvider{
name: name,
apiKey: apiKey,
apiBase: apiBase,
chatPath: "/chat/completions",
defaultModel: defaultModel,
client: NewDefaultHTTPClient(),
retryConfig: DefaultRetryConfig(),
middlewares: ComposeMiddlewares(FastModeMiddleware, ServiceTierMiddleware, CacheMiddleware),
}
}
// WithChatPath returns a copy with a custom chat completions path.
func (p *OpenAIProvider) WithChatPath(path string) *OpenAIProvider {
p.chatPath = path
return p
}
// WithAuthPrefix sets a custom Authorization header prefix for providers with non-standard auth formats.
// Default is "Bearer " if not set.
func (p *OpenAIProvider) WithAuthPrefix(prefix string) *OpenAIProvider {
p.authPrefix = prefix
return p
}
// WithSiteInfo sets site identification headers sent with API requests.
// Used by OpenRouter for rankings (HTTP-Referer, X-Title).
func (p *OpenAIProvider) WithSiteInfo(url, title string) *OpenAIProvider {
p.siteURL = url
p.siteTitle = title
return p
}
// WithExtraHeaders sets static headers attached to every outgoing request.
// Used by providers that require a fixed identity header (e.g. kimi_coding's
// User-Agent: claude-code/0.1.0). Repeat calls merge — keys already present are
// overwritten. Passing an empty map is a no-op.
func (p *OpenAIProvider) WithExtraHeaders(h map[string]string) *OpenAIProvider {
if len(h) == 0 {
return p
}
if p.extraHeaders == nil {
p.extraHeaders = make(map[string]string, len(h))
}
for k, v := range h {
p.extraHeaders[k] = v
}
return p
}
// ExtraHeaders returns a copy of the static headers configured for this provider.
// Used by adapter_openai.go to mirror the runtime request headers.
func (p *OpenAIProvider) ExtraHeaders() map[string]string {
if len(p.extraHeaders) == 0 {
return nil
}
out := make(map[string]string, len(p.extraHeaders))
for k, v := range p.extraHeaders {
out[k] = v
}
return out
}
// WithRegistry sets the model registry for forward-compat resolution.
func (p *OpenAIProvider) WithRegistry(r ModelRegistry) *OpenAIProvider {
p.registry = r
return p
}
// WithMiddlewares sets the composed request middleware chain.
func (p *OpenAIProvider) WithMiddlewares(mws ...RequestMiddleware) *OpenAIProvider {
p.middlewares = ComposeMiddlewares(mws...)
return p
}
// WithProviderType sets the DB provider_type for correct API endpoint routing in media tools.
func (p *OpenAIProvider) WithProviderType(pt string) *OpenAIProvider {
p.providerType = pt
return p
}
// WithHTTPClient overrides the default HTTP client. Used by Vertex to inject an oauth2.Transport.
func (p *OpenAIProvider) WithHTTPClient(c *http.Client) *OpenAIProvider {
if c != nil {
p.client = c
}
return p
}
// WithoutAuthHeader disables the Authorization header in doRequest(). Used by Vertex where
// the oauth2.Transport injects Authorization itself.
func (p *OpenAIProvider) WithoutAuthHeader() *OpenAIProvider {
p.noAuthHeader = true
return p
}
// WithOllamaNumCtx sets a static options.num_ctx value injected on every Ollama request.
// When set, this takes precedence over the value queried from /api/show and the built-in
// default of 131072. A non-positive value is ignored.
func (p *OpenAIProvider) WithOllamaNumCtx(n int) *OpenAIProvider {
if n > 0 {
p.ollamaNumCtx = &n
}
return p
}
// OllamaNumCtx returns the configured num_ctx override, or nil if not set.
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 }
// SupportsThinking returns false for Ollama endpoints, which disable thinking by default
// (models like qwq and deepseek-r1 have thinking on by default and goclaw suppresses it).
func (p *OpenAIProvider) SupportsThinking() bool { return !p.isOllamaEndpoint() }
func (p *OpenAIProvider) APIKey() string { return p.apiKey }
func (p *OpenAIProvider) APIBase() string { return p.apiBase }
func (p *OpenAIProvider) AuthPrefix() string { return p.authPrefix }
func (p *OpenAIProvider) ProviderType() string { return p.providerType }
// Capabilities implements CapabilitiesAware for pipeline code-path selection.
func (p *OpenAIProvider) Capabilities() ProviderCapabilities {
return ProviderCapabilities{
Streaming: true,
ToolCalling: true,
StreamWithTools: true,
Thinking: !p.isOllamaEndpoint(),
Vision: true,
CacheControl: false,
MaxContextWindow: 128_000,
TokenizerID: "o200k_base",
}
}
// middlewareConfig builds a MiddlewareConfig from provider fields and the current request.
func (p *OpenAIProvider) middlewareConfig(model string, req ChatRequest) MiddlewareConfig {
return MiddlewareConfig{
Provider: p.name,
Model: model,
Caps: p.Capabilities(),
AuthType: "api_key",
APIBase: p.apiBase,
Options: req.Options,
}
}
// schemaProviderName returns the most specific provider identifier for schema normalization.
// Prefers providerType (from DB) over name for accurate profile matching.
func (p *OpenAIProvider) schemaProviderName() string {
if p.providerType != "" {
return p.providerType
}
return p.name
}
// resolveModel returns the model ID to use for a request.
// For OpenRouter, model IDs require a provider prefix (e.g. "anthropic/claude-sonnet-4-5-20250929").
// If the caller passes an unprefixed model, fall back to the provider's default.
// After alias resolution, checks the registry for forward-compat specs.
func (p *OpenAIProvider) resolveModel(model string) string {
if model == "" {
return p.defaultModel
}
if p.name == "openrouter" && !strings.Contains(model, "/") {
return p.defaultModel
}
// Trigger forward-compat resolution to cache specs for token counting.
// The model ID itself is unchanged — we don't rename models.
if p.registry != nil {
_ = p.registry.Resolve("openai", model)
}
return model
}