Files
goclaw/cmd/gateway_providers.go
T
Duc Nguyen dc51018563 fix: subagent provider routing + api_base fallback (#262)
* fix(subagent): inherit parent agent's provider instead of alphabetical fallback

Subagents previously used a fixed provider (alphabetically first from the
registry, often "anthropic") regardless of which provider the parent agent
used. This caused invalid combos like anthropic/glm-5 when a zai-coding
agent spawned subagents.

- Pass provider registry to SubagentManager for runtime resolution
- Inject parent provider name into context (WithParentProvider)
- Resolve activeProvider from parent context before LLM call
- Fix trace spans to show actual resolved provider, not default

* fix(providers): api_base fallback from config/env for DB providers

DB providers with empty api_base now inherit from config/env vars
(e.g., GOCLAW_ANTHROPIC_BASE_URL). Prevents proxy API keys from being
sent to the real provider API endpoint.

- Add APIBaseForType() method on ProvidersConfig
- registerProvidersFromDB falls back to config when api_base is empty
- ProvidersHandler uses resolveAPIBase() for model listing
- Add api_base, display_name, settings to provider validation whitelist

* fix(tracing): pass resolved provider name to subagent span emitters

- emitSubagentSpanStart now accepts providerName param instead of
  reading sm.provider.Name() — ensures root subagent span reflects
  the inherited parent provider, not the fallback default
- registerInMemory now uses resolveAPIBase() so DB providers with
  empty api_base inherit the config/env fallback (same as startup path)

---------

Co-authored-by: viettranx <viettranx@gmail.com>
2026-03-18 22:40:49 +07:00

430 lines
16 KiB
Go

package cmd
import (
"context"
"encoding/json"
"log/slog"
"net"
"os/exec"
"path/filepath"
"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) {
if cfg.Providers.Anthropic.APIKey != "" {
registry.Register(providers.NewAnthropicProvider(cfg.Providers.Anthropic.APIKey,
providers.WithAnthropicBaseURL(cfg.Providers.Anthropic.APIBase)))
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"))
slog.Info("registered provider", "name", "openai")
}
if cfg.Providers.OpenRouter.APIKey != "" {
registry.Register(providers.NewOpenAIProvider("openrouter", cfg.Providers.OpenRouter.APIKey, "https://openrouter.ai/api/v1", "anthropic/claude-sonnet-4-5-20250929"))
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 != "" {
registry.Register(providers.NewOpenAIProvider("minimax", cfg.Providers.MiniMax.APIKey, "https://api.minimax.io/v1", "MiniMax-M2.5").
WithChatPath("/text/chatcompletion_v2"))
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"))
slog.Info("registered provider", "name", "bailian")
}
if cfg.Providers.Zai.APIKey != "" {
base := cfg.Providers.Zai.APIBase
if base == "" {
base = "https://api.z.ai/api/paas/v4"
}
registry.Register(providers.NewOpenAIProvider("zai", cfg.Providers.Zai.APIKey, base, "glm-5"))
slog.Info("registered provider", "name", "zai")
}
if cfg.Providers.ZaiCoding.APIKey != "" {
base := cfg.Providers.ZaiCoding.APIBase
if base == "" {
base = "https://api.z.ai/api/coding/paas/v4"
}
registry.Register(providers.NewOpenAIProvider("zai-coding", cfg.Providers.ZaiCoding.APIKey, base, "glm-5"))
slog.Info("registered provider", "name", "zai-coding")
}
// Local / self-hosted Ollama — gated on Host, no API key required.
// Ollama's OpenAI-compat endpoint accepts any non-empty Bearer value.
if cfg.Providers.Ollama.Host != "" {
host := cfg.Providers.Ollama.Host
registry.Register(providers.NewOpenAIProvider("ollama", "ollama", host+"/v1", "llama3.3"))
slog.Info("registered provider", "name", "ollama")
}
// Ollama Cloud — API key required (generate at ollama.com/settings/keys).
if cfg.Providers.OllamaCloud.APIKey != "" {
base := cfg.Providers.OllamaCloud.APIBase
if base == "" {
base = "https://ollama.com/v1"
}
registry.Register(providers.NewOpenAIProvider("ollama-cloud", cfg.Providers.OllamaCloud.APIKey, base, "llama3.3"))
slog.Info("registered provider", "name", "ollama-cloud")
}
// Claude CLI provider (subscription-based, no API key needed)
if cfg.Providers.ClaudeCLI.CLIPath != "" {
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))
}
// Build per-session MCP config: external MCP servers + GoClaw bridge
gatewayAddr := loopbackAddr(cfg.Gateway.Host, cfg.Gateway.Port)
mcpData := providers.BuildCLIMCPConfigData(cfg.Tools.McpServers, gatewayAddr, cfg.Gateway.Token)
opts = append(opts, providers.WithClaudeCLIMCPConfigData(mcpData))
// Enable GoClaw security hooks (shell deny patterns, path restrictions)
opts = append(opts, providers.WithClaudeCLISecurityHooks(
cfg.Providers.ClaudeCLI.BaseWorkDir, true))
registry.Register(providers.NewClaudeCLIProvider(cliPath, opts...))
slog.Info("registered provider", "name", "claude-cli")
}
// ACP provider (config-based) — orchestrates any ACP-compatible agent binary
if cfg.Providers.ACP.Binary != "" {
registerACPFromConfig(registry, cfg.Providers.ACP)
}
}
// 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) {
ctx := context.Background()
dbProviders, err := provStore.ListProviders(ctx)
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 {
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)
continue
}
var cliOpts []providers.ClaudeCLIOption
cliOpts = append(cliOpts, providers.WithClaudeCLISecurityHooks("", true))
if gatewayAddr != "" {
mcpData := providers.BuildCLIMCPConfigData(nil, gatewayAddr, gatewayToken)
mcpData.AgentMCPLookup = buildMCPServerLookup(mcpStore)
cliOpts = append(cliOpts, providers.WithClaudeCLIMCPConfigData(mcpData))
}
registry.Register(providers.NewClaudeCLIProvider(cliPath, cliOpts...))
slog.Info("registered provider from DB", "name", p.Name)
continue
}
// ACP provider — no API key needed (agents manage their own auth).
if p.ProviderType == store.ProviderACP {
registerACPFromDB(registry, p)
continue
}
// Local Ollama requires no API key — handle before the key guard (same pattern as ClaudeCLI).
if p.ProviderType == store.ProviderOllama {
host := p.APIBase
if host == "" {
host = "http://localhost:11434"
}
registry.Register(providers.NewOpenAIProvider(p.Name, "ollama", host+"/v1", "llama3.3"))
slog.Info("registered provider from DB", "name", p.Name)
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)
registry.Register(providers.NewCodexProvider(p.Name, ts, p.APIBase, ""))
case store.ProviderAnthropicNative:
registry.Register(providers.NewAnthropicProvider(p.APIKey,
providers.WithAnthropicBaseURL(p.APIBase)))
case store.ProviderDashScope:
registry.Register(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.Register(providers.NewOpenAIProvider(p.Name, p.APIKey, base, "qwen3.5-plus"))
case store.ProviderZai:
base := p.APIBase
if base == "" {
base = "https://api.z.ai/api/paas/v4"
}
registry.Register(providers.NewOpenAIProvider(p.Name, p.APIKey, base, "glm-5"))
case store.ProviderZaiCoding:
base := p.APIBase
if base == "" {
base = "https://api.z.ai/api/coding/paas/v4"
}
registry.Register(providers.NewOpenAIProvider(p.Name, p.APIKey, base, "glm-5"))
case store.ProviderOllamaCloud:
base := p.APIBase
if base == "" {
base = "https://ollama.com/v1"
}
registry.Register(providers.NewOpenAIProvider(p.Name, p.APIKey, base, "llama3.3"))
case store.ProviderSuno:
// Suno is a media-only provider (music gen). Register as OpenAI-compat
// so credentialProvider interface works for API key/base extraction.
base := p.APIBase
if base == "" {
base = "https://api.sunoapi.org"
}
prov := providers.NewOpenAIProvider(p.Name, p.APIKey, base, "")
prov.WithProviderType(p.ProviderType)
registry.Register(prov)
default:
prov := providers.NewOpenAIProvider(p.Name, p.APIKey, p.APIBase, "")
prov.WithProviderType(p.ProviderType)
if p.ProviderType == store.ProviderMiniMax {
prov.WithChatPath("/text/chatcompletion_v2")
}
registry.Register(prov)
}
slog.Info("registered provider from DB", "name", p.Name)
}
}
// registerACPFromConfig registers an ACP provider from config file settings.
func registerACPFromConfig(registry *providers.Registry, cfg config.ACPConfig) {
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.DefaultDenyPatterns(), 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) {
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.Register(providers.NewACPProvider(
binary, settings.Args, workDir, idleTTL, tools.DefaultDenyPatterns(),
providers.WithACPModel(p.Name),
))
slog.Info("registered provider from DB", "name", p.Name, "type", "acp")
}
// defaultACPWorkDir returns the default workspace directory for ACP agents.
func defaultACPWorkDir() string {
return filepath.Join(config.ResolvedDataDirFromEnv(), "acp-workspaces")
}