Files
goclaw/cmd/gateway_providers.go
T
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

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")
}