Merge pull request #1520 from cesc1802/dev

Embedding paths skip DockerLocalhost rewrite — Ollama "Verify Embedding" fails with connection refused in Docker
This commit is contained in:
Clark Cant authored and GitHub committed 2026-08-22 20:57:05 +07:00
commit fc8a35eebd
5 files changed
+96

No files matched your search

+5
View File
@@ -9,6 +9,7 @@ import (
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
@@ -92,6 +93,10 @@ func TestSubagentExecTool_NilStoreIsSafe(t *testing.T) {
func captureEmbeddingRequest(t *testing.T, es *store.EmbeddingSettings) map[string]any {
t.Helper()
// Pin Docker detection off so the loopback httptest URL is not rewritten
// to host.docker.internal when this suite runs inside a container.
t.Cleanup(config.SetInDockerForTest(false))
var requestBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
+17
View File
@@ -4,16 +4,33 @@ import (
"os"
"strings"
"sync"
"sync/atomic"
)
var (
dockerOnce sync.Once
dockerCached bool
// inDockerForTest overrides Docker detection. Production code MUST never
// set this; use SetInDockerForTest exclusively from *_test.go.
inDockerForTest atomic.Pointer[bool]
)
// SetInDockerForTest forces InDocker to return v, bypassing the /.dockerenv
// probe. Returns a restore func that reinstates the previous override (or real
// detection when none was active). Test-only.
func SetInDockerForTest(v bool) func() {
prev := inDockerForTest.Load()
inDockerForTest.Store(&v)
return func() { inDockerForTest.Store(prev) }
}
// InDocker returns true when running inside a Docker container.
// Result is cached after the first call.
func InDocker() bool {
if override := inDockerForTest.Load(); override != nil {
return *override
}
dockerOnce.Do(func() {
_, err := os.Stat("/.dockerenv")
dockerCached = err == nil
+36
View File
@@ -0,0 +1,36 @@
package config
import "testing"
func TestDockerLocalhostRewritesLoopbackInDocker(t *testing.T) {
restore := SetInDockerForTest(true)
defer restore()
cases := []struct {
name string
in string
want string
}{
{"localhost", "http://localhost:11434/v1", "http://host.docker.internal:11434/v1"},
{"loopback ip", "http://127.0.0.1:11434/v1", "http://host.docker.internal:11434/v1"},
{"non-loopback unchanged", "https://api.openai.com/v1", "https://api.openai.com/v1"},
{"empty", "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := DockerLocalhost(tc.in); got != tc.want {
t.Fatalf("DockerLocalhost(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
func TestDockerLocalhostPassthroughOutsideDocker(t *testing.T) {
restore := SetInDockerForTest(false)
defer restore()
in := "http://localhost:11434/v1"
if got := DockerLocalhost(in); got != in {
t.Fatalf("DockerLocalhost(%q) = %q, want unchanged outside Docker", in, got)
}
}
+6
View File
@@ -11,6 +11,8 @@ import (
"net/http"
"strings"
"time"
"github.com/nextlevelbuilder/goclaw/internal/config"
)
// ContentHash returns a short SHA256 hex digest of the content (first 16 bytes).
@@ -156,6 +158,10 @@ func NewOpenAIEmbeddingProvider(name, apiKey, apiURL, model string) *OpenAIEmbed
if model == "" {
model = "text-embedding-3-small"
}
// Rewrite loopback hosts to host.docker.internal when running inside Docker,
// centralized here so no call site (verify handler, runtime memory embedding)
// can reach the container itself instead of a host service like Ollama.
apiURL = config.DockerLocalhost(apiURL)
return &OpenAIEmbeddingProvider{
name: name,
@@ -5,8 +5,11 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/nextlevelbuilder/goclaw/internal/config"
)
func TestOpenAIEmbeddingProviderRestoresResponseOrder(t *testing.T) {
@@ -50,3 +53,32 @@ func TestOpenAIEmbeddingProviderUsesBoundedHTTPClient(t *testing.T) {
t.Fatalf("HTTP timeout = %v, want 60s", provider.httpClient)
}
}
func TestOpenAIEmbeddingProviderRewritesLoopbackInDocker(t *testing.T) {
restore := config.SetInDockerForTest(true)
defer restore()
provider := NewOpenAIEmbeddingProvider("ollama", "", "http://localhost:11434/v1", "nomic-embed-text")
if provider.apiURL != "http://host.docker.internal:11434/v1" {
t.Fatalf("apiURL = %q, want Docker localhost rewrite applied in constructor", provider.apiURL)
}
}
func TestOpenAIEmbeddingProviderKeepsLoopbackOutsideDocker(t *testing.T) {
restore := config.SetInDockerForTest(false)
defer restore()
provider := NewOpenAIEmbeddingProvider("ollama", "", "http://localhost:11434/v1", "nomic-embed-text")
if provider.apiURL != "http://localhost:11434/v1" {
t.Fatalf("apiURL = %q, want unchanged outside Docker", provider.apiURL)
}
}
// TestMain pins Docker detection off so the httptest-based Embed tests above
// stay deterministic even when the test binary itself runs inside a container.
func TestMain(m *testing.M) {
restore := config.SetInDockerForTest(false)
code := m.Run()
restore()
os.Exit(code)
}