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)
This commit is contained in:
therichardngai-code authored and GitHub committed 2026-03-10 18:43:02 +07:00
1 parent 456e594b8f
commit d874266e87
9 files changed
+68 -8

No files matched your search

+35
View File
@@ -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.
+13 -3
View File
@@ -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 != ""
}
+3
View File
@@ -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)
+3
View File
@@ -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)
+4
View File
@@ -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.
+2
View File
@@ -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: "" },
];
@@ -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 {
@@ -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 };
+5 -3
View File
@@ -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 && <p className="text-sm text-destructive">{error}</p>}
<div className="flex justify-end">
<Button onClick={handleCreate} disabled={loading || (!isCLI && !apiKey.trim())}>
<Button onClick={handleCreate} disabled={loading || (!isCLI && !isOllama && !apiKey.trim())}>
{loading ? t("provider.creating") : t("provider.create")}
</Button>
</div>