Files
goclaw/internal/config/config_secrets.go
T
therichardngai-code d874266e87 feat(providers): add Ollama local and Ollama Cloud provider support (#113)
* feat(providers): add Ollama local and Ollama Cloud provider support

Adds two new provider variants:

ollama — local/self-hosted Ollama instance
- Gated on providers.ollama.host in config (or GOCLAW_OLLAMA_HOST env)
- No API key required; Ollama's OpenAI-compat endpoint accepts any Bearer value
- Defaults to http://localhost:11434/v1 (configurable for LAN/remote hosts)
- Default model: llama3.3

ollama-cloud — Ollama Cloud (managed remote inference)
- Gated on providers.ollama_cloud.api_key (or GOCLAW_OLLAMA_CLOUD_API_KEY env)
- Bearer token from ollama.com/settings/keys
- Default base URL: https://ollama.com/v1 (overridable via api_base)
- Default model: llama3.3

Both variants use the existing NewOpenAIProvider (OpenAI-compat) — no new
provider struct needed. Both are registered from config file and DB (via
llm_providers table with ProviderOllama / ProviderOllamaCloud types).

OllamaCloud.APIKey follows all existing secret handling patterns:
MaskedCopy, StripSecrets, StripMaskedSecrets.

* feat(providers): wire Ollama into web UI and fix DB registration

- Add ollama + ollama_cloud to PROVIDER_TYPES constants (dropdowns)
- Fix setup wizard: skip API key requirement for Ollama local (isOllama)
- Fix bootstrap status: recognize Ollama local as no-API-key provider
- Add ollama_cloud to config-page KNOWN_PROVIDERS list
- Fix gateway_providers.go: move ProviderOllama before APIKey=='' guard
  so DB-registered local Ollama providers actually register at startup
  (same pattern as ClaudeCLI, which also needs no API key)
2026-03-10 18:43:02 +07:00

219 lines
6.6 KiB
Go

package config
import "encoding/json"
const secretMask = "***"
// MaskedCopy returns a deep copy of the config with all secret fields masked.
// Used by config.get to avoid exposing secrets to WebSocket clients.
func (c *Config) MaskedCopy() *Config {
c.mu.RLock()
defer c.mu.RUnlock()
// Deep copy via JSON round-trip
data, err := json.Marshal(c)
if err != nil {
return &Config{}
}
cp := Default()
if err := json.Unmarshal(data, cp); err != nil {
return &Config{}
}
// Mask provider API keys
maskNonEmpty(&cp.Providers.Anthropic.APIKey)
maskNonEmpty(&cp.Providers.OpenAI.APIKey)
maskNonEmpty(&cp.Providers.OpenRouter.APIKey)
maskNonEmpty(&cp.Providers.Groq.APIKey)
maskNonEmpty(&cp.Providers.DeepSeek.APIKey)
maskNonEmpty(&cp.Providers.Gemini.APIKey)
maskNonEmpty(&cp.Providers.Mistral.APIKey)
maskNonEmpty(&cp.Providers.XAI.APIKey)
maskNonEmpty(&cp.Providers.MiniMax.APIKey)
maskNonEmpty(&cp.Providers.Cohere.APIKey)
maskNonEmpty(&cp.Providers.Perplexity.APIKey)
maskNonEmpty(&cp.Providers.DashScope.APIKey)
maskNonEmpty(&cp.Providers.Bailian.APIKey)
maskNonEmpty(&cp.Providers.Zai.APIKey)
maskNonEmpty(&cp.Providers.ZaiCoding.APIKey)
maskNonEmpty(&cp.Providers.OllamaCloud.APIKey)
// Mask gateway token
maskNonEmpty(&cp.Gateway.Token)
// Mask channel secrets
maskNonEmpty(&cp.Channels.Telegram.Token)
maskNonEmpty(&cp.Channels.Discord.Token)
maskNonEmpty(&cp.Channels.Slack.BotToken)
maskNonEmpty(&cp.Channels.Slack.AppToken)
maskNonEmpty(&cp.Channels.Zalo.Token)
maskNonEmpty(&cp.Channels.Zalo.WebhookSecret)
maskNonEmpty(&cp.Channels.Feishu.AppID)
maskNonEmpty(&cp.Channels.Feishu.AppSecret)
maskNonEmpty(&cp.Channels.Feishu.EncryptKey)
maskNonEmpty(&cp.Channels.Feishu.VerificationToken)
// Mask TTS API keys
maskNonEmpty(&cp.Tts.OpenAI.APIKey)
maskNonEmpty(&cp.Tts.ElevenLabs.APIKey)
maskNonEmpty(&cp.Tts.MiniMax.APIKey)
// Mask web tool keys
maskNonEmpty(&cp.Tools.Web.Brave.APIKey)
// Mask Tailscale auth key
maskNonEmpty(&cp.Tailscale.AuthKey)
return cp
}
// StripSecrets zeros out all secret fields in the config.
// Used before saving to disk to ensure secrets never persist in config.json.
func (c *Config) StripSecrets() {
// Provider API keys
c.Providers.Anthropic.APIKey = ""
c.Providers.OpenAI.APIKey = ""
c.Providers.OpenRouter.APIKey = ""
c.Providers.Groq.APIKey = ""
c.Providers.DeepSeek.APIKey = ""
c.Providers.Gemini.APIKey = ""
c.Providers.Mistral.APIKey = ""
c.Providers.XAI.APIKey = ""
c.Providers.MiniMax.APIKey = ""
c.Providers.Cohere.APIKey = ""
c.Providers.Perplexity.APIKey = ""
c.Providers.DashScope.APIKey = ""
c.Providers.Bailian.APIKey = ""
c.Providers.Zai.APIKey = ""
c.Providers.ZaiCoding.APIKey = ""
c.Providers.OllamaCloud.APIKey = ""
// Gateway token
c.Gateway.Token = ""
// Channel secrets
c.Channels.Telegram.Token = ""
c.Channels.Discord.Token = ""
c.Channels.Slack.BotToken = ""
c.Channels.Slack.AppToken = ""
c.Channels.Zalo.Token = ""
c.Channels.Zalo.WebhookSecret = ""
c.Channels.Feishu.AppID = ""
c.Channels.Feishu.AppSecret = ""
c.Channels.Feishu.EncryptKey = ""
c.Channels.Feishu.VerificationToken = ""
// TTS API keys
c.Tts.OpenAI.APIKey = ""
c.Tts.ElevenLabs.APIKey = ""
c.Tts.MiniMax.APIKey = ""
// Web tool keys
c.Tools.Web.Brave.APIKey = ""
// Tailscale auth key
c.Tailscale.AuthKey = ""
}
// StripMaskedSecrets strips only fields that still contain the mask value "***".
// Real values (user-entered via UI) are preserved, so that secrets entered
// via the config UI persist in config.json.
func (c *Config) StripMaskedSecrets() {
stripIfMasked := func(s *string) {
if *s == secretMask {
*s = ""
}
}
// Provider API keys
stripIfMasked(&c.Providers.Anthropic.APIKey)
stripIfMasked(&c.Providers.OpenAI.APIKey)
stripIfMasked(&c.Providers.OpenRouter.APIKey)
stripIfMasked(&c.Providers.Groq.APIKey)
stripIfMasked(&c.Providers.DeepSeek.APIKey)
stripIfMasked(&c.Providers.Gemini.APIKey)
stripIfMasked(&c.Providers.Mistral.APIKey)
stripIfMasked(&c.Providers.XAI.APIKey)
stripIfMasked(&c.Providers.MiniMax.APIKey)
stripIfMasked(&c.Providers.Cohere.APIKey)
stripIfMasked(&c.Providers.Perplexity.APIKey)
stripIfMasked(&c.Providers.DashScope.APIKey)
stripIfMasked(&c.Providers.Bailian.APIKey)
stripIfMasked(&c.Providers.Zai.APIKey)
stripIfMasked(&c.Providers.ZaiCoding.APIKey)
stripIfMasked(&c.Providers.OllamaCloud.APIKey)
// Gateway token
stripIfMasked(&c.Gateway.Token)
// Channel secrets
stripIfMasked(&c.Channels.Telegram.Token)
stripIfMasked(&c.Channels.Discord.Token)
stripIfMasked(&c.Channels.Slack.BotToken)
stripIfMasked(&c.Channels.Slack.AppToken)
stripIfMasked(&c.Channels.Zalo.Token)
stripIfMasked(&c.Channels.Zalo.WebhookSecret)
stripIfMasked(&c.Channels.Feishu.AppID)
stripIfMasked(&c.Channels.Feishu.AppSecret)
stripIfMasked(&c.Channels.Feishu.EncryptKey)
stripIfMasked(&c.Channels.Feishu.VerificationToken)
// TTS API keys
stripIfMasked(&c.Tts.OpenAI.APIKey)
stripIfMasked(&c.Tts.ElevenLabs.APIKey)
stripIfMasked(&c.Tts.MiniMax.APIKey)
// Web tool keys
stripIfMasked(&c.Tools.Web.Brave.APIKey)
// Tailscale auth key
stripIfMasked(&c.Tailscale.AuthKey)
}
// ApplyDBSecrets overlays secrets from the config_secrets table onto the config.
// Called before ApplyEnvOverrides() — env vars take highest precedence.
// Precedence chain: config.json defaults → DB secrets → env vars.
func (c *Config) ApplyDBSecrets(secrets map[string]string) {
apply := func(key string, dst *string) {
if v, ok := secrets[key]; ok && v != "" {
*dst = v
}
}
apply("gateway.token", &c.Gateway.Token)
apply("tts.openai.api_key", &c.Tts.OpenAI.APIKey)
apply("tts.elevenlabs.api_key", &c.Tts.ElevenLabs.APIKey)
apply("tts.minimax.api_key", &c.Tts.MiniMax.APIKey)
apply("tts.minimax.group_id", &c.Tts.MiniMax.GroupID)
apply("tools.web.brave.api_key", &c.Tools.Web.Brave.APIKey)
apply("tailscale.auth_key", &c.Tailscale.AuthKey)
}
// ExtractDBSecrets returns the config_secrets key-value pairs from the config.
// Saves secrets to the config_secrets table.
func (c *Config) ExtractDBSecrets() map[string]string {
secrets := make(map[string]string)
collect := func(key, value string) {
if value != "" && value != secretMask {
secrets[key] = value
}
}
collect("gateway.token", c.Gateway.Token)
collect("tts.openai.api_key", c.Tts.OpenAI.APIKey)
collect("tts.elevenlabs.api_key", c.Tts.ElevenLabs.APIKey)
collect("tts.minimax.api_key", c.Tts.MiniMax.APIKey)
collect("tts.minimax.group_id", c.Tts.MiniMax.GroupID)
collect("tools.web.brave.api_key", c.Tools.Web.Brave.APIKey)
collect("tailscale.auth_key", c.Tailscale.AuthKey)
return secrets
}
func maskNonEmpty(s *string) {
if *s != "" {
*s = secretMask
}
}