Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ WEBHOOK_MAX_COUNT=500
# EMBEDDING_PROVIDER=openai
# EMBEDDING_PROVIDER=google-gemini
# EMBEDDING_PROVIDER_API_KEY=sk-... (required for openai and google; not used for google-gemini)
# EMBEDDING_BASE_URL=https://embeddings.example.com/v1 (optional; only supported with EMBEDDING_PROVIDER=openai)
# EMBEDDING_GOOGLE_CLOUD_PROJECT= (required for google-gemini; or use GOOGLE_CLOUD_PROJECT)
# EMBEDDING_GOOGLE_CLOUD_LOCATION= (required for google-gemini, e.g. europe-west3; or use GOOGLE_CLOUD_LOCATION)
# GOOGLE_APPLICATION_CREDENTIALS= (optional; for google-gemini when outside Google Cloud: path to service account key JSON)
Expand Down
3 changes: 3 additions & 0 deletions charts/hub/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ config:
DATABASE_MAX_CONN_IDLE_TIME_SECONDS: "1800"
DATABASE_HEALTH_CHECK_PERIOD_SECONDS: "60"
DATABASE_CONNECT_TIMEOUT_SECONDS: "10"
# Optional: use with EMBEDDING_PROVIDER=openai to target a self-hosted OpenAI-compatible embeddings endpoint.
# Example: http://text-embeddings-inference.default.svc.cluster.local/v1
EMBEDDING_BASE_URL: ""

secrets:
create: true
Expand Down
1 change: 1 addition & 0 deletions cmd/api/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ func setupEmbeddingSearchHandler(
Provider: embeddingProviderName,
ProviderAPIKey: cfg.Embedding.ProviderAPIKey,
Model: embeddingModel,
BaseURL: cfg.Embedding.BaseURL,
Normalize: cfg.Embedding.Normalize,
GoogleCloudProject: cfg.Embedding.GoogleCloudProject,
GoogleCloudLocation: cfg.Embedding.GoogleCloudLocation,
Expand Down
1 change: 1 addition & 0 deletions cmd/backfill-embeddings/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ func run() int {
Provider: providerCanonical,
ProviderAPIKey: cfg.Embedding.ProviderAPIKey,
Model: embeddingModel,
BaseURL: cfg.Embedding.BaseURL,
Normalize: cfg.Embedding.Normalize,
GoogleCloudProject: cfg.Embedding.GoogleCloudProject,
GoogleCloudLocation: cfg.Embedding.GoogleCloudLocation,
Expand Down
1 change: 1 addition & 0 deletions cmd/worker/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ func NewWorkerApp(cfg *config.Config, db *pgxpool.Pool) (*WorkerApp, error) {
Provider: providerName,
ProviderAPIKey: cfg.Embedding.ProviderAPIKey,
Model: embeddingModel,
BaseURL: cfg.Embedding.BaseURL,
Normalize: cfg.Embedding.Normalize,
GoogleCloudProject: cfg.Embedding.GoogleCloudProject,
GoogleCloudLocation: cfg.Embedding.GoogleCloudLocation,
Expand Down
27 changes: 19 additions & 8 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ var (
ErrWebhookMaxCount = errors.New("WEBHOOK_MAX_COUNT must be a positive integer")
ErrDatabaseMinConnsExceedsMax = errors.New("DATABASE_MIN_CONNS must not exceed DATABASE_MAX_CONNS")
ErrInvalidPublicBaseURL = errors.New("PUBLIC_BASE_URL must be an absolute http(s) URL without query or fragment")
ErrInvalidEmbeddingBaseURL = errors.New("EMBEDDING_BASE_URL must be an absolute http(s) URL without query or fragment")
)

// DefaultDatabaseURL is the default connection URL when DATABASE_URL is unset (local/test only).
Expand Down Expand Up @@ -115,6 +116,7 @@ type EmbeddingConfig struct {
ProviderAPIKey string `env:"EMBEDDING_PROVIDER_API_KEY"`
Provider string `env:"EMBEDDING_PROVIDER"`
Model string `env:"EMBEDDING_MODEL"`
BaseURL string `env:"EMBEDDING_BASE_URL"`
MaxConcurrent int `env:"EMBEDDING_MAX_CONCURRENT" env-default:"5"`
MaxAttempts int `env:"EMBEDDING_MAX_ATTEMPTS" env-default:"3"`
Normalize bool `env:"EMBEDDING_NORMALIZE" env-default:"false"`
Expand Down Expand Up @@ -312,42 +314,51 @@ func validate(cfg *Config) error {
}

if cfg.Server.PublicBaseURL != "" {
normalized, err := normalizePublicBaseURL(cfg.Server.PublicBaseURL)
normalized, err := normalizeHTTPBaseURL(cfg.Server.PublicBaseURL, ErrInvalidPublicBaseURL)
if err != nil {
return err
}

cfg.Server.PublicBaseURL = normalized
}

if cfg.Embedding.BaseURL != "" {
normalized, err := normalizeHTTPBaseURL(cfg.Embedding.BaseURL, ErrInvalidEmbeddingBaseURL)
if err != nil {
return err
}

cfg.Embedding.BaseURL = normalized
}

return nil
}

func normalizePublicBaseURL(raw string) (string, error) {
func normalizeHTTPBaseURL(raw string, sentinel error) (string, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return "", ErrInvalidPublicBaseURL
return "", sentinel
}

parsed, err := url.Parse(trimmed)
if err != nil {
return "", fmt.Errorf("%w: %w", ErrInvalidPublicBaseURL, err)
return "", fmt.Errorf("%w: %w", sentinel, err)
}

if !parsed.IsAbs() || parsed.Host == "" {
return "", ErrInvalidPublicBaseURL
return "", sentinel
}

if parsed.Scheme != "http" && parsed.Scheme != "https" {
return "", ErrInvalidPublicBaseURL
return "", sentinel
}

if parsed.RawQuery != "" || parsed.Fragment != "" {
return "", ErrInvalidPublicBaseURL
return "", sentinel
}

if parsed.User != nil {
return "", ErrInvalidPublicBaseURL
return "", sentinel
}

if parsed.Path == "/" {
Expand Down
46 changes: 44 additions & 2 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,48 @@ func TestLoad_EmbeddingGoogleCloudLocation_precedence(t *testing.T) {
}
}

func TestLoad_EmbeddingBaseURL(t *testing.T) {
t.Setenv("API_KEY", "test-api-key")
t.Setenv("EMBEDDING_BASE_URL", "https://embeddings.example.com/v1/")

cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}

if cfg.Embedding.BaseURL != "https://embeddings.example.com/v1" {
t.Errorf("Embedding.BaseURL = %q, want https://embeddings.example.com/v1", cfg.Embedding.BaseURL)
}
}

func TestLoad_EmbeddingBaseURLValidation(t *testing.T) {
tests := []struct {
name string
value string
}{
{name: "rejects relative url", value: "/v1"},
{name: "rejects unsupported scheme", value: "ftp://embeddings.example.com/v1"},
{name: "rejects query", value: "https://embeddings.example.com/v1?x=1"},
{name: "rejects fragment", value: "https://embeddings.example.com/v1#frag"},
{name: "rejects user info", value: "https://user:pass@embeddings.example.com/v1"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("EMBEDDING_BASE_URL", tt.value)

_, err := Load()
if err == nil {
t.Fatalf("Load() error = nil, want error")
}

if !errors.Is(err, ErrInvalidEmbeddingBaseURL) {
t.Fatalf("Load() error = %v, want %v", err, ErrInvalidEmbeddingBaseURL)
}
})
}
}

func TestLoad_PublicBaseURLValidation(t *testing.T) {
tests := []struct {
name string
Expand Down Expand Up @@ -582,7 +624,7 @@ func TestNormalizePublicBaseURL(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := normalizePublicBaseURL(tt.value)
got, err := normalizeHTTPBaseURL(tt.value, ErrInvalidPublicBaseURL)
if err != nil {
t.Fatalf("normalizePublicBaseURL() error = %v, want nil", err)
}
Expand All @@ -597,7 +639,7 @@ func TestNormalizePublicBaseURL(t *testing.T) {
func TestNormalizePublicBaseURLRejectsInvalidValues(t *testing.T) {
for _, value := range []string{" ", "http://[::1", "hub.example.com"} {
t.Run(value, func(t *testing.T) {
_, err := normalizePublicBaseURL(value)
_, err := normalizeHTTPBaseURL(value, ErrInvalidPublicBaseURL)
if !errors.Is(err, ErrInvalidPublicBaseURL) {
t.Fatalf("normalizePublicBaseURL() error = %v, want %v", err, ErrInvalidPublicBaseURL)
}
Expand Down
18 changes: 17 additions & 1 deletion internal/openai/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ var (
// Client calls the OpenAI embeddings API via the official SDK.
type Client struct {
sdk openaisdk.Client
baseURL string
dimensions int
model string
normalize bool
Expand All @@ -51,6 +52,13 @@ func WithModel(model string) ClientOption {
}
}

// WithBaseURL sets a custom OpenAI-compatible base URL (for example a self-hosted embeddings runtime).
func WithBaseURL(baseURL string) ClientOption {
return func(c *Client) {
c.baseURL = baseURL
}
}

// WithNormalize enables L2 normalization of the embedding vector before returning (e.g. before storing or caching).
func WithNormalize(normalize bool) ClientOption {
return func(c *Client) {
Expand All @@ -62,14 +70,22 @@ func WithNormalize(normalize bool) ClientOption {
// Embedding dimension is fixed (models.EmbeddingVectorDimensions); WithDimensions is optional for overrides.
func NewClient(apiKey string, opts ...ClientOption) *Client {
client := &Client{
sdk: openaisdk.NewClient(option.WithAPIKey(apiKey)),
dimensions: models.EmbeddingVectorDimensions,
}

for _, opt := range opts {
opt(client)
}

sdkOpts := []option.RequestOption{
option.WithAPIKey(apiKey),
}
if client.baseURL != "" {
sdkOpts = append(sdkOpts, option.WithBaseURL(client.baseURL))
}

client.sdk = openaisdk.NewClient(sdkOpts...)

return client
}

Expand Down
102 changes: 102 additions & 0 deletions internal/openai/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package openai

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

type embeddingRequest struct {
Input string `json:"input"`
Model string `json:"model"`
Dimensions int `json:"dimensions"`
}

func newEmbeddingServer(t *testing.T, embedding []float64) (*httptest.Server, *atomic.Int32) {
t.Helper()

var hitCount atomic.Int32

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hitCount.Add(1)
assert.Equal(t, "/v1/embeddings", r.URL.Path)
assert.Equal(t, "Bearer sk-test", r.Header.Get("Authorization"))

var req embeddingRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Errorf("decode request body: %v", err)
http.Error(w, "invalid request", http.StatusBadRequest)

return
}

assert.Equal(t, "hello world", req.Input)
assert.Equal(t, "test-model", req.Model)
assert.Equal(t, 2, req.Dimensions)

w.Header().Set("Content-Type", "application/json")

if err := json.NewEncoder(w).Encode(map[string]any{
"object": "list",
"model": req.Model,
"data": []map[string]any{
{
"object": "embedding",
"index": 0,
"embedding": embedding,
},
},
"usage": map[string]any{
"prompt_tokens": 1,
"total_tokens": 1,
},
}); err != nil {
t.Errorf("encode response body: %v", err)
}
}))

t.Cleanup(server.Close)

return server, &hitCount
}

func TestCreateEmbedding_UsesExplicitBaseURLOverEnvironment(t *testing.T) {
envServer, envHits := newEmbeddingServer(t, []float64{9, 9})
explicitServer, explicitHits := newEmbeddingServer(t, []float64{1, 2})

t.Setenv("OPENAI_BASE_URL", envServer.URL+"/v1")

client := NewClient("sk-test",
WithBaseURL(explicitServer.URL+"/v1"),
WithDimensions(2),
WithModel("test-model"),
)

embedding, err := client.CreateEmbedding(context.Background(), "hello world")
require.NoError(t, err)
assert.Equal(t, []float32{1, 2}, embedding)
assert.Equal(t, int32(0), envHits.Load())
assert.Equal(t, int32(1), explicitHits.Load())
}

func TestCreateEmbedding_UsesEnvironmentBaseURLWhenExplicitBaseURLIsUnset(t *testing.T) {
envServer, envHits := newEmbeddingServer(t, []float64{3, 4})

t.Setenv("OPENAI_BASE_URL", envServer.URL+"/v1")

client := NewClient("sk-test",
WithDimensions(2),
WithModel("test-model"),
)

embedding, err := client.CreateEmbedding(context.Background(), "hello world")
require.NoError(t, err)
assert.Equal(t, []float32{3, 4}, embedding)
assert.Equal(t, int32(1), envHits.Load())
}
8 changes: 8 additions & 0 deletions internal/service/embedding_client_factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ var (
ErrEmbeddingConfigInvalid = errors.New("embedding config invalid")
// ErrEmbeddingProviderAPIKey is returned when an API-key-based provider is configured without a key.
ErrEmbeddingProviderAPIKey = errors.New("EMBEDDING_PROVIDER_API_KEY is required for this provider")
// ErrEmbeddingBaseURLUnsupported is returned when a custom base URL is configured for a non-openai provider.
ErrEmbeddingBaseURLUnsupported = errors.New("EMBEDDING_BASE_URL is only supported for openai")
// ErrEmbeddingGoogleGeminiConfig is returned when google-gemini is configured without project or location.
ErrEmbeddingGoogleGeminiConfig = errors.New(
"google-gemini requires EMBEDDING_GOOGLE_CLOUD_PROJECT and EMBEDDING_GOOGLE_CLOUD_LOCATION")
Expand Down Expand Up @@ -59,6 +61,7 @@ var embeddingProviderRegistry = map[string]providerEntry{
func openAIEmbeddingFactory(_ context.Context, cfg EmbeddingClientConfig) (EmbeddingClient, error) {
return openai.NewClient(cfg.ProviderAPIKey,
openai.WithModel(cfg.Model),
openai.WithBaseURL(cfg.BaseURL),
openai.WithNormalize(cfg.Normalize),
), nil
}
Expand Down Expand Up @@ -102,6 +105,7 @@ type EmbeddingClientConfig struct {
Provider string
ProviderAPIKey string // API key for openai/google providers; not logged or serialized
Model string
BaseURL string
Normalize bool
GoogleCloudProject string
GoogleCloudLocation string
Expand All @@ -121,6 +125,10 @@ func ValidateEmbeddingConfig(cfg EmbeddingClientConfig) error {
return fmt.Errorf("%w: %s", ErrEmbeddingProviderAPIKey, provider)
}

if cfg.BaseURL != "" && provider != EmbeddingProviderOpenAI {
return fmt.Errorf("%w: %s", ErrEmbeddingBaseURLUnsupported, provider)
}

if entry.RequiresGoogleGeminiConfig && (cfg.GoogleCloudProject == "" || cfg.GoogleCloudLocation == "") {
return ErrEmbeddingGoogleGeminiConfig
}
Expand Down
Loading
Loading