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
This commit is contained in:
viettranx committed 2026-03-25 14:50:43 +07:00
1 parent fabec18606
commit 28a17c4d6b
13 files changed
+144 -47

No files matched your search

+8 -8
View File
@@ -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
}
+23 -7
View File
@@ -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.
+9 -7
View File
@@ -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.
@@ -27,16 +27,30 @@ const EMBEDDING_MODELS: Record<string, { id: string; name: string }[]> = {
// 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")}
</Button>
{embResult && (
<span className={`flex items-center gap-1 text-xs ${embResult.valid ? "text-emerald-600 dark:text-emerald-400" : "text-destructive"}`}>
<span className={`flex items-center gap-1 text-xs ${
embResult.valid
? embResult.dimension_mismatch
? "text-amber-600 dark:text-amber-400"
: "text-emerald-600 dark:text-emerald-400"
: "text-destructive"
}`}>
{embResult.valid ? (
<>
<CheckCircle2 className="h-3.5 w-3.5" />
{t("embedding.dimensions", { count: embResult.dimensions })}
{embResult.dimension_mismatch ? (
<AlertTriangle className="h-3.5 w-3.5" />
) : (
<CheckCircle2 className="h-3.5 w-3.5" />
)}
{embResult.dimension_mismatch
? t("embedding.dimensionsMismatch", { count: embResult.dimensions })
: t("embedding.dimensions", { count: embResult.dimensions })}
</>
) : (
<>
+4 -1
View File
@@ -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",
@@ -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",
+4 -1
View File
@@ -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",
@@ -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",
+4 -1
View File
@@ -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已创建",
@@ -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行为",
@@ -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<VerifyResult | null>(null);
const verifyEmbedding = useCallback(
async (providerId: string, model?: string) => {
async (providerId: string, model?: string, dimensions?: number) => {
setEmbVerifying(true);
setEmbResult(null);
try {
const body: Record<string, unknown> = {};
if (model) body.model = model;
if (dimensions && dimensions > 0) body.dimensions = dimensions;
const res = await http.post<VerifyResult>(
`/v1/providers/${providerId}/verify-embedding`,
model ? { model } : {},
body,
);
setEmbResult(res);
return res;
@@ -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<string, unknown>;
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)
/>
</div>
<div className="space-y-2">
<Label htmlFor="embDimensions">{t("embedding.dimensions")}</Label>
<Input
id="embDimensions"
type="number"
value={embDimensions}
onChange={(e) => setEmbDimensions(e.target.value)}
placeholder="1536"
min={1}
className="text-base md:text-sm"
/>
<p className="text-xs text-muted-foreground">{t("embedding.dimensionsHint")}</p>
</div>
<div className="space-y-2">
<Label htmlFor="embApiBase">{t("embedding.apiBase")}</Label>
<Input
@@ -207,11 +230,23 @@ export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps)
{t("embedding.verify")}
</Button>
{embResult && (
<span className={`flex items-center gap-1 text-xs ${embResult.valid ? "text-success" : "text-destructive"}`}>
<span className={`flex items-center gap-1 text-xs ${
embResult.valid
? embResult.dimension_mismatch
? "text-amber-600 dark:text-amber-400"
: "text-success"
: "text-destructive"
}`}>
{embResult.valid ? (
<>
<CheckCircle2 className="h-3.5 w-3.5" />
{embResult.dimensions} dimensions
{embResult.dimension_mismatch ? (
<AlertTriangle className="h-3.5 w-3.5" />
) : (
<CheckCircle2 className="h-3.5 w-3.5" />
)}
{embResult.dimension_mismatch
? t("embedding.dimensionsMismatch", { count: embResult.dimensions })
: `${embResult.dimensions} dimensions`}
</>
) : (
<>
+1
View File
@@ -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 */