diff --git a/cmd/gateway_providers.go b/cmd/gateway_providers.go index a561ece9..0ce69f9f 100644 --- a/cmd/gateway_providers.go +++ b/cmd/gateway_providers.go @@ -116,6 +116,24 @@ func registerProviders(registry *providers.Registry, cfg *config.Config) { 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 @@ -243,6 +261,17 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi slog.Info("registered provider from DB", "name", p.Name) 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 } @@ -273,6 +302,12 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi 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. diff --git a/internal/config/config_channels.go b/internal/config/config_channels.go index 68c5e5e7..e63d4848 100644 --- a/internal/config/config_channels.go +++ b/internal/config/config_channels.go @@ -194,9 +194,17 @@ type ProvidersConfig struct { Perplexity ProviderConfig `json:"perplexity"` DashScope ProviderConfig `json:"dashscope"` Bailian ProviderConfig `json:"bailian"` - Zai ProviderConfig `json:"zai"` - ZaiCoding ProviderConfig `json:"zai_coding"` - ClaudeCLI ClaudeCLIConfig `json:"claude_cli"` + Zai ProviderConfig `json:"zai"` + ZaiCoding ProviderConfig `json:"zai_coding"` + Ollama OllamaConfig `json:"ollama"` // local Ollama instance (no API key needed) + OllamaCloud ProviderConfig `json:"ollama_cloud"` // Ollama Cloud (API key required) + ClaudeCLI ClaudeCLIConfig `json:"claude_cli"` +} + +// OllamaConfig configures a local (or self-hosted) Ollama instance. +// No API key is required — Ollama accepts any Bearer token value. +type OllamaConfig struct { + Host string `json:"host"` // Ollama server base URL, e.g. http://localhost:11434 } // ClaudeCLIConfig configures the Claude CLI provider (uses subscription, not API key). @@ -230,6 +238,8 @@ func (c *Config) HasAnyProvider() bool { p.Bailian.APIKey != "" || p.Zai.APIKey != "" || p.ZaiCoding.APIKey != "" || + p.Ollama.Host != "" || + p.OllamaCloud.APIKey != "" || p.ClaudeCLI.CLIPath != "" } diff --git a/internal/config/config_load.go b/internal/config/config_load.go index 49dc93f1..79a506d2 100644 --- a/internal/config/config_load.go +++ b/internal/config/config_load.go @@ -112,6 +112,9 @@ func (c *Config) applyEnvOverrides() { 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) envStr("GOCLAW_GATEWAY_TOKEN", &c.Gateway.Token) envStr("GOCLAW_TELEGRAM_TOKEN", &c.Channels.Telegram.Token) envStr("GOCLAW_DISCORD_TOKEN", &c.Channels.Discord.Token) diff --git a/internal/config/config_secrets.go b/internal/config/config_secrets.go index e73b846d..6e6cf938 100644 --- a/internal/config/config_secrets.go +++ b/internal/config/config_secrets.go @@ -36,6 +36,7 @@ func (c *Config) MaskedCopy() *Config { 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) @@ -85,6 +86,7 @@ func (c *Config) StripSecrets() { c.Providers.Bailian.APIKey = "" c.Providers.Zai.APIKey = "" c.Providers.ZaiCoding.APIKey = "" + c.Providers.OllamaCloud.APIKey = "" // Gateway token c.Gateway.Token = "" @@ -139,6 +141,7 @@ func (c *Config) StripMaskedSecrets() { 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) diff --git a/internal/store/provider_store.go b/internal/store/provider_store.go index 3500fde1..06d94e5d 100644 --- a/internal/store/provider_store.go +++ b/internal/store/provider_store.go @@ -28,6 +28,8 @@ const ( ProviderYesScale = "yescale" ProviderZai = "zai" ProviderZaiCoding = "zai_coding" + ProviderOllama = "ollama" // local or self-hosted Ollama (no API key) + ProviderOllamaCloud = "ollama_cloud" // Ollama Cloud (Bearer token required) ) // ValidProviderTypes lists all accepted provider_type values. @@ -51,6 +53,8 @@ var ValidProviderTypes = map[string]bool{ ProviderYesScale: true, ProviderZai: true, ProviderZaiCoding: true, + ProviderOllama: true, + ProviderOllamaCloud: true, } // LLMProviderData represents an LLM provider configuration. diff --git a/ui/web/src/constants/providers.ts b/ui/web/src/constants/providers.ts index 8d167264..edbb1158 100644 --- a/ui/web/src/constants/providers.ts +++ b/ui/web/src/constants/providers.ts @@ -23,5 +23,7 @@ export const PROVIDER_TYPES: ProviderTypeInfo[] = [ { value: "yescale", label: "YesScale", apiBase: "https://api.yescale.one/v1", placeholder: "" }, { value: "zai", label: "Z.ai API", apiBase: "https://api.z.ai/api/paas/v4", placeholder: "" }, { value: "zai_coding", label: "Z.ai Coding Plan", apiBase: "https://api.z.ai/api/coding/paas/v4", placeholder: "" }, + { value: "ollama", label: "Ollama (Local)", apiBase: "http://localhost:11434/v1", placeholder: "" }, + { value: "ollama_cloud", label: "Ollama Cloud", apiBase: "https://ollama.com/v1", placeholder: "" }, { value: "claude_cli", label: "Claude CLI (Local)", apiBase: "", placeholder: "" }, ]; diff --git a/ui/web/src/pages/config/sections/providers-section.tsx b/ui/web/src/pages/config/sections/providers-section.tsx index 7a857804..72b0d5b1 100644 --- a/ui/web/src/pages/config/sections/providers-section.tsx +++ b/ui/web/src/pages/config/sections/providers-section.tsx @@ -25,6 +25,7 @@ const KNOWN_PROVIDERS = [ { key: "minimax", label: "MiniMax", envKey: "GOCLAW_MINIMAX_API_KEY" }, { key: "cohere", label: "Cohere", envKey: "GOCLAW_COHERE_API_KEY" }, { key: "perplexity", label: "Perplexity", envKey: "GOCLAW_PERPLEXITY_API_KEY" }, + { key: "ollama_cloud", label: "Ollama Cloud", envKey: "GOCLAW_OLLAMA_CLOUD_API_KEY" }, ]; function isSecret(val: unknown): boolean { diff --git a/ui/web/src/pages/setup/hooks/use-bootstrap-status.ts b/ui/web/src/pages/setup/hooks/use-bootstrap-status.ts index b49631f0..599e3747 100644 --- a/ui/web/src/pages/setup/hooks/use-bootstrap-status.ts +++ b/ui/web/src/pages/setup/hooks/use-bootstrap-status.ts @@ -17,9 +17,9 @@ export function useBootstrapStatus() { if (loading) return { needsSetup: false, currentStep: "complete" as SetupStep }; // A provider is "configured" if enabled + has an API key set (masked as "***") - // Claude CLI and ChatGPT OAuth don't require API keys — check type instead + // Claude CLI, ChatGPT OAuth, and local Ollama don't require API keys — check type instead const hasProvider = providers.some((p) => p.enabled && - (p.api_key === "***" || p.provider_type === "claude_cli" || p.provider_type === "chatgpt_oauth")); + (p.api_key === "***" || p.provider_type === "claude_cli" || p.provider_type === "chatgpt_oauth" || p.provider_type === "ollama")); const hasAgent = agents.length > 0; if (!hasProvider) return { needsSetup: true, currentStep: 1 as SetupStep }; diff --git a/ui/web/src/pages/setup/step-provider.tsx b/ui/web/src/pages/setup/step-provider.tsx index f02c8d42..8d233c6c 100644 --- a/ui/web/src/pages/setup/step-provider.tsx +++ b/ui/web/src/pages/setup/step-provider.tsx @@ -35,6 +35,8 @@ export function StepProvider({ onComplete }: StepProviderProps) { const [error, setError] = useState(""); const isCLI = providerType === "claude_cli"; + // Local Ollama uses no API key — the server accepts any non-empty Bearer value internally + const isOllama = providerType === "ollama"; const handleTypeChange = (value: string) => { setProviderType(value); @@ -53,7 +55,7 @@ export function StepProvider({ onComplete }: StepProviderProps) { ); const handleCreate = async () => { - if (!isCLI && !apiKey.trim()) { setError(t("provider.errors.apiKeyRequired")); return; } + if (!isCLI && !isOllama && !apiKey.trim()) { setError(t("provider.errors.apiKeyRequired")); return; } setLoading(true); setError(""); try { @@ -61,7 +63,7 @@ export function StepProvider({ onComplete }: StepProviderProps) { name: name.trim(), provider_type: providerType, api_base: apiBase.trim() || undefined, - api_key: isCLI ? undefined : apiKey.trim(), + api_key: isCLI || isOllama ? undefined : apiKey.trim(), enabled: true, }) as ProviderData; onComplete(provider); @@ -143,7 +145,7 @@ export function StepProvider({ onComplete }: StepProviderProps) { {error &&
{error}
}