mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-04 20:13:25 +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>
630 lines
24 KiB
Go
630 lines
24 KiB
Go
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/config"
|
|
"github.com/nextlevelbuilder/goclaw/internal/oauth"
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
|
)
|
|
|
|
// loopbackAddr normalizes a gateway address for local connections.
|
|
// CLI processes on the same machine can't connect to 0.0.0.0 on some OSes.
|
|
func loopbackAddr(host string, port int) string {
|
|
if host == "" || host == "0.0.0.0" || host == "::" {
|
|
host = "127.0.0.1"
|
|
}
|
|
return net.JoinHostPort(host, strconv.Itoa(port))
|
|
}
|
|
|
|
func registerProviders(registry *providers.Registry, cfg *config.Config, modelReg providers.ModelRegistry) {
|
|
if cfg.Providers.Anthropic.APIKey != "" {
|
|
registry.Register(providers.NewAnthropicProvider(cfg.Providers.Anthropic.APIKey,
|
|
providers.WithAnthropicBaseURL(cfg.Providers.Anthropic.APIBase),
|
|
providers.WithAnthropicRegistry(modelReg)))
|
|
slog.Info("registered provider", "name", "anthropic")
|
|
}
|
|
|
|
if cfg.Providers.OpenAI.APIKey != "" {
|
|
registry.Register(providers.NewOpenAIProvider("openai", cfg.Providers.OpenAI.APIKey, cfg.Providers.OpenAI.APIBase, "gpt-4o").
|
|
WithRegistry(modelReg))
|
|
slog.Info("registered provider", "name", "openai")
|
|
}
|
|
|
|
if cfg.Providers.OpenRouter.APIKey != "" {
|
|
orProv := providers.NewOpenAIProvider("openrouter", cfg.Providers.OpenRouter.APIKey, "https://openrouter.ai/api/v1", "anthropic/claude-sonnet-4-5-20250929")
|
|
orProv.WithSiteInfo("https://goclaw.sh", "GoClaw")
|
|
registry.Register(orProv)
|
|
slog.Info("registered provider", "name", "openrouter")
|
|
}
|
|
|
|
if cfg.Providers.Groq.APIKey != "" {
|
|
registry.Register(providers.NewOpenAIProvider("groq", cfg.Providers.Groq.APIKey, "https://api.groq.com/openai/v1", "llama-3.3-70b-versatile"))
|
|
slog.Info("registered provider", "name", "groq")
|
|
}
|
|
|
|
if cfg.Providers.DeepSeek.APIKey != "" {
|
|
registry.Register(providers.NewOpenAIProvider("deepseek", cfg.Providers.DeepSeek.APIKey, "https://api.deepseek.com/v1", "deepseek-chat"))
|
|
slog.Info("registered provider", "name", "deepseek")
|
|
}
|
|
|
|
if cfg.Providers.Gemini.APIKey != "" {
|
|
registry.Register(providers.NewOpenAIProvider("gemini", cfg.Providers.Gemini.APIKey, "https://generativelanguage.googleapis.com/v1beta/openai", "gemini-2.0-flash"))
|
|
slog.Info("registered provider", "name", "gemini")
|
|
}
|
|
|
|
if cfg.Providers.Mistral.APIKey != "" {
|
|
registry.Register(providers.NewOpenAIProvider("mistral", cfg.Providers.Mistral.APIKey, "https://api.mistral.ai/v1", "mistral-large-latest"))
|
|
slog.Info("registered provider", "name", "mistral")
|
|
}
|
|
|
|
if cfg.Providers.XAI.APIKey != "" {
|
|
registry.Register(providers.NewOpenAIProvider("xai", cfg.Providers.XAI.APIKey, "https://api.x.ai/v1", "grok-3-mini"))
|
|
slog.Info("registered provider", "name", "xai")
|
|
}
|
|
|
|
if cfg.Providers.MiniMax.APIKey != "" {
|
|
base := cfg.Providers.MiniMax.APIBase
|
|
if base == "" {
|
|
base = store.MiniMaxDefaultAPIBase
|
|
}
|
|
registry.Register(providers.NewOpenAIProvider("minimax", cfg.Providers.MiniMax.APIKey, base, store.MiniMaxDefaultModel))
|
|
slog.Info("registered provider", "name", "minimax")
|
|
}
|
|
|
|
if cfg.Providers.Cohere.APIKey != "" {
|
|
registry.Register(providers.NewOpenAIProvider("cohere", cfg.Providers.Cohere.APIKey, "https://api.cohere.ai/compatibility/v1", "command-a"))
|
|
slog.Info("registered provider", "name", "cohere")
|
|
}
|
|
|
|
if cfg.Providers.Perplexity.APIKey != "" {
|
|
registry.Register(providers.NewOpenAIProvider("perplexity", cfg.Providers.Perplexity.APIKey, "https://api.perplexity.ai", "sonar-pro"))
|
|
slog.Info("registered provider", "name", "perplexity")
|
|
}
|
|
|
|
if cfg.Providers.DashScope.APIKey != "" {
|
|
registry.Register(providers.NewDashScopeProvider("dashscope", cfg.Providers.DashScope.APIKey, cfg.Providers.DashScope.APIBase, "qwen3-max"))
|
|
slog.Info("registered provider", "name", "dashscope")
|
|
}
|
|
|
|
if cfg.Providers.Bailian.APIKey != "" {
|
|
base := cfg.Providers.Bailian.APIBase
|
|
if base == "" {
|
|
base = "https://coding-intl.dashscope.aliyuncs.com/v1"
|
|
}
|
|
registry.Register(providers.NewOpenAIProvider("bailian", cfg.Providers.Bailian.APIKey, base, "qwen3.5-plus").
|
|
WithProviderType(store.ProviderBailian))
|
|
slog.Info("registered provider", "name", "bailian")
|
|
}
|
|
|
|
if cfg.Providers.Zai.APIKey != "" {
|
|
base := cfg.Providers.Zai.APIBase
|
|
if base == "" {
|
|
base = store.ZaiDefaultAPIBase
|
|
}
|
|
registry.Register(providers.NewOpenAIProvider("zai", cfg.Providers.Zai.APIKey, base, store.ZaiDefaultModel))
|
|
slog.Info("registered provider", "name", "zai")
|
|
}
|
|
|
|
if cfg.Providers.ZaiCoding.APIKey != "" {
|
|
base := cfg.Providers.ZaiCoding.APIBase
|
|
if base == "" {
|
|
base = store.ZaiCodingDefaultAPIBase
|
|
}
|
|
registry.Register(providers.NewOpenAIProvider("zai-coding", cfg.Providers.ZaiCoding.APIKey, base, store.ZaiDefaultModel))
|
|
slog.Info("registered provider", "name", "zai-coding")
|
|
}
|
|
|
|
// Local / self-hosted Ollama — gated on Host, no API key required.
|
|
// Uses the native Ollama Go client for proper options.num_ctx support.
|
|
if cfg.Providers.Ollama.Host != "" {
|
|
host := cfg.Providers.Ollama.Host
|
|
ctx5s, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
numCtx := providers.FetchOllamaModelContext(ctx5s, config.DockerLocalhost(host), "llama3.3", "")
|
|
cancel()
|
|
var numCtxPtr *int
|
|
if numCtx != providers.OllamaDefaultNumCtx {
|
|
numCtxPtr = &numCtx
|
|
}
|
|
registry.Register(providers.NewOllamaProvider("ollama", host, "llama3.3", numCtxPtr, nil))
|
|
slog.Info("registered provider", "name", "ollama")
|
|
}
|
|
|
|
// Ollama Cloud — API key required (generate at ollama.com/settings/keys).
|
|
// Uses the native Ollama Go client; the cloud endpoint is Ollama-native, not OpenAI-compat.
|
|
if cfg.Providers.OllamaCloud.APIKey != "" {
|
|
base := cfg.Providers.OllamaCloud.APIBase
|
|
if base == "" {
|
|
base = "https://ollama.com"
|
|
}
|
|
ctx5s, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
numCtx := providers.FetchOllamaModelContext(ctx5s, config.DockerLocalhost(base), "llama3.3", "")
|
|
cancel()
|
|
var numCtxPtr *int
|
|
if numCtx != providers.OllamaDefaultNumCtx {
|
|
numCtxPtr = &numCtx
|
|
}
|
|
registry.Register(providers.NewOllamaProvider("ollama-cloud", base, "llama3.3", numCtxPtr, nil))
|
|
slog.Info("registered provider", "name", "ollama-cloud")
|
|
}
|
|
|
|
// Novita AI — OpenAI-compatible endpoint.
|
|
if cfg.Providers.Novita.APIKey != "" {
|
|
base := cfg.Providers.Novita.APIBase
|
|
if base == "" {
|
|
base = store.NovitaDefaultAPIBase
|
|
}
|
|
registry.Register(providers.NewOpenAIProvider("novita", cfg.Providers.Novita.APIKey, base, store.NovitaDefaultModel))
|
|
slog.Info("registered provider", "name", "novita")
|
|
}
|
|
|
|
// BytePlus ModelArk — OpenAI-compatible (standard Bearer auth).
|
|
if cfg.Providers.BytePlus.APIKey != "" {
|
|
base := cfg.Providers.BytePlus.APIBase
|
|
if base == "" {
|
|
base = store.BytePlusDefaultAPIBase
|
|
}
|
|
prov := providers.NewOpenAIProvider("byteplus", cfg.Providers.BytePlus.APIKey, base, store.BytePlusDefaultModel)
|
|
prov.WithProviderType(store.ProviderBytePlus)
|
|
registry.Register(prov)
|
|
slog.Info("registered provider", "name", "byteplus")
|
|
}
|
|
|
|
// BytePlus ModelArk Coding Plan — separate endpoint for developer tools quota.
|
|
if cfg.Providers.BytePlusCoding.APIKey != "" {
|
|
base := cfg.Providers.BytePlusCoding.APIBase
|
|
if base == "" {
|
|
base = store.BytePlusCodingDefaultAPIBase
|
|
}
|
|
prov := providers.NewOpenAIProvider("byteplus-coding", cfg.Providers.BytePlusCoding.APIKey, base, store.BytePlusDefaultModel)
|
|
prov.WithProviderType(store.ProviderBytePlusCoding)
|
|
registry.Register(prov)
|
|
slog.Info("registered provider", "name", "byteplus-coding")
|
|
}
|
|
|
|
// Google Cloud Vertex AI — OAuth2 service account or Application Default Credentials.
|
|
// Registers when project_id + region are set. Credential sources (priority order):
|
|
// inline JSON (APIKey) → file path (CredentialsFile) → ADC.
|
|
if cfg.Providers.Vertex.ProjectID != "" && cfg.Providers.Vertex.Region != "" {
|
|
vcfg := providers.VertexConfig{
|
|
Name: "vertex",
|
|
CredentialsJSON: cfg.Providers.Vertex.APIKey,
|
|
CredentialsFile: cfg.Providers.Vertex.CredentialsFile,
|
|
ProjectID: cfg.Providers.Vertex.ProjectID,
|
|
Region: cfg.Providers.Vertex.Region,
|
|
DefaultModel: cfg.Providers.Vertex.Model,
|
|
}
|
|
prov, err := providers.NewVertexProviderWithTimeout(vcfg)
|
|
if err != nil {
|
|
slog.Warn("vertex: initialization failed", "error", err)
|
|
} else {
|
|
registry.Register(prov)
|
|
slog.Info("registered provider", "name", "vertex", "region", cfg.Providers.Vertex.Region, "project", cfg.Providers.Vertex.ProjectID)
|
|
}
|
|
}
|
|
|
|
registerClaudeCLIFromConfig(registry, cfg)
|
|
|
|
// ACP provider (config-based) — orchestrates any ACP-compatible agent binary
|
|
if cfg.Providers.ACP.Binary != "" {
|
|
registerACPFromConfig(registry, cfg.Providers.ACP, configuredShellDenyGroups(cfg))
|
|
}
|
|
}
|
|
|
|
// buildMCPServerLookup creates an MCPServerLookup from an MCPServerStore.
|
|
// Returns nil if mcpStore is nil.
|
|
func buildMCPServerLookup(mcpStore store.MCPServerStore) providers.MCPServerLookup {
|
|
if mcpStore == nil {
|
|
return nil
|
|
}
|
|
return func(ctx context.Context, agentID string) []providers.MCPServerEntry {
|
|
aid, err := uuid.Parse(agentID)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
accessible, err := mcpStore.ListAccessible(ctx, aid, "")
|
|
if err != nil {
|
|
slog.Warn("claude-cli: failed to list agent MCP servers", "agent_id", agentID, "error", err)
|
|
return nil
|
|
}
|
|
var entries []providers.MCPServerEntry
|
|
for _, info := range accessible {
|
|
srv := info.Server
|
|
if !srv.Enabled {
|
|
continue
|
|
}
|
|
entry := providers.MCPServerEntry{
|
|
Name: srv.Name,
|
|
Transport: srv.Transport,
|
|
Command: srv.Command,
|
|
URL: srv.URL,
|
|
Args: jsonToStringSlice(srv.Args),
|
|
Headers: jsonToStringMap(srv.Headers),
|
|
Env: jsonToStringMap(srv.Env),
|
|
}
|
|
entries = append(entries, entry)
|
|
}
|
|
return entries
|
|
}
|
|
}
|
|
|
|
// jsonToStringSlice converts a json.RawMessage to []string.
|
|
func jsonToStringSlice(data json.RawMessage) []string {
|
|
if len(data) == 0 {
|
|
return nil
|
|
}
|
|
var result []string
|
|
if err := json.Unmarshal(data, &result); err != nil {
|
|
return nil
|
|
}
|
|
return result
|
|
}
|
|
|
|
// jsonToStringMap converts a json.RawMessage to map[string]string.
|
|
func jsonToStringMap(data json.RawMessage) map[string]string {
|
|
if len(data) == 0 {
|
|
return nil
|
|
}
|
|
var result map[string]string
|
|
if err := json.Unmarshal(data, &result); err != nil {
|
|
return nil
|
|
}
|
|
return result
|
|
}
|
|
|
|
// registerProvidersFromDB loads providers from Postgres and registers them.
|
|
// DB providers are registered after config providers, so they take precedence (overwrite).
|
|
// gatewayAddr is used to inject GoClaw MCP bridge for Claude CLI providers.
|
|
// mcpStore is optional; when provided, per-agent MCP servers are injected into CLI config.
|
|
// cfg provides fallback api_base values from config/env when DB providers have none set.
|
|
func registerProvidersFromDB(registry *providers.Registry, provStore store.ProviderStore, secretStore store.ConfigSecretsStore, gatewayAddr, gatewayToken string, mcpStore store.MCPServerStore, cfg *config.Config, modelReg providers.ModelRegistry) {
|
|
dbProviders, err := provStore.ListAllProviders(context.Background())
|
|
if err != nil {
|
|
slog.Warn("failed to load providers from DB", "error", err)
|
|
return
|
|
}
|
|
for _, p := range dbProviders {
|
|
// Claude CLI doesn't need API key
|
|
if !p.Enabled {
|
|
continue
|
|
}
|
|
if p.ProviderType == store.ProviderClaudeCLI {
|
|
registerClaudeCLIFromDB(registry, p, gatewayAddr, gatewayToken, mcpStore, cfg)
|
|
continue
|
|
}
|
|
// ACP provider — no API key needed (agents manage their own auth).
|
|
if p.ProviderType == store.ProviderACP {
|
|
registerACPFromDB(registry, p, configuredShellDenyGroups(cfg))
|
|
continue
|
|
}
|
|
// Local Ollama requires no API key — handle before the key guard (same pattern as ClaudeCLI).
|
|
// api_base is stored with /v1 (normalized at write time), so no suffix appending needed.
|
|
if p.ProviderType == store.ProviderOllama {
|
|
host := p.APIBase
|
|
if host == "" {
|
|
host = "http://localhost:11434"
|
|
}
|
|
numCtx := resolveOllamaNumCtx(&p, config.DockerLocalhost(host), "")
|
|
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
|
|
}
|
|
// Vertex supports ADC (empty api_key) — handle before the generic key guard.
|
|
if p.ProviderType == store.ProviderVertex {
|
|
vsettings := store.ParseVertexProviderSettings(p.Settings)
|
|
if vsettings == nil {
|
|
slog.Warn("vertex: missing project_id/region in settings, skipping", "name", p.Name)
|
|
continue
|
|
}
|
|
vcfg := providers.VertexConfig{
|
|
Name: p.Name,
|
|
CredentialsJSON: p.APIKey,
|
|
ProjectID: vsettings.ProjectID,
|
|
Region: vsettings.Region,
|
|
DefaultModel: vsettings.Model,
|
|
APIBaseOverride: p.APIBase,
|
|
}
|
|
prov, err := providers.NewVertexProviderWithTimeout(vcfg)
|
|
if err != nil {
|
|
slog.Warn("vertex: init from DB failed", "name", p.Name, "error", err)
|
|
continue
|
|
}
|
|
registry.RegisterForTenant(p.TenantID, prov)
|
|
slog.Info("registered provider from DB", "name", p.Name, "type", "vertex", "region", vsettings.Region)
|
|
continue
|
|
}
|
|
|
|
if p.APIKey == "" {
|
|
continue
|
|
}
|
|
// Fall back to config/env api_base when DB provider has none set.
|
|
if p.APIBase == "" && cfg != nil {
|
|
if base := cfg.Providers.APIBaseForType(p.ProviderType); base != "" {
|
|
p.APIBase = base
|
|
slog.Info("provider api_base inherited from config", "name", p.Name, "api_base", base)
|
|
}
|
|
}
|
|
switch p.ProviderType {
|
|
case store.ProviderChatGPTOAuth:
|
|
ts := oauth.NewDBTokenSource(provStore, secretStore, p.Name).WithTenantID(p.TenantID)
|
|
codex := providers.NewCodexProvider(p.Name, ts, p.APIBase, "")
|
|
if oauthSettings := store.ParseChatGPTOAuthProviderSettings(p.Settings); oauthSettings != nil {
|
|
codex.WithRoutingDefaults(oauthSettings.CodexPool.Strategy, oauthSettings.CodexPool.ExtraProviderNames)
|
|
}
|
|
registry.RegisterForTenant(p.TenantID, codex)
|
|
case store.ProviderAnthropicNative:
|
|
registry.RegisterForTenant(p.TenantID, providers.NewAnthropicProvider(p.APIKey,
|
|
providers.WithAnthropicName(p.Name),
|
|
providers.WithAnthropicBaseURL(p.APIBase),
|
|
providers.WithAnthropicRegistry(modelReg)))
|
|
case store.ProviderDashScope:
|
|
registry.RegisterForTenant(p.TenantID, providers.NewDashScopeProvider(p.Name, p.APIKey, p.APIBase, ""))
|
|
case store.ProviderBailian:
|
|
base := p.APIBase
|
|
if base == "" {
|
|
base = "https://coding-intl.dashscope.aliyuncs.com/v1"
|
|
}
|
|
registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, "qwen3.5-plus").
|
|
WithProviderType(p.ProviderType))
|
|
case store.ProviderZai:
|
|
base := p.APIBase
|
|
if base == "" {
|
|
base = store.ZaiDefaultAPIBase
|
|
}
|
|
registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, store.ZaiDefaultModel))
|
|
case store.ProviderZaiCoding:
|
|
base := p.APIBase
|
|
if base == "" {
|
|
base = store.ZaiCodingDefaultAPIBase
|
|
}
|
|
registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, store.ZaiDefaultModel))
|
|
case store.ProviderOllamaCloud:
|
|
base := p.APIBase
|
|
if base == "" {
|
|
base = "https://ollama.com"
|
|
}
|
|
numCtx := resolveOllamaNumCtx(&p, base, p.APIKey)
|
|
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
|
|
if base == "" {
|
|
base = store.NovitaDefaultAPIBase
|
|
}
|
|
registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, store.NovitaDefaultModel))
|
|
case store.ProviderBytePlus:
|
|
base := p.APIBase
|
|
if base == "" {
|
|
base = store.BytePlusDefaultAPIBase
|
|
}
|
|
prov := providers.NewOpenAIProvider(p.Name, p.APIKey, base, store.BytePlusDefaultModel)
|
|
prov.WithProviderType(p.ProviderType)
|
|
registry.RegisterForTenant(p.TenantID, prov)
|
|
case store.ProviderBytePlusCoding:
|
|
base := p.APIBase
|
|
if base == "" {
|
|
base = store.BytePlusCodingDefaultAPIBase
|
|
}
|
|
prov := providers.NewOpenAIProvider(p.Name, p.APIKey, base, store.BytePlusDefaultModel)
|
|
prov.WithProviderType(p.ProviderType)
|
|
registry.RegisterForTenant(p.TenantID, prov)
|
|
case store.ProviderKimiCoding:
|
|
// Moonshot Kimi Coding requires a fixed User-Agent on every request.
|
|
// OpenAI-compatible wire shape otherwise.
|
|
base := p.APIBase
|
|
if base == "" {
|
|
base = store.KimiCodingDefaultAPIBase
|
|
}
|
|
prov := providers.NewOpenAIProvider(p.Name, p.APIKey, base, store.KimiCodingDefaultModel)
|
|
prov.WithProviderType(p.ProviderType)
|
|
prov.WithExtraHeaders(map[string]string{
|
|
"User-Agent": store.KimiCodingRequiredUserAgent,
|
|
})
|
|
registry.RegisterForTenant(p.TenantID, prov)
|
|
case store.ProviderAIMLAPI:
|
|
prov := providers.NewAIMLAPIProvider(p.Name, p.APIKey, p.APIBase)
|
|
prov.WithProviderType(p.ProviderType)
|
|
registry.RegisterForTenant(p.TenantID, prov)
|
|
default:
|
|
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")
|
|
}
|
|
registry.RegisterForTenant(p.TenantID, prov)
|
|
}
|
|
slog.Info("registered provider from DB", "name", p.Name)
|
|
}
|
|
}
|
|
|
|
func openAIProviderDefaults(providerType, apiBase string) (string, string) {
|
|
switch providerType {
|
|
case store.ProviderMiniMax:
|
|
if apiBase == "" {
|
|
apiBase = store.MiniMaxDefaultAPIBase
|
|
}
|
|
return apiBase, store.MiniMaxDefaultModel
|
|
default:
|
|
return apiBase, ""
|
|
}
|
|
}
|
|
|
|
// resolveOllamaNumCtx returns the num_ctx to use for an Ollama provider, or nil
|
|
// when the built-in default should be used (provider handles it internally).
|
|
// Priority:
|
|
// 1. User-configured num_ctx from provider settings JSONB (explicit override wins).
|
|
// 2. Value queried from Ollama /api/show for the provider's default model.
|
|
// 3. nil when neither is available (OllamaProvider omits options.num_ctx, using Ollama's default).
|
|
func resolveOllamaNumCtx(p *store.LLMProviderData, apiBase, apiKey string) *int {
|
|
if s := store.ParseOllamaSettings(p.Settings); s != nil {
|
|
return s.NumCtx
|
|
}
|
|
// Query the Ollama API for the model's native context length.
|
|
// Use a short timeout so startup is not blocked by a slow/absent Ollama server.
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
numCtx := providers.FetchOllamaModelContext(ctx, apiBase, "llama3.3", apiKey)
|
|
if numCtx != providers.OllamaDefaultNumCtx {
|
|
return &numCtx
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func registerClaudeCLIFromConfig(registry *providers.Registry, cfg *config.Config) {
|
|
if cfg == nil || cfg.Providers.ClaudeCLI.CLIPath == "" {
|
|
return
|
|
}
|
|
cliPath := cfg.Providers.ClaudeCLI.CLIPath
|
|
var opts []providers.ClaudeCLIOption
|
|
if cfg.Providers.ClaudeCLI.Model != "" {
|
|
opts = append(opts, providers.WithClaudeCLIModel(cfg.Providers.ClaudeCLI.Model))
|
|
}
|
|
if cfg.Providers.ClaudeCLI.BaseWorkDir != "" {
|
|
opts = append(opts, providers.WithClaudeCLIWorkDir(cfg.Providers.ClaudeCLI.BaseWorkDir))
|
|
}
|
|
if cfg.Providers.ClaudeCLI.PermMode != "" {
|
|
opts = append(opts, providers.WithClaudeCLIPermMode(cfg.Providers.ClaudeCLI.PermMode))
|
|
}
|
|
gatewayAddr := loopbackAddr(cfg.Gateway.Host, cfg.Gateway.Port)
|
|
mcpData := providers.BuildCLIMCPConfigData(cfg.Tools.McpServers, gatewayAddr, cfg.Gateway.Token)
|
|
opts = append(opts, providers.WithClaudeCLIMCPConfigData(mcpData))
|
|
opts = append(opts, providers.WithClaudeCLISecurityHooks(
|
|
cfg.Providers.ClaudeCLI.BaseWorkDir, true, configuredShellDenyPatterns(cfg)))
|
|
registry.Register(providers.NewClaudeCLIProvider(cliPath, opts...))
|
|
slog.Info("registered provider", "name", "claude-cli")
|
|
}
|
|
|
|
func registerClaudeCLIFromDB(registry *providers.Registry, p store.LLMProviderData, gatewayAddr, gatewayToken string, mcpStore store.MCPServerStore, cfg *config.Config) bool {
|
|
cliPath := p.APIBase // reuse APIBase field for CLI path
|
|
if cliPath == "" {
|
|
cliPath = "claude"
|
|
}
|
|
// Validate: only accept "claude" or absolute path
|
|
if cliPath != "claude" && !filepath.IsAbs(cliPath) {
|
|
slog.Warn("security.claude_cli: invalid path from DB, using default", "path", cliPath)
|
|
cliPath = "claude"
|
|
}
|
|
if _, err := exec.LookPath(cliPath); err != nil {
|
|
slog.Warn("claude-cli: binary not found, skipping", "path", cliPath, "error", err)
|
|
return false
|
|
}
|
|
var cliOpts []providers.ClaudeCLIOption
|
|
cliOpts = append(cliOpts, providers.WithClaudeCLIName(p.Name))
|
|
cliOpts = append(cliOpts, providers.WithClaudeCLISecurityHooks("", true, configuredShellDenyPatterns(cfg)))
|
|
if gatewayAddr != "" {
|
|
mcpData := providers.BuildCLIMCPConfigData(nil, gatewayAddr, gatewayToken)
|
|
mcpData.AgentMCPLookup = buildMCPServerLookup(mcpStore)
|
|
cliOpts = append(cliOpts, providers.WithClaudeCLIMCPConfigData(mcpData))
|
|
}
|
|
registry.RegisterForTenant(p.TenantID, providers.NewClaudeCLIProvider(cliPath, cliOpts...))
|
|
slog.Info("registered provider from DB", "name", p.Name)
|
|
return true
|
|
}
|
|
|
|
// registerACPFromConfig registers an ACP provider from config file settings.
|
|
func registerACPFromConfig(registry *providers.Registry, cfg config.ACPConfig, shellDenyGroups map[string]bool) {
|
|
if _, err := exec.LookPath(cfg.Binary); err != nil {
|
|
slog.Warn("acp: binary not found, skipping", "binary", cfg.Binary, "error", err)
|
|
return
|
|
}
|
|
idleTTL := 5 * time.Minute
|
|
if cfg.IdleTTL != "" {
|
|
if d, err := time.ParseDuration(cfg.IdleTTL); err == nil {
|
|
idleTTL = d
|
|
}
|
|
}
|
|
workDir := cfg.WorkDir
|
|
if workDir == "" {
|
|
workDir = defaultACPWorkDir()
|
|
}
|
|
var opts []providers.ACPOption
|
|
if cfg.Model != "" {
|
|
opts = append(opts, providers.WithACPModel(cfg.Model))
|
|
}
|
|
if cfg.PermMode != "" {
|
|
opts = append(opts, providers.WithACPPermMode(cfg.PermMode))
|
|
}
|
|
registry.Register(providers.NewACPProvider(
|
|
cfg.Binary, cfg.Args, workDir, idleTTL, tools.ResolveDenyPatterns(shellDenyGroups), opts...,
|
|
))
|
|
slog.Info("registered provider", "name", "acp", "binary", cfg.Binary)
|
|
}
|
|
|
|
// registerACPFromDB registers an ACP provider from a DB provider row.
|
|
func registerACPFromDB(registry *providers.Registry, p store.LLMProviderData, shellDenyGroups map[string]bool) {
|
|
binary := p.APIBase // repurpose api_base as binary path
|
|
if binary == "" {
|
|
slog.Warn("acp: no binary specified in DB provider", "name", p.Name)
|
|
return
|
|
}
|
|
if binary != "claude" && binary != "codex" && binary != "gemini" && !filepath.IsAbs(binary) {
|
|
slog.Warn("security.acp: invalid binary path from DB", "path", binary)
|
|
return
|
|
}
|
|
if _, err := exec.LookPath(binary); err != nil {
|
|
slog.Warn("acp: binary not found, skipping", "binary", binary, "error", err)
|
|
return
|
|
}
|
|
// Parse settings JSONB for extra config
|
|
var settings struct {
|
|
Args []string `json:"args"`
|
|
IdleTTL string `json:"idle_ttl"`
|
|
PermMode string `json:"perm_mode"`
|
|
WorkDir string `json:"work_dir"`
|
|
}
|
|
if p.Settings != nil {
|
|
if err := json.Unmarshal(p.Settings, &settings); err != nil {
|
|
slog.Warn("acp: invalid settings JSON, using defaults", "name", p.Name, "error", err)
|
|
}
|
|
}
|
|
idleTTL := 5 * time.Minute
|
|
if settings.IdleTTL != "" {
|
|
if d, err := time.ParseDuration(settings.IdleTTL); err == nil {
|
|
idleTTL = d
|
|
}
|
|
}
|
|
workDir := settings.WorkDir
|
|
if workDir == "" {
|
|
workDir = defaultACPWorkDir()
|
|
}
|
|
registry.RegisterForTenant(p.TenantID, providers.NewACPProvider(
|
|
binary, settings.Args, workDir, idleTTL, tools.ResolveDenyPatterns(shellDenyGroups),
|
|
providers.WithACPName(p.Name),
|
|
providers.WithACPModel(p.Name),
|
|
))
|
|
slog.Info("registered provider from DB", "name", p.Name, "type", "acp")
|
|
}
|
|
|
|
func configuredShellDenyGroups(cfg *config.Config) map[string]bool {
|
|
if cfg == nil {
|
|
return nil
|
|
}
|
|
return cfg.ShellDenyGroupsSnapshot()
|
|
}
|
|
|
|
func configuredShellDenyPatterns(cfg *config.Config) []*regexp.Regexp {
|
|
return tools.ResolveDenyPatterns(configuredShellDenyGroups(cfg))
|
|
}
|
|
|
|
// defaultACPWorkDir returns the default workspace directory for ACP agents.
|
|
func defaultACPWorkDir() string {
|
|
return filepath.Join(config.ResolvedDataDirFromEnv(), "acp-workspaces")
|
|
}
|