From 28a17c4d6bb7bad1cd8d7decfe7010fae33400c0 Mon Sep 17 00:00:00 2001 From: viettranx Date: Wed, 25 Mar 2026 14:50:43 +0700 Subject: [PATCH] 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 --- cmd/gateway_agents.go | 16 +++--- internal/http/provider_verify_embedding.go | 30 ++++++++--- internal/store/provider_store.go | 16 +++--- .../layout/system-settings-modal.tsx | 39 +++++++++++--- ui/web/src/i18n/locales/en/providers.json | 5 +- .../src/i18n/locales/en/system-settings.json | 5 +- ui/web/src/i18n/locales/vi/providers.json | 5 +- .../src/i18n/locales/vi/system-settings.json | 5 +- ui/web/src/i18n/locales/zh/providers.json | 5 +- .../src/i18n/locales/zh/system-settings.json | 5 +- .../providers/hooks/use-provider-verify.ts | 8 ++- .../provider-detail/provider-overview.tsx | 51 ++++++++++++++++--- ui/web/src/types/provider.ts | 1 + 13 files changed, 144 insertions(+), 47 deletions(-) diff --git a/cmd/gateway_agents.go b/cmd/gateway_agents.go index 7de54f3b..51032a7c 100644 --- a/cmd/gateway_agents.go +++ b/cmd/gateway_agents.go @@ -123,8 +123,12 @@ func buildEmbeddingProvider( apiBase = memCfg.EmbeddingAPIBase } - // Gemini requires dimension truncation to match pgvector(1536) index. - needsDimTruncate := dbp.ProviderType == store.ProviderGeminiNative + // Dimension truncation: provider settings override, default to 1536 to match pgvector schema. + // Models that natively output 1536 ignore the parameter; models with larger native dims get truncated. + dims := 1536 + if es != nil && es.Dimensions > 0 { + dims = es.Dimensions + } // Try registry first for the actual API key / base (handles runtime-registered providers) if providerReg != nil { @@ -134,9 +138,7 @@ func buildEmbeddingProvider( apiBase = op.APIBase() } ep := memory.NewOpenAIEmbeddingProvider(dbp.Name, op.APIKey(), apiBase, model) - if needsDimTruncate { - ep.WithDimensions(1536) - } + ep.WithDimensions(dims) return ep } slog.Debug("embedding provider in registry is not OpenAI-compatible, using DB record", "name", dbp.Name) @@ -146,9 +148,7 @@ func buildEmbeddingProvider( // Fallback: build directly from DB record if dbp.APIKey != "" { ep := memory.NewOpenAIEmbeddingProvider(dbp.Name, dbp.APIKey, apiBase, model) - if needsDimTruncate { - ep.WithDimensions(1536) - } + ep.WithDimensions(dims) return ep } diff --git a/internal/http/provider_verify_embedding.go b/internal/http/provider_verify_embedding.go index c09bebc0..0a78f3cf 100644 --- a/internal/http/provider_verify_embedding.go +++ b/internal/http/provider_verify_embedding.go @@ -26,7 +26,8 @@ func (h *ProvidersHandler) handleVerifyEmbedding(w http.ResponseWriter, r *http. } var req struct { - Model string `json:"model"` + 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)}) @@ -45,12 +46,13 @@ func (h *ProvidersHandler) handleVerifyEmbedding(w http.ResponseWriter, r *http. 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 == "" { - if es := store.ParseEmbeddingSettings(p.Settings); es != nil && es.Model != "" { - model = es.Model - } + if model == "" && es != nil && es.Model != "" { + model = es.Model } if model == "" { model = "text-embedding-3-small" @@ -58,12 +60,22 @@ func (h *ProvidersHandler) handleVerifyEmbedding(w http.ResponseWriter, r *http. // Resolve API base: settings.embedding.api_base → provider api_base → resolved base apiBase := h.resolveAPIBase(p) - if es := store.ParseEmbeddingSettings(p.Settings); es != nil && es.APIBase != "" { + 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() @@ -77,7 +89,11 @@ func (h *ProvidersHandler) handleVerifyEmbedding(w http.ResponseWriter, r *http. if len(vectors) > 0 && len(vectors[0]) > 0 { dims = len(vectors[0]) } - writeJSON(w, http.StatusOK, map[string]any{"valid": true, "dimensions": dims}) + 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. diff --git a/internal/store/provider_store.go b/internal/store/provider_store.go index 74ff6334..c28dbd59 100644 --- a/internal/store/provider_store.go +++ b/internal/store/provider_store.go @@ -74,9 +74,10 @@ type LLMProviderData struct { // EmbeddingSettings holds embedding-specific configuration stored in provider settings JSONB. type EmbeddingSettings struct { - Enabled bool `json:"enabled"` - Model string `json:"model,omitempty"` // e.g. "text-embedding-3-small" - APIBase string `json:"api_base,omitempty"` // override if embedding endpoint differs from chat + Enabled bool `json:"enabled"` + Model string `json:"model,omitempty"` // e.g. "text-embedding-3-small" + APIBase string `json:"api_base,omitempty"` // override if embedding endpoint differs from chat + Dimensions int `json:"dimensions,omitempty"` // truncate output to N dims (e.g. 1536); 0 = model default } // ParseEmbeddingSettings extracts embedding config from a provider's settings JSONB. @@ -96,10 +97,11 @@ func ParseEmbeddingSettings(settings json.RawMessage) *EmbeddingSettings { // NoEmbeddingTypes lists provider types that cannot serve embeddings. var NoEmbeddingTypes = map[string]bool{ - ProviderACP: true, - ProviderClaudeCLI: true, - ProviderChatGPTOAuth: true, - ProviderSuno: true, + ProviderAnthropicNative: true, // uses x-api-key auth, not Bearer; no embedding models + ProviderACP: true, + ProviderClaudeCLI: true, + ProviderChatGPTOAuth: true, + ProviderSuno: true, } // ProviderStore manages LLM providers. diff --git a/ui/web/src/components/layout/system-settings-modal.tsx b/ui/web/src/components/layout/system-settings-modal.tsx index a0efa54a..c8085b53 100644 --- a/ui/web/src/components/layout/system-settings-modal.tsx +++ b/ui/web/src/components/layout/system-settings-modal.tsx @@ -27,16 +27,30 @@ const EMBEDDING_MODELS: Record = { // OpenAI — native 1536d openai_compat: [ { id: "text-embedding-3-small", name: "text-embedding-3-small (1536d)" }, + { id: "text-embedding-3-large", name: "text-embedding-3-large (3072d → 1536 via dimensions)" }, { id: "text-embedding-ada-002", name: "text-embedding-ada-002 (1536d)" }, ], // OpenRouter — proxied OpenAI models openrouter: [ { id: "openai/text-embedding-3-small", name: "openai/text-embedding-3-small (1536d)" }, + { id: "openai/text-embedding-3-large", name: "openai/text-embedding-3-large (3072d → 1536)" }, { id: "openai/text-embedding-ada-002", name: "openai/text-embedding-ada-002 (1536d)" }, ], - // Mistral — mistral-embed native 1024d, codestral-embed native 1536d (MRL) + // Gemini — gemini-embedding-001 (3072d native, truncate to 1536 via dimensions param) + gemini_native: [ + { id: "gemini-embedding-001", name: "gemini-embedding-001 (3072d → 1536 via dimensions)" }, + ], + // Mistral — codestral-embed defaults to 1536d (MRL) mistral: [ - { id: "codestral-embed", name: "codestral-embed (1536d)" }, + { id: "codestral-embed", name: "codestral-embed (1536d default)" }, + ], + // DashScope/Qwen — text-embedding-v3 (custom dimensions support) + dashscope: [ + { id: "text-embedding-v3", name: "text-embedding-v3 (1536 via dimensions)" }, + ], + // Cohere — embed-v4 native 1536d + cohere: [ + { id: "embed-v4", name: "embed-v4 (1536d native)" }, ], }; // Fallback for unlisted provider types — no curated models @@ -148,7 +162,8 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP const handleVerifyEmb = () => { if (!selectedEmbProviderData) return; - verifyEmbedding(selectedEmbProviderData.id, embModel.trim() || undefined); + // Always request 1536 dims — pgvector schema requires vector(1536). + verifyEmbedding(selectedEmbProviderData.id, embModel.trim() || undefined, 1536); }; const handleSave = async () => { @@ -275,11 +290,23 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP {t("embedding.verify")} {embResult && ( - + {embResult.valid ? ( <> - - {t("embedding.dimensions", { count: embResult.dimensions })} + {embResult.dimension_mismatch ? ( + + ) : ( + + )} + {embResult.dimension_mismatch + ? t("embedding.dimensionsMismatch", { count: embResult.dimensions }) + : t("embedding.dimensions", { count: embResult.dimensions })} ) : ( <> diff --git a/ui/web/src/i18n/locales/en/providers.json b/ui/web/src/i18n/locales/en/providers.json index 4e5fcfb8..5077e426 100644 --- a/ui/web/src/i18n/locales/en/providers.json +++ b/ui/web/src/i18n/locales/en/providers.json @@ -158,11 +158,14 @@ "enable": "Enable Embedding", "enableDesc": "Allow this provider to generate text embeddings for memory, skills, and knowledge graph", "model": "Embedding Model", + "dimensions": "Output Dimensions", + "dimensionsHint": "Truncate output to N dimensions (e.g. 1536). Required for models with native dims > 1536 like gemini-embedding-001 (3072d).", "apiBase": "Embedding API Base", "apiBasePlaceholder": "(same as provider)", "apiBaseHint": "Override if embedding endpoint differs from the provider's main API base", "verify": "Verify Embedding", - "verifyFailed": "Embedding verification failed" + "verifyFailed": "Embedding verification failed", + "dimensionsMismatch": "{{count}} dimensions — does not match required 1536" }, "toast": { "created": "Provider created", diff --git a/ui/web/src/i18n/locales/en/system-settings.json b/ui/web/src/i18n/locales/en/system-settings.json index a0af3ca1..1c67de95 100644 --- a/ui/web/src/i18n/locales/en/system-settings.json +++ b/ui/web/src/i18n/locales/en/system-settings.json @@ -16,11 +16,12 @@ "modelTip": "Embedding model (e.g. text-embedding-3-small). Must support /embeddings endpoint.", "providerPlaceholder": "Select provider", "modelPlaceholder": "text-embedding-3-small", - "supportedProviders": "Verified 1536d: OpenAI (text-embedding-3-small), OpenRouter (openai/*), Mistral (codestral-embed). Other providers may work if they support /embeddings with 1536d output.", + "supportedProviders": "Verified 1536d: OpenAI (text-embedding-3-small/large), Gemini (gemini-embedding-001 + dimensions), Mistral (codestral-embed), Cohere (embed-v4), DashScope (text-embedding-v3 + dimensions). Configure dimensions in provider settings for models with native dims > 1536.", "verify": "Verify Embedding", "verifyRequired": "Verify the embedding model before saving.", "verifyFailed": "Verification failed", - "dimensions": "{{count}} dimensions" + "dimensions": "{{count}} dimensions", + "dimensionsMismatch": "{{count}} dimensions — does not match required 1536" }, "ux": { "title": "UX Behavior", diff --git a/ui/web/src/i18n/locales/vi/providers.json b/ui/web/src/i18n/locales/vi/providers.json index 63192341..f4c23fe5 100644 --- a/ui/web/src/i18n/locales/vi/providers.json +++ b/ui/web/src/i18n/locales/vi/providers.json @@ -158,11 +158,14 @@ "enable": "Bật Embedding", "enableDesc": "Cho phép provider này tạo embedding cho bộ nhớ, kỹ năng và đồ thị tri thức", "model": "Model Embedding", + "dimensions": "Số chiều đầu ra", + "dimensionsHint": "Cắt ngắn đầu ra về N chiều (VD: 1536). Bắt buộc với model có chiều gốc > 1536 như gemini-embedding-001 (3072d).", "apiBase": "API Base Embedding", "apiBasePlaceholder": "(giống provider)", "apiBaseHint": "Ghi đè nếu endpoint embedding khác với API base chính của provider", "verify": "Kiểm tra Embedding", - "verifyFailed": "Kiểm tra embedding thất bại" + "verifyFailed": "Kiểm tra embedding thất bại", + "dimensionsMismatch": "{{count}} chiều — không khớp yêu cầu 1536" }, "toast": { "created": "Đã tạo provider", diff --git a/ui/web/src/i18n/locales/vi/system-settings.json b/ui/web/src/i18n/locales/vi/system-settings.json index 83bb6522..98963901 100644 --- a/ui/web/src/i18n/locales/vi/system-settings.json +++ b/ui/web/src/i18n/locales/vi/system-settings.json @@ -16,11 +16,12 @@ "modelTip": "Model embedding (vd: text-embedding-3-small). Phải hỗ trợ endpoint /embeddings.", "providerPlaceholder": "Chọn provider", "modelPlaceholder": "text-embedding-3-small", - "supportedProviders": "Đã xác minh 1536d: OpenAI (text-embedding-3-small), OpenRouter (openai/*), Mistral (codestral-embed). Các provider khác có thể hoạt động nếu hỗ trợ /embeddings với output 1536d.", + "supportedProviders": "Đã xác minh 1536d: OpenAI (text-embedding-3-small/large), Gemini (gemini-embedding-001 + dimensions), Mistral (codestral-embed), Cohere (embed-v4), DashScope (text-embedding-v3 + dimensions). Cấu hình dimensions trong provider settings cho model có chiều gốc > 1536.", "verify": "Kiểm tra Embedding", "verifyRequired": "Kiểm tra model embedding trước khi lưu.", "verifyFailed": "Kiểm tra thất bại", - "dimensions": "{{count}} chiều" + "dimensions": "{{count}} chiều", + "dimensionsMismatch": "{{count}} chiều — không khớp yêu cầu 1536" }, "ux": { "title": "Hành vi UX", diff --git a/ui/web/src/i18n/locales/zh/providers.json b/ui/web/src/i18n/locales/zh/providers.json index 19638325..f04cf097 100644 --- a/ui/web/src/i18n/locales/zh/providers.json +++ b/ui/web/src/i18n/locales/zh/providers.json @@ -158,11 +158,14 @@ "enable": "启用Embedding", "enableDesc": "允许此Provider为记忆、技能和知识图谱生成文本嵌入", "model": "Embedding模型", + "dimensions": "输出维度", + "dimensionsHint": "将输出截断为N维(如1536)。对于原生维度 > 1536的模型(如gemini-embedding-001 3072d)必须设置。", "apiBase": "Embedding API地址", "apiBasePlaceholder": "(与Provider相同)", "apiBaseHint": "如果Embedding端点与Provider的主API地址不同,可在此覆盖", "verify": "验证Embedding", - "verifyFailed": "Embedding验证失败" + "verifyFailed": "Embedding验证失败", + "dimensionsMismatch": "{{count}}维 — 不符合要求的1536" }, "toast": { "created": "Provider已创建", diff --git a/ui/web/src/i18n/locales/zh/system-settings.json b/ui/web/src/i18n/locales/zh/system-settings.json index a768bac6..b077e75a 100644 --- a/ui/web/src/i18n/locales/zh/system-settings.json +++ b/ui/web/src/i18n/locales/zh/system-settings.json @@ -16,11 +16,12 @@ "modelTip": "Embedding模型(如text-embedding-3-small)。必须支持/embeddings端点。", "providerPlaceholder": "选择Provider", "modelPlaceholder": "text-embedding-3-small", - "supportedProviders": "已验证1536d:OpenAI (text-embedding-3-small)、OpenRouter (openai/*)、Mistral (codestral-embed)。其他Provider如支持/embeddings并输出1536d也可使用。", + "supportedProviders": "已验证1536d:OpenAI (text-embedding-3-small/large)、Gemini (gemini-embedding-001 + dimensions)、Mistral (codestral-embed)、Cohere (embed-v4)、DashScope (text-embedding-v3 + dimensions)。原生维度 > 1536的模型需在Provider设置中配置dimensions。", "verify": "验证Embedding", "verifyRequired": "保存前请先验证Embedding模型。", "verifyFailed": "验证失败", - "dimensions": "{{count}}维" + "dimensions": "{{count}}维", + "dimensionsMismatch": "{{count}}维 — 不符合要求的1536" }, "ux": { "title": "UX行为", diff --git a/ui/web/src/pages/providers/hooks/use-provider-verify.ts b/ui/web/src/pages/providers/hooks/use-provider-verify.ts index ab2c90c0..076e3a0a 100644 --- a/ui/web/src/pages/providers/hooks/use-provider-verify.ts +++ b/ui/web/src/pages/providers/hooks/use-provider-verify.ts @@ -5,6 +5,7 @@ interface VerifyResult { valid: boolean; error?: string; dimensions?: number; + dimension_mismatch?: boolean; // true when output dims != 1536 } export function useProviderVerify() { @@ -44,13 +45,16 @@ export function useProviderVerify() { const [embResult, setEmbResult] = useState(null); const verifyEmbedding = useCallback( - async (providerId: string, model?: string) => { + async (providerId: string, model?: string, dimensions?: number) => { setEmbVerifying(true); setEmbResult(null); try { + const body: Record = {}; + if (model) body.model = model; + if (dimensions && dimensions > 0) body.dimensions = dimensions; const res = await http.post( `/v1/providers/${providerId}/verify-embedding`, - model ? { model } : {}, + body, ); setEmbResult(res); return res; diff --git a/ui/web/src/pages/providers/provider-detail/provider-overview.tsx b/ui/web/src/pages/providers/provider-detail/provider-overview.tsx index 018a29e7..e52f8b7c 100644 --- a/ui/web/src/pages/providers/provider-detail/provider-overview.tsx +++ b/ui/web/src/pages/providers/provider-detail/provider-overview.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { Copy, Loader2, CheckCircle2, XCircle } from "lucide-react"; +import { Copy, Loader2, CheckCircle2, XCircle, AlertTriangle } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; @@ -19,7 +19,7 @@ interface ProviderOverviewProps { } const NO_API_KEY_TYPES = new Set(["claude_cli", "acp", "chatgpt_oauth"]); -const NO_EMBEDDING_TYPES = new Set(["claude_cli", "acp", "chatgpt_oauth", "suno"]); +const NO_EMBEDDING_TYPES = new Set(["claude_cli", "acp", "chatgpt_oauth", "suno", "anthropic_native"]); export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps) { const { t } = useTranslation("providers"); @@ -44,6 +44,7 @@ export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps) const [embEnabled, setEmbEnabled] = useState(initEmb?.enabled ?? false); const [embModel, setEmbModel] = useState(initEmb?.model ?? ""); const [embApiBase, setEmbApiBase] = useState(initEmb?.api_base ?? ""); + const [embDimensions, setEmbDimensions] = useState(initEmb?.dimensions ? String(initEmb.dimensions) : ""); // Re-sync embedding state when provider changes (e.g. after save) useEffect(() => { @@ -51,11 +52,12 @@ export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps) setEmbEnabled(es?.enabled ?? false); setEmbModel(es?.model ?? ""); setEmbApiBase(es?.api_base ?? ""); + setEmbDimensions(es?.dimensions ? String(es.dimensions) : ""); }, [provider.settings]); // Verify embedding const { verifyEmbedding, embVerifying, embResult, resetEmb } = useProviderVerify(); - useEffect(() => { resetEmb(); }, [embModel, resetEmb]); + useEffect(() => { resetEmb(); }, [embModel, embDimensions, resetEmb]); // Save state const [saving, setSaving] = useState(false); @@ -76,10 +78,16 @@ export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps) // Merge embedding settings into existing settings if (showEmbedding) { const existing = (provider.settings || {}) as Record; + const parsedDims = embDimensions ? parseInt(embDimensions, 10) : 0; data.settings = { ...existing, embedding: embEnabled - ? { enabled: true, model: embModel.trim() || undefined, api_base: embApiBase.trim() || undefined } + ? { + enabled: true, + model: embModel.trim() || undefined, + api_base: embApiBase.trim() || undefined, + dimensions: parsedDims > 0 ? parsedDims : undefined, + } : { enabled: false }, }; } @@ -97,7 +105,8 @@ export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps) }; const handleVerifyEmbedding = () => { - verifyEmbedding(provider.id, embModel.trim() || undefined); + const parsedDims = embDimensions ? parseInt(embDimensions, 10) : 0; + verifyEmbedding(provider.id, embModel.trim() || undefined, parsedDims > 0 ? parsedDims : undefined); }; return ( @@ -183,6 +192,20 @@ export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps) /> +
+ + setEmbDimensions(e.target.value)} + placeholder="1536" + min={1} + className="text-base md:text-sm" + /> +

{t("embedding.dimensionsHint")}

+
+
{embResult && ( - + {embResult.valid ? ( <> - - {embResult.dimensions} dimensions + {embResult.dimension_mismatch ? ( + + ) : ( + + )} + {embResult.dimension_mismatch + ? t("embedding.dimensionsMismatch", { count: embResult.dimensions }) + : `${embResult.dimensions} dimensions`} ) : ( <> diff --git a/ui/web/src/types/provider.ts b/ui/web/src/types/provider.ts index 9b3b8310..8e70883b 100644 --- a/ui/web/src/types/provider.ts +++ b/ui/web/src/types/provider.ts @@ -30,6 +30,7 @@ export interface EmbeddingSettings { enabled: boolean; model?: string; api_base?: string; + dimensions?: number; // truncate output to N dims (e.g. 1536); 0/undefined = model default } /** Extract embedding settings from provider.settings */