diff --git a/cmd/gateway_providers.go b/cmd/gateway_providers.go index 520d5069..294d2db0 100644 --- a/cmd/gateway_providers.go +++ b/cmd/gateway_providers.go @@ -279,7 +279,7 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi if host == "" { host = "http://localhost:11434" } - registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, "ollama", host+"/v1", "llama3.3")) + registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, "ollama", config.DockerLocalhost(host+"/v1"), "llama3.3")) slog.Info("registered provider from DB", "name", p.Name) continue } diff --git a/docker-compose.yml b/docker-compose.yml index 1d602428..c4f5509f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,6 +41,8 @@ services: volumes: - goclaw-data:/app/data - goclaw-workspace:/app/workspace + extra_hosts: + - "host.docker.internal:host-gateway" security_opt: - no-new-privileges:true init: true diff --git a/internal/config/runtime.go b/internal/config/runtime.go new file mode 100644 index 00000000..7661faaa --- /dev/null +++ b/internal/config/runtime.go @@ -0,0 +1,32 @@ +package config + +import ( + "os" + "strings" + "sync" +) + +var ( + dockerOnce sync.Once + dockerCached bool +) + +// InDocker returns true when running inside a Docker container. +// Result is cached after the first call. +func InDocker() bool { + dockerOnce.Do(func() { + _, err := os.Stat("/.dockerenv") + dockerCached = err == nil + }) + return dockerCached +} + +// DockerLocalhost rewrites localhost in url to host.docker.internal +// when running inside Docker, so the container can reach host services. +// Returns the url unchanged when not in Docker or when it doesn't contain localhost. +func DockerLocalhost(url string) string { + if InDocker() && strings.Contains(url, "localhost") { + return strings.Replace(url, "localhost", "host.docker.internal", 1) + } + return url +} diff --git a/internal/http/provider_verify.go b/internal/http/provider_verify.go index b27c18fc..97385a7e 100644 --- a/internal/http/provider_verify.go +++ b/internal/http/provider_verify.go @@ -5,13 +5,13 @@ import ( "encoding/json" "errors" "net/http" - "os" "os/exec" "path/filepath" "strings" "time" "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/store" @@ -139,8 +139,7 @@ func (h *ProvidersHandler) handleClaudeCLIAuthStatus(w http.ResponseWriter, r *h } } - _, dockerErr := os.Stat("/.dockerenv") - inDocker := dockerErr == nil + inDocker := config.InDocker() status, err := providers.CheckClaudeAuthStatus(ctx, cliPath) if err != nil { diff --git a/internal/http/providers.go b/internal/http/providers.go index 03e6dd19..fcb6c6c5 100644 --- a/internal/http/providers.go +++ b/internal/http/providers.go @@ -9,6 +9,7 @@ import ( "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/oauth" "github.com/nextlevelbuilder/goclaw/internal/providers" @@ -141,6 +142,12 @@ func (h *ProvidersHandler) registerInMemory(p *store.LLMProviderData) { h.providerReg.RegisterForTenant(p.TenantID, providers.NewClaudeCLIProvider(cliPath, cliOpts...)) return } + // Ollama doesn't need an API key — inject a dummy value like startup does. + // In Docker, swap localhost → host.docker.internal so the container can reach the host. + if p.ProviderType == store.ProviderOllama { + h.providerReg.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, "ollama", config.DockerLocalhost(p.APIBase), "llama3.3")) + return + } if p.APIKey == "" { return }