Files
goclaw/internal/http/provider_verify_embedding.go
viettranx 28a17c4d6b feat(embedding): data-driven dimension config with multi-provider support
- Add `dimensions` field to EmbeddingSettings for per-provider truncation
- Default to 1536 dims to match pgvector schema, preventing INSERT failures
- Replace hardcoded Gemini-only dimension truncation with data-driven config
- Fix verify endpoint to apply dimensions and return mismatch warning
- Expand curated embedding models: Gemini (gemini-embedding-001), Cohere
  (embed-v4), DashScope (text-embedding-v3), OpenAI (text-embedding-3-large)
- Add dimensions input to provider detail UI with i18n (en/vi/zh)
- Add anthropic_native to NoEmbeddingTypes (incompatible auth format)
- System settings verify always requests 1536d to match production behavior
2026-03-25 14:50:43 +07:00

151 lines
4.6 KiB
Go

package http
import (
"context"
"encoding/json"
"net/http"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/memory"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// handleVerifyEmbedding tests a provider's embedding capability with a minimal API call.
//
// POST /v1/providers/{id}/verify-embedding
// Body: {"model": "text-embedding-3-small"} (optional, falls back to settings.embedding.model)
// Response: {"valid": true, "dimensions": 1536} or {"valid": false, "error": "..."}
func (h *ProvidersHandler) handleVerifyEmbedding(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "provider")})
return
}
var req struct {
Model string `json:"model"`
Dimensions int `json:"dimensions"` // optional: truncate output to N dims
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&req); err != nil && err.Error() != "EOF" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
p, err := h.store.GetProvider(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "provider", id.String())})
return
}
// Provider types that cannot serve embeddings
if store.NoEmbeddingTypes[p.ProviderType] {
writeJSON(w, http.StatusOK, map[string]any{"valid": false, "error": "provider type does not support embeddings"})
return
}
// Parse embedding settings once for model/apiBase/dimensions resolution.
es := store.ParseEmbeddingSettings(p.Settings)
// Resolve model: request body → settings.embedding.model → default
model := req.Model
if model == "" && es != nil && es.Model != "" {
model = es.Model
}
if model == "" {
model = "text-embedding-3-small"
}
// Resolve API base: settings.embedding.api_base → provider api_base → resolved base
apiBase := h.resolveAPIBase(p)
if es != nil && es.APIBase != "" {
apiBase = es.APIBase
}
ep := memory.NewOpenAIEmbeddingProvider(p.Name, p.APIKey, apiBase, model)
// Apply dimension truncation: request body → provider settings → none.
// Clamp to reasonable range to avoid sending absurd values upstream.
truncDims := req.Dimensions
if truncDims <= 0 && es != nil && es.Dimensions > 0 {
truncDims = es.Dimensions
}
if truncDims > 0 && truncDims <= 8192 {
ep.WithDimensions(truncDims)
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
vectors, embErr := ep.Embed(ctx, []string{"test"})
if embErr != nil {
writeJSON(w, http.StatusOK, map[string]any{"valid": false, "error": friendlyVerifyError(embErr)})
return
}
dims := 0
if len(vectors) > 0 && len(vectors[0]) > 0 {
dims = len(vectors[0])
}
result := map[string]any{"valid": true, "dimensions": dims}
if dims > 0 && dims != 1536 {
result["dimension_mismatch"] = true
}
writeJSON(w, http.StatusOK, result)
}
// handleEmbeddingStatus returns the current embedding system configuration.
//
// GET /v1/embedding/status
// Response: {"configured": true, "provider": "openai", "model": "text-embedding-3-small"}
// or: {"configured": false}
func (h *ProvidersHandler) handleEmbeddingStatus(w http.ResponseWriter, r *http.Request) {
// Primary: check system_configs for embedding.provider/model
if h.sysConfigStore != nil {
provName, _ := h.sysConfigStore.Get(r.Context(), "embedding.provider")
model, _ := h.sysConfigStore.Get(r.Context(), "embedding.model")
if provName != "" {
if model == "" {
model = "text-embedding-3-small"
}
writeJSON(w, http.StatusOK, map[string]any{
"configured": true,
"provider": provName,
"provider_name": provName,
"model": model,
})
return
}
}
// Fallback: check provider-level embedding settings
providerList, err := h.store.ListProviders(r.Context())
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"configured": false})
return
}
for _, p := range providerList {
if !p.Enabled || store.NoEmbeddingTypes[p.ProviderType] {
continue
}
es := store.ParseEmbeddingSettings(p.Settings)
if es != nil && es.Enabled {
model := es.Model
if model == "" {
model = "text-embedding-3-small"
}
writeJSON(w, http.StatusOK, map[string]any{
"configured": true,
"provider": p.DisplayName,
"provider_name": p.Name,
"model": model,
})
return
}
}
writeJSON(w, http.StatusOK, map[string]any{"configured": false})
}