Files
Thibault Jaigu 32eb2328f3 feat(provider): add Requesty OpenAI-compatible provider (#1579)
Register Requesty (https://router.requesty.ai/v1) next to OpenRouter on the
existing OpenAI-compatible transport: config and env vars
(GOCLAW_REQUESTY_API_KEY, GOCLAW_REQUESTY_BASE_URL), secret masking, DB and
in-memory registration, CLI setup, doctor, placeholder provider, OpenAPI enum,
Web/Desktop provider lists and docs.

The models list merges Requesty managed policies (GET /models/managed) with
the key's catalog from GET /models.
2026-09-26 03:33:33 +07:00

560 lines
17 KiB
Go

package config
import (
"crypto/sha256"
"encoding/json"
"fmt"
"log/slog"
"net"
"net/netip"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/titanous/json5"
)
const GatewayAllowInsecureNoAuthEnv = "GOCLAW_ALLOW_INSECURE_NO_AUTH"
// GatewayNoAuthFallbackAllowed reports whether empty-token gateway auth may
// run in local/dev compatibility mode.
func GatewayNoAuthFallbackAllowed(g GatewayConfig) bool {
if strings.TrimSpace(g.Token) != "" {
return false
}
if insecureNoAuthOptIn() {
return true
}
return isLoopbackGatewayHost(g.Host)
}
// ValidateGatewayAuth fails configurations that would expose the gateway
// without any bearer token.
func ValidateGatewayAuth(g GatewayConfig) error {
if strings.TrimSpace(g.Token) != "" || GatewayNoAuthFallbackAllowed(g) {
return nil
}
return fmt.Errorf("gateway token is required when GOCLAW_HOST=%q; set GOCLAW_GATEWAY_TOKEN or explicit %s=1 for local development only", g.Host, GatewayAllowInsecureNoAuthEnv)
}
func insecureNoAuthOptIn() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(GatewayAllowInsecureNoAuthEnv))) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
func isLoopbackGatewayHost(host string) bool {
host = strings.TrimSpace(host)
if host == "" {
return false
}
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
host = strings.Trim(host, "[]")
if strings.EqualFold(host, "localhost") {
return true
}
addr, err := netip.ParseAddr(host)
return err == nil && addr.IsLoopback()
}
func parseEnvBool(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
// Default returns a Config with sensible defaults.
func Default() *Config {
return &Config{
DataDir: "~/.goclaw/data",
Agents: AgentsConfig{
Defaults: AgentDefaults{
Workspace: "~/.goclaw/workspace",
RestrictToWorkspace: true,
Provider: "anthropic",
Model: "claude-sonnet-4-5-20250929",
MaxTokens: DefaultMaxTokens,
Temperature: DefaultTemperature,
MaxToolIterations: DefaultMaxIterations,
MaxToolCalls: 25,
ContextWindow: DefaultContextWindow,
Subagents: &SubagentsConfig{
MaxConcurrent: 20,
MaxSpawnDepth: 1,
},
},
},
Channels: ChannelsConfig{
Telegram: TelegramConfig{
ReactionLevel: "full",
},
Discord: DiscordConfig{
HistoryLimit: 200,
},
},
Gateway: GatewayConfig{
Host: "0.0.0.0",
Port: 18790,
MaxMessageChars: DefaultMaxMessageChars,
RateLimitRPM: 20,
},
Tools: ToolsConfig{
Browser: BrowserToolConfig{
Enabled: true,
Headless: true,
CookieSyncEnabled: true,
},
ExecApproval: ExecApprovalCfg{
Security: "full",
Ask: "off",
},
RateLimitPerHour: 150,
},
Skills: SkillsConfig{
MaxUploadSizeMB: DefaultSkillMaxUploadSizeMB,
},
Sessions: SessionsConfig{},
}
}
// Load reads config from a JSON file, then overlays env vars.
func Load(path string) (*Config, error) {
cfg := Default()
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
cfg.applyEnvOverrides()
return cfg, nil
}
return nil, fmt.Errorf("read config: %w", err)
}
if err := json5.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
cfg.applyEnvOverrides()
return cfg, nil
}
// applyEnvOverrides overlays env vars onto the config.
// Env vars take precedence over file values.
func (c *Config) applyEnvOverrides() {
envStr := func(key string, dst *string) {
if v := os.Getenv(key); v != "" {
*dst = v
}
}
envStr("GOCLAW_ANTHROPIC_API_KEY", &c.Providers.Anthropic.APIKey)
envStr("GOCLAW_ANTHROPIC_BASE_URL", &c.Providers.Anthropic.APIBase)
envStr("GOCLAW_OPENAI_API_KEY", &c.Providers.OpenAI.APIKey)
envStr("GOCLAW_OPENAI_BASE_URL", &c.Providers.OpenAI.APIBase)
envStr("GOCLAW_ATLASCLOUD_API_KEY", &c.Providers.AtlasCloud.APIKey)
envStr("GOCLAW_ATLASCLOUD_BASE_URL", &c.Providers.AtlasCloud.APIBase)
envStr("GOCLAW_API_ROUTE_API_KEY", &c.Providers.APIRoute.APIKey)
envStr("GOCLAW_API_ROUTE_BASE_URL", &c.Providers.APIRoute.APIBase)
envStr("GOCLAW_REQUESTY_API_KEY", &c.Providers.Requesty.APIKey)
envStr("GOCLAW_REQUESTY_BASE_URL", &c.Providers.Requesty.APIBase)
envStr("GOCLAW_OPENROUTER_API_KEY", &c.Providers.OpenRouter.APIKey)
envStr("GOCLAW_GROQ_API_KEY", &c.Providers.Groq.APIKey)
envStr("GOCLAW_DEEPSEEK_API_KEY", &c.Providers.DeepSeek.APIKey)
envStr("GOCLAW_GEMINI_API_KEY", &c.Providers.Gemini.APIKey)
envStr("GOCLAW_MISTRAL_API_KEY", &c.Providers.Mistral.APIKey)
envStr("GOCLAW_XAI_API_KEY", &c.Providers.XAI.APIKey)
envStr("GOCLAW_MINIMAX_API_KEY", &c.Providers.MiniMax.APIKey)
envStr("GOCLAW_COHERE_API_KEY", &c.Providers.Cohere.APIKey)
envStr("GOCLAW_PERPLEXITY_API_KEY", &c.Providers.Perplexity.APIKey)
envStr("GOCLAW_DASHSCOPE_API_KEY", &c.Providers.DashScope.APIKey)
envStr("GOCLAW_BAILIAN_API_KEY", &c.Providers.Bailian.APIKey)
envStr("GOCLAW_ZAI_API_KEY", &c.Providers.Zai.APIKey)
envStr("GOCLAW_ZAI_CODING_API_KEY", &c.Providers.ZaiCoding.APIKey)
envStr("GOCLAW_OLLAMA_HOST", &c.Providers.Ollama.Host)
envStr("GOCLAW_OLLAMA_CLOUD_API_KEY", &c.Providers.OllamaCloud.APIKey)
envStr("GOCLAW_OLLAMA_CLOUD_API_BASE", &c.Providers.OllamaCloud.APIBase)
// Google Cloud Vertex AI (OAuth2 service account + ADC).
// APIKey may hold inline SA JSON; CredentialsFile is a path to SA JSON.
// If both empty, ADC (GOOGLE_APPLICATION_CREDENTIALS / gcloud / GCE metadata) is used.
envStr("GOCLAW_VERTEX_API_KEY", &c.Providers.Vertex.APIKey)
envStr("GOCLAW_VERTEX_CREDENTIALS_FILE", &c.Providers.Vertex.CredentialsFile)
envStr("GOCLAW_VERTEX_PROJECT_ID", &c.Providers.Vertex.ProjectID)
envStr("GOCLAW_VERTEX_REGION", &c.Providers.Vertex.Region)
envStr("GOCLAW_VERTEX_MODEL", &c.Providers.Vertex.Model)
envStr("GOCLAW_GATEWAY_TOKEN", &c.Gateway.Token)
envStr("GOCLAW_MCP_SERVER_TOKEN", &c.Gateway.MCPServerToken)
envStr("GOCLAW_TELEGRAM_TOKEN", &c.Channels.Telegram.Token)
envStr("GOCLAW_DISCORD_TOKEN", &c.Channels.Discord.Token)
envStr("GOCLAW_ZALO_TOKEN", &c.Channels.Zalo.Token)
envStr("GOCLAW_LARK_APP_ID", &c.Channels.Feishu.AppID)
envStr("GOCLAW_LARK_APP_SECRET", &c.Channels.Feishu.AppSecret)
envStr("GOCLAW_LARK_ENCRYPT_KEY", &c.Channels.Feishu.EncryptKey)
envStr("GOCLAW_LARK_VERIFICATION_TOKEN", &c.Channels.Feishu.VerificationToken)
// WhatsApp no longer needs bridge_url — runs natively via whatsmeow.
envStr("GOCLAW_SLACK_BOT_TOKEN", &c.Channels.Slack.BotToken)
envStr("GOCLAW_SLACK_APP_TOKEN", &c.Channels.Slack.AppToken)
envStr("GOCLAW_SLACK_USER_TOKEN", &c.Channels.Slack.UserToken)
// TTS secrets
envStr("GOCLAW_TTS_OPENAI_API_KEY", &c.Tts.OpenAI.APIKey)
envStr("GOCLAW_TTS_ELEVENLABS_API_KEY", &c.Tts.ElevenLabs.APIKey)
envStr("GOCLAW_TTS_MINIMAX_API_KEY", &c.Tts.MiniMax.APIKey)
envStr("GOCLAW_TTS_MINIMAX_GROUP_ID", &c.Tts.MiniMax.GroupID)
// Auto-enable channels if credentials are provided via env
if c.Channels.Telegram.Token != "" {
c.Channels.Telegram.Enabled = true
}
if c.Channels.Discord.Token != "" {
c.Channels.Discord.Enabled = true
}
if c.Channels.Zalo.Token != "" {
c.Channels.Zalo.Enabled = true
}
if c.Channels.Feishu.AppID != "" && c.Channels.Feishu.AppSecret != "" {
c.Channels.Feishu.Enabled = true
}
// WhatsApp is enabled via config or DB instances (no bridge_url needed).
if c.Channels.Slack.BotToken != "" && c.Channels.Slack.AppToken != "" {
c.Channels.Slack.Enabled = true
}
// Claude CLI provider
envStr("GOCLAW_CLAUDE_CLI_PATH", &c.Providers.ClaudeCLI.CLIPath)
envStr("GOCLAW_CLAUDE_CLI_MODEL", &c.Providers.ClaudeCLI.Model)
envStr("GOCLAW_CLAUDE_CLI_WORK_DIR", &c.Providers.ClaudeCLI.BaseWorkDir)
// Default provider/model: env is fallback only (applied when config has no value).
// The onboard wizard sets these in .env for initial bootstrap; once the user
// saves a provider/model via the Dashboard, the config-file value wins.
envFallback := func(key string, dst *string) {
if *dst == "" {
if v := os.Getenv(key); v != "" {
*dst = v
}
}
}
envFallback("GOCLAW_PROVIDER", &c.Agents.Defaults.Provider)
envFallback("GOCLAW_MODEL", &c.Agents.Defaults.Model)
// Data directory, workspace & sessions
envStr("GOCLAW_DATA_DIR", &c.DataDir)
envStr("GOCLAW_WORKSPACE", &c.Agents.Defaults.Workspace)
// Gateway host/port
envStr("GOCLAW_HOST", &c.Gateway.Host)
if v := os.Getenv("GOCLAW_PORT"); v != "" {
if port, err := strconv.Atoi(v); err == nil && port > 0 {
c.Gateway.Port = port
}
}
if v := os.Getenv("GOCLAW_SKILLS_MAX_UPLOAD_SIZE_MB"); v != "" {
if mb, err := strconv.Atoi(v); err == nil {
c.Skills.MaxUploadSizeMB = ClampSkillMaxUploadSizeMB(mb)
}
}
// Webhook agent-run timeouts (seconds). Bounds (default 600, cap 3600) are
// applied at consumption via webhooks.ResolveTimeoutSec.
if v := os.Getenv("GOCLAW_WEBHOOK_ASYNC_TIMEOUT_SEC"); v != "" {
if sec, err := strconv.Atoi(v); err == nil && sec > 0 {
c.Gateway.WebhookAsyncTimeoutSec = sec
}
}
if v := os.Getenv("GOCLAW_WEBHOOK_SYNC_TIMEOUT_SEC"); v != "" {
if sec, err := strconv.Atoi(v); err == nil && sec > 0 {
c.Gateway.WebhookSyncTimeoutSec = sec
}
}
envBoolPtr := func(key string, dst **bool) {
if v := os.Getenv(key); v != "" {
b := parseEnvBool(v)
*dst = &b
}
}
envBool := func(key string, dst *bool) {
if v := os.Getenv(key); v != "" {
*dst = parseEnvBool(v)
}
}
// Webhook internal streaming toggle (default true; nil → on via webhooks.ResolveStream).
envBoolPtr("GOCLAW_WEBHOOK_STREAM", &c.Gateway.WebhookStream)
envBoolPtr("GOCLAW_SKILLS_SLASH_COMMANDS_ENABLED", &c.Skills.SlashCommands.Enabled)
envBoolPtr("GOCLAW_SKILLS_SLASH_COMMANDS_SUGGEST_NOT_FOUND", &c.Skills.SlashCommands.SuggestNotFound)
envBool("GOCLAW_SKILLS_SLASH_COMMANDS_PARTIAL_MATCHING", &c.Skills.SlashCommands.PartialMatching)
envStr("GOCLAW_SKILLS_SLASH_COMMANDS_PREFIX", &c.Skills.SlashCommands.Prefix)
// Database
envStr("GOCLAW_POSTGRES_DSN", &c.Database.PostgresDSN)
envStr("GOCLAW_REDIS_DSN", &c.Database.RedisDSN)
envStr("GOCLAW_STORAGE_BACKEND", &c.Database.StorageBackend)
envStr("GOCLAW_SQLITE_PATH", &c.Database.SQLitePath)
// Deprecation warning for GOCLAW_MODE (removed — PostgreSQL is always active)
if v := os.Getenv("GOCLAW_MODE"); v != "" {
slog.Warn("GOCLAW_MODE is deprecated; managed mode is now the only mode", "value", v)
}
// Telemetry
envStr("GOCLAW_TELEMETRY_ENDPOINT", &c.Telemetry.Endpoint)
envStr("GOCLAW_TELEMETRY_PROTOCOL", &c.Telemetry.Protocol)
envStr("GOCLAW_TELEMETRY_SERVICE_NAME", &c.Telemetry.ServiceName)
if v := os.Getenv("GOCLAW_TELEMETRY_ENABLED"); v != "" {
c.Telemetry.Enabled = v == "true" || v == "1"
}
if v := os.Getenv("GOCLAW_TELEMETRY_INSECURE"); v != "" {
c.Telemetry.Insecure = v == "true" || v == "1"
}
// Owner IDs from env (comma-separated, whitespace-trimmed)
if v := os.Getenv("GOCLAW_OWNER_IDS"); v != "" {
var ids []string
for id := range strings.SplitSeq(v, ",") {
if trimmed := strings.TrimSpace(id); trimmed != "" {
ids = append(ids, trimmed)
}
}
c.Gateway.OwnerIDs = ids
}
// Allowed origins from env (comma-separated, whitespace-trimmed)
if v := os.Getenv("GOCLAW_ALLOWED_ORIGINS"); v != "" {
var origins []string
for origin := range strings.SplitSeq(v, ",") {
if trimmed := strings.TrimSpace(origin); trimmed != "" {
origins = append(origins, trimmed)
}
}
c.Gateway.AllowedOrigins = origins
}
// Trusted MCP server hosts from env (comma-separated, whitespace-trimmed).
// These hosts are exempt from the private-IP SSRF block when registering MCP
// servers (e.g. self-hosted MCP on a private network).
if v := os.Getenv("GOCLAW_MCP_ALLOWED_HOSTS"); v != "" {
var hosts []string
for h := range strings.SplitSeq(v, ",") {
if trimmed := strings.TrimSpace(h); trimmed != "" {
hosts = append(hosts, trimmed)
}
}
c.Gateway.MCPAllowedHosts = hosts
}
// Tailscale (tsnet)
envStr("GOCLAW_TSNET_HOSTNAME", &c.Tailscale.Hostname)
envStr("GOCLAW_TSNET_AUTH_KEY", &c.Tailscale.AuthKey)
envStr("GOCLAW_TSNET_DIR", &c.Tailscale.StateDir)
// Sandbox (for Docker-compose sandbox overlay)
ensureSandbox := func() {
if c.Agents.Defaults.Sandbox == nil {
c.Agents.Defaults.Sandbox = &SandboxConfig{}
}
}
if v := os.Getenv("GOCLAW_SANDBOX_MODE"); v != "" {
ensureSandbox()
c.Agents.Defaults.Sandbox.Mode = v
}
if v := os.Getenv("GOCLAW_SANDBOX_IMAGE"); v != "" {
ensureSandbox()
c.Agents.Defaults.Sandbox.Image = v
}
if v := os.Getenv("GOCLAW_SANDBOX_WORKSPACE_ACCESS"); v != "" {
ensureSandbox()
c.Agents.Defaults.Sandbox.WorkspaceAccess = v
}
if v := os.Getenv("GOCLAW_SANDBOX_SCOPE"); v != "" {
ensureSandbox()
c.Agents.Defaults.Sandbox.Scope = v
}
if v := os.Getenv("GOCLAW_SANDBOX_MEMORY_MB"); v != "" {
ensureSandbox()
if mb, err := strconv.Atoi(v); err == nil && mb > 0 {
c.Agents.Defaults.Sandbox.MemoryMB = mb
}
}
if v := os.Getenv("GOCLAW_SANDBOX_CPUS"); v != "" {
ensureSandbox()
if cpus, err := strconv.ParseFloat(v, 64); err == nil && cpus > 0 {
c.Agents.Defaults.Sandbox.CPUs = cpus
}
}
if v := os.Getenv("GOCLAW_SANDBOX_TIMEOUT_SEC"); v != "" {
ensureSandbox()
if sec, err := strconv.Atoi(v); err == nil && sec > 0 {
c.Agents.Defaults.Sandbox.TimeoutSec = sec
}
}
if v := os.Getenv("GOCLAW_SANDBOX_NETWORK"); v != "" {
ensureSandbox()
c.Agents.Defaults.Sandbox.NetworkEnabled = v == "true" || v == "1"
}
// Browser (for Docker-compose browser sidecar overlay)
envStr("GOCLAW_BROWSER_REMOTE_URL", &c.Tools.Browser.RemoteURL)
envStr("GOCLAW_BROWSER_BACKEND", &c.Tools.Browser.Backend)
if c.Tools.Browser.RemoteURL != "" {
c.Tools.Browser.Enabled = true
}
// Cron job execution
envStr("GOCLAW_CRON_JOB_TIMEOUT", &c.Cron.JobTimeout)
}
// Save writes the config to a JSON file.
func Save(path string, cfg *Config) error {
cfg.mu.RLock()
defer cfg.mu.RUnlock()
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
return os.WriteFile(path, data, 0600)
}
// Hash returns a SHA-256 hash of the config for optimistic concurrency.
func (c *Config) Hash() string {
c.mu.RLock()
defer c.mu.RUnlock()
data, _ := json.Marshal(c)
h := sha256.Sum256(data)
return fmt.Sprintf("%x", h[:8])
}
// ResolvedDataDir returns the expanded data directory path.
func (c *Config) ResolvedDataDir() string {
c.mu.RLock()
defer c.mu.RUnlock()
return ExpandHome(c.DataDir)
}
// ResolvedDataDirFromEnv returns the data dir from GOCLAW_DATA_DIR env or default.
// Use this in packages that don't have access to a Config instance.
func ResolvedDataDirFromEnv() string {
if v := os.Getenv("GOCLAW_DATA_DIR"); v != "" {
return ExpandHome(v)
}
return ExpandHome("~/.goclaw/data")
}
// WorkspacePath returns the expanded workspace path.
func (c *Config) WorkspacePath() string {
c.mu.RLock()
defer c.mu.RUnlock()
return ExpandHome(c.Agents.Defaults.Workspace)
}
// ResolveAgent returns the effective config for a given agent ID,
// merging defaults with per-agent overrides.
func (c *Config) ResolveAgent(agentID string) AgentDefaults {
c.mu.RLock()
defer c.mu.RUnlock()
d := c.Agents.Defaults
if spec, ok := c.Agents.List[agentID]; ok {
if spec.Provider != "" {
d.Provider = spec.Provider
}
if spec.Model != "" {
d.Model = spec.Model
}
if spec.MaxTokens > 0 {
d.MaxTokens = spec.MaxTokens
}
if spec.Temperature > 0 {
d.Temperature = spec.Temperature
}
if spec.MaxToolIterations > 0 {
d.MaxToolIterations = spec.MaxToolIterations
}
if spec.ContextWindow > 0 {
d.ContextWindow = spec.ContextWindow
}
if spec.MaxToolCalls > 0 {
d.MaxToolCalls = spec.MaxToolCalls
}
if spec.Workspace != "" {
d.Workspace = spec.Workspace
}
if spec.Sandbox != nil {
d.Sandbox = spec.Sandbox
}
if spec.AgentType != "" {
d.AgentType = spec.AgentType
}
}
return d
}
// ResolveDefaultAgentID returns the ID of the agent marked as default,
// or "default" if none is explicitly marked.
func (c *Config) ResolveDefaultAgentID() string {
c.mu.RLock()
defer c.mu.RUnlock()
for id, spec := range c.Agents.List {
if spec.Default {
return id
}
}
return DefaultAgentID
}
// ResolveDisplayName returns the display name for an agent.
// Falls back to "GoClaw" if not configured.
func (c *Config) ResolveDisplayName(agentID string) string {
c.mu.RLock()
defer c.mu.RUnlock()
if spec, ok := c.Agents.List[agentID]; ok && spec.DisplayName != "" {
return spec.DisplayName
}
return "GoClaw"
}
// ApplyEnvOverrides re-applies environment variable overrides onto the config.
// Call this after modifying config to restore runtime secrets from env vars.
func (c *Config) ApplyEnvOverrides() {
c.applyEnvOverrides()
}
// ExpandHome replaces leading ~ with the user home directory.
func ExpandHome(path string) string {
if path == "" || path[0] != '~' {
return path
}
home, _ := os.UserHomeDir()
if len(path) > 1 && path[1] == '/' {
return home + path[1:]
}
return home
}
// ContractHome replaces the user home directory prefix with ~.
// Reverse of ExpandHome — used to store portable paths in the database.
func ContractHome(path string) string {
if path == "" {
return path
}
home, _ := os.UserHomeDir()
if home != "" && strings.HasPrefix(path, home) {
return "~" + path[len(home):]
}
return path
}