mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 02:15:57 +00:00
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>
225 lines
8.0 KiB
Go
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
|
|
}
|