Improve MemFuse recall via hybrid ranking and candidate generation.
Add RRF fusion with English word-level lexical scoring, tiered keyword fetch, and vector margin expansion (0.15/200) to fix pre-rank truncation; wire DashScope embedding bench path and update baseline to 28.8% recall@20. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import { loadMemindEnvFiles } from './memind-runtime-profile.mjs';
|
||||
|
||||
loadMemindEnvFiles(process.cwd());
|
||||
|
||||
process.env.MEMIND_EMBEDDING_PROVIDER =
|
||||
process.env.MEMIND_EMBEDDING_PROVIDER ?? 'dashscope';
|
||||
process.env.MEMIND_EMBEDDING_FROM_LLM_KEYS =
|
||||
process.env.MEMIND_EMBEDDING_FROM_LLM_KEYS ?? '1';
|
||||
process.env.MEMIND_EMBEDDING_MODEL =
|
||||
process.env.MEMIND_EMBEDDING_MODEL ?? 'text-embedding-v3';
|
||||
|
||||
export {
|
||||
embedText,
|
||||
embedQuery,
|
||||
prefetchEmbedTexts,
|
||||
probeEmbeddingAvailability,
|
||||
flushEmbeddingCache,
|
||||
__resetEmbeddingCacheForTests,
|
||||
} from './embed-memory-v2-openai-compat.mjs';
|
||||
|
||||
export { default } from './embed-memory-v2-openai-compat.mjs';
|
||||
@@ -0,0 +1,363 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { loadMemindEnvFiles } from './memind-runtime-profile.mjs';
|
||||
import { resolveLlmEmbeddingCredentials } from './resolve-llm-embedding-credentials.mjs';
|
||||
|
||||
loadMemindEnvFiles(process.cwd());
|
||||
|
||||
const memoryCache = new Map();
|
||||
let diskEntries = null;
|
||||
let diskCachePath = null;
|
||||
let diskDirty = false;
|
||||
let llmCredentialCache = null;
|
||||
|
||||
function envFlag(value) {
|
||||
return ['1', 'true', 'yes', 'on'].includes(String(value ?? '').trim().toLowerCase());
|
||||
}
|
||||
|
||||
async function resolveConfig(env = process.env) {
|
||||
const provider = String(env.MEMIND_EMBEDDING_PROVIDER ?? 'openai').trim().toLowerCase();
|
||||
let apiKey = String(
|
||||
env.MEMIND_EMBEDDING_API_KEY
|
||||
?? env.DASHSCOPE_API_KEY
|
||||
?? env.OPENAI_API_KEY
|
||||
?? env.OPENROUTER_API_KEY
|
||||
?? '',
|
||||
).trim();
|
||||
let baseUrl = String(
|
||||
env.MEMIND_EMBEDDING_BASE_URL
|
||||
?? (provider === 'ollama'
|
||||
? 'http://127.0.0.1:11434'
|
||||
: provider === 'dashscope'
|
||||
? 'https://dashscope.aliyuncs.com/compatible-mode/v1'
|
||||
: env.OPENAI_API_BASE_URL ?? 'https://api.openai.com/v1'),
|
||||
).trim().replace(/\/$/, '');
|
||||
let model = String(
|
||||
env.MEMIND_EMBEDDING_MODEL
|
||||
?? (provider === 'ollama'
|
||||
? 'nomic-embed-text'
|
||||
: provider === 'dashscope'
|
||||
? 'text-embedding-v3'
|
||||
: 'text-embedding-3-small'),
|
||||
).trim();
|
||||
const cachePath = String(
|
||||
env.MEMIND_EMBEDDING_CACHE_PATH
|
||||
?? '.release-gate/memfuse-embedding-cache.json',
|
||||
).trim();
|
||||
const batchSize = Math.max(1, Math.min(256, Number(env.MEMIND_EMBEDDING_BATCH_SIZE ?? 64) || 64));
|
||||
const dimensions = Number(env.MEMIND_EMBEDDING_DIMENSIONS ?? 0) || null;
|
||||
const useLlmKeys = envFlag(env.MEMIND_EMBEDDING_FROM_LLM_KEYS)
|
||||
|| (provider === 'dashscope' && !apiKey);
|
||||
|
||||
if (useLlmKeys && !apiKey) {
|
||||
if (!llmCredentialCache) {
|
||||
llmCredentialCache = await resolveLlmEmbeddingCredentials({ env });
|
||||
}
|
||||
if (llmCredentialCache.available) {
|
||||
apiKey = llmCredentialCache.apiKey;
|
||||
baseUrl = llmCredentialCache.baseUrl ?? baseUrl;
|
||||
if (!env.MEMIND_EMBEDDING_MODEL) model = llmCredentialCache.model ?? model;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
model,
|
||||
cachePath,
|
||||
batchSize,
|
||||
dimensions,
|
||||
llmKeyName: llmCredentialCache?.available ? llmCredentialCache.keyName : null,
|
||||
};
|
||||
}
|
||||
|
||||
function hashCacheKey(text, model) {
|
||||
return crypto.createHash('sha256').update(`${model}\0${text}`).digest('hex');
|
||||
}
|
||||
|
||||
async function ensureDiskCache(config) {
|
||||
if (diskEntries && diskCachePath === config.cachePath) return diskEntries;
|
||||
diskCachePath = config.cachePath;
|
||||
diskEntries = new Map();
|
||||
diskDirty = false;
|
||||
if (!config.cachePath) return diskEntries;
|
||||
try {
|
||||
const raw = await fs.readFile(config.cachePath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
for (const [key, value] of Object.entries(parsed?.entries ?? {})) {
|
||||
if (Array.isArray(value) && value.every((item) => Number.isFinite(Number(item)))) {
|
||||
diskEntries.set(key, value.map(Number));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err && typeof err === 'object' && err.code === 'ENOENT') return diskEntries;
|
||||
if (err instanceof SyntaxError) {
|
||||
const corruptPath = `${path.resolve(config.cachePath)}.corrupt`;
|
||||
try {
|
||||
await fs.rename(path.resolve(config.cachePath), corruptPath);
|
||||
} catch {
|
||||
// ignore rename failure; start with empty cache
|
||||
}
|
||||
return diskEntries;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return diskEntries;
|
||||
}
|
||||
|
||||
async function persistDiskCache(config) {
|
||||
if (!diskDirty || !config.cachePath || !diskEntries) return;
|
||||
const target = path.resolve(config.cachePath);
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
const payload = `${JSON.stringify({ model: config.model, entries: Object.fromEntries(diskEntries) }, null, 2)}\n`;
|
||||
const tempPath = `${target}.tmp`;
|
||||
await fs.writeFile(tempPath, payload, 'utf8');
|
||||
await fs.rename(tempPath, target);
|
||||
diskDirty = false;
|
||||
}
|
||||
|
||||
function normalizeVector(value) {
|
||||
if (!Array.isArray(value)) return null;
|
||||
const numbers = value.map((item) => Number(item));
|
||||
if (!numbers.length || numbers.some((item) => !Number.isFinite(item))) return null;
|
||||
return numbers;
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function fetchWithRetry(label, fn, {
|
||||
retries = 6,
|
||||
baseDelayMs = 500,
|
||||
env = process.env,
|
||||
} = {}) {
|
||||
const maxRetries = Math.max(0, Number(env.MEMIND_EMBEDDING_MAX_RETRIES ?? retries) || retries);
|
||||
const delayMs = Math.max(100, Number(env.MEMIND_EMBEDDING_RETRY_BASE_MS ?? baseDelayMs) || baseDelayMs);
|
||||
let lastError = null;
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const retriable = /(?:429|503|502|504|rate limit|limit_requests|timeout|fetch failed)/i.test(message);
|
||||
if (!retriable || attempt === maxRetries) throw err;
|
||||
await sleep(delayMs * (2 ** attempt));
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
async function fetchOpenAiEmbeddingsBatch(texts, config, fetchImpl = fetch) {
|
||||
if (!config.apiKey) {
|
||||
throw new Error(
|
||||
'MEMIND_EMBEDDING_API_KEY / OPENAI_API_KEY is required for OpenAI-compatible embeddings',
|
||||
);
|
||||
}
|
||||
const requestDelayMs = Math.max(
|
||||
0,
|
||||
Number(process.env.MEMIND_EMBEDDING_REQUEST_DELAY_MS ?? 300) || 0,
|
||||
);
|
||||
const payload = await fetchWithRetry('openai-embedding-batch', async () => {
|
||||
const response = await fetchImpl(`${config.baseUrl}/embeddings`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: config.model,
|
||||
input: texts,
|
||||
encoding_format: 'float',
|
||||
...(config.dimensions ? { dimensions: config.dimensions } : {}),
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '');
|
||||
throw new Error(
|
||||
`Embedding request failed (${response.status}): ${detail.slice(0, 240)}`,
|
||||
);
|
||||
}
|
||||
return response.json();
|
||||
}, { env: process.env });
|
||||
const rows = Array.isArray(payload?.data) ? payload.data : [];
|
||||
rows.sort((left, right) => Number(left?.index ?? 0) - Number(right?.index ?? 0));
|
||||
const vectors = rows.map((row) => normalizeVector(row?.embedding));
|
||||
if (vectors.length !== texts.length || vectors.some((vector) => !vector)) {
|
||||
throw new Error('Embedding batch response size mismatch');
|
||||
}
|
||||
if (requestDelayMs > 0) await sleep(requestDelayMs);
|
||||
return vectors;
|
||||
}
|
||||
|
||||
async function fetchOpenAiEmbedding(text, config, fetchImpl = fetch) {
|
||||
const [vector] = await fetchOpenAiEmbeddingsBatch([text], config, fetchImpl);
|
||||
return vector;
|
||||
}
|
||||
|
||||
async function fetchOllamaEmbedding(text, config, fetchImpl = fetch) {
|
||||
const response = await fetchImpl(`${config.baseUrl}/api/embed`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: config.model,
|
||||
input: text,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '');
|
||||
throw new Error(
|
||||
`Ollama embedding request failed (${response.status}): ${detail.slice(0, 240)}`,
|
||||
);
|
||||
}
|
||||
const payload = await response.json();
|
||||
const vector = normalizeVector(payload?.embeddings?.[0]);
|
||||
if (!vector) throw new Error('Ollama embedding response missing embeddings[0]');
|
||||
return vector;
|
||||
}
|
||||
|
||||
async function fetchRemoteEmbedding(text, config, fetchImpl = fetch) {
|
||||
if (config.provider === 'ollama') {
|
||||
return fetchOllamaEmbedding(text, config, fetchImpl);
|
||||
}
|
||||
return fetchOpenAiEmbedding(text, config, fetchImpl);
|
||||
}
|
||||
|
||||
export async function embedText(text, options = {}) {
|
||||
const config = await resolveConfig(options.env ?? process.env);
|
||||
const normalized = String(text ?? '').trim();
|
||||
if (!normalized) {
|
||||
throw new Error('embedText requires non-empty text');
|
||||
}
|
||||
const key = hashCacheKey(normalized, config.model);
|
||||
if (memoryCache.has(key)) return memoryCache.get(key);
|
||||
|
||||
const disk = await ensureDiskCache(config);
|
||||
if (disk.has(key)) {
|
||||
const cached = disk.get(key);
|
||||
memoryCache.set(key, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const vector = await fetchRemoteEmbedding(
|
||||
normalized,
|
||||
config,
|
||||
options.fetchImpl ?? fetch,
|
||||
);
|
||||
memoryCache.set(key, vector);
|
||||
disk.set(key, vector);
|
||||
diskDirty = true;
|
||||
if (options.persist !== false && !envFlag(process.env.MEMIND_EMBEDDING_DEFER_PERSIST)) {
|
||||
await persistDiskCache(config);
|
||||
}
|
||||
return vector;
|
||||
}
|
||||
|
||||
function resolveBatchSize(config, env = process.env) {
|
||||
const configured = Number(env.MEMIND_EMBEDDING_BATCH_SIZE ?? config.batchSize ?? 10);
|
||||
const maxBatch = config.provider === 'dashscope' ? 10 : 64;
|
||||
return Math.max(1, Math.min(maxBatch, configured || 10));
|
||||
}
|
||||
|
||||
export async function prefetchEmbedTexts(texts, options = {}) {
|
||||
const env = options.env ?? process.env;
|
||||
const config = await resolveConfig(env);
|
||||
const disk = await ensureDiskCache(config);
|
||||
const normalized = [...new Set(
|
||||
(Array.isArray(texts) ? texts : [])
|
||||
.map((text) => String(text ?? '').trim())
|
||||
.filter(Boolean),
|
||||
)];
|
||||
const missing = normalized.filter((text) => {
|
||||
const key = hashCacheKey(text, config.model);
|
||||
return !memoryCache.has(key) && !disk.has(key);
|
||||
});
|
||||
if (missing.length === 0) return { requested: normalized.length, fetched: 0 };
|
||||
|
||||
const batchSize = resolveBatchSize(config, env);
|
||||
let fetched = 0;
|
||||
for (let index = 0; index < missing.length; index += batchSize) {
|
||||
const chunk = missing.slice(index, index + batchSize);
|
||||
const vectors = config.provider === 'ollama'
|
||||
? await Promise.all(chunk.map((text) => fetchOllamaEmbedding(text, config, options.fetchImpl ?? fetch)))
|
||||
: await fetchOpenAiEmbeddingsBatch(chunk, config, options.fetchImpl ?? fetch);
|
||||
for (let offset = 0; offset < chunk.length; offset += 1) {
|
||||
const text = chunk[offset];
|
||||
const key = hashCacheKey(text, config.model);
|
||||
memoryCache.set(key, vectors[offset]);
|
||||
disk.set(key, vectors[offset]);
|
||||
fetched += 1;
|
||||
}
|
||||
diskDirty = true;
|
||||
}
|
||||
if (options.persist !== false) {
|
||||
await persistDiskCache(config);
|
||||
}
|
||||
return { requested: normalized.length, fetched };
|
||||
}
|
||||
|
||||
export async function embedQuery(query, input = {}, options = {}) {
|
||||
return embedText(query, {
|
||||
...options,
|
||||
env: input.env ?? options.env,
|
||||
});
|
||||
}
|
||||
|
||||
export async function flushEmbeddingCache(options = {}) {
|
||||
const config = await resolveConfig(options.env ?? process.env);
|
||||
await persistDiskCache(config);
|
||||
}
|
||||
|
||||
export async function probeEmbeddingAvailability(options = {}) {
|
||||
const config = await resolveConfig(options.env ?? process.env);
|
||||
if (config.provider !== 'ollama' && !config.apiKey) {
|
||||
return {
|
||||
available: false,
|
||||
reason: config.provider === 'dashscope'
|
||||
? 'dashscope_api_key_missing_or_llm_keys_unavailable'
|
||||
: 'embedding_api_key_missing',
|
||||
provider: config.provider,
|
||||
model: config.model,
|
||||
baseUrl: config.baseUrl,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const vector = await embedText('memfuse embedding probe', {
|
||||
env: options.env ?? process.env,
|
||||
fetchImpl: options.fetchImpl ?? fetch,
|
||||
persist: false,
|
||||
});
|
||||
await flushEmbeddingCache({ env: options.env ?? process.env });
|
||||
return {
|
||||
available: true,
|
||||
provider: config.provider,
|
||||
model: config.model,
|
||||
baseUrl: config.baseUrl,
|
||||
llmKeyName: config.llmKeyName,
|
||||
dimensions: vector.length,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
available: false,
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
provider: config.provider,
|
||||
model: config.model,
|
||||
baseUrl: config.baseUrl,
|
||||
llmKeyName: config.llmKeyName,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function __resetEmbeddingCacheForTests() {
|
||||
memoryCache.clear();
|
||||
diskEntries = null;
|
||||
diskCachePath = null;
|
||||
diskDirty = false;
|
||||
llmCredentialCache = null;
|
||||
}
|
||||
|
||||
export default embedQuery;
|
||||
@@ -0,0 +1,116 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
__resetEmbeddingCacheForTests,
|
||||
embedQuery,
|
||||
embedText,
|
||||
probeEmbeddingAvailability,
|
||||
} from './embed-memory-v2-openai-compat.mjs';
|
||||
|
||||
function mockFetch(responseFactory) {
|
||||
return async (url, init) => {
|
||||
const call = { url: String(url), init };
|
||||
return responseFactory(call);
|
||||
};
|
||||
}
|
||||
|
||||
test('embedText uses OpenAI-compatible /embeddings and caches by text hash', async () => {
|
||||
__resetEmbeddingCacheForTests();
|
||||
const calls = [];
|
||||
const fetchImpl = mockFetch(({ url, init }) => {
|
||||
calls.push({ url, body: JSON.parse(String(init.body)) });
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return { data: [{ embedding: [0.1, 0.2, 0.3] }] };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const first = await embedText('Sarah closed the curtains', {
|
||||
env: {
|
||||
MEMIND_EMBEDDING_PROVIDER: 'openai',
|
||||
MEMIND_EMBEDDING_API_KEY: 'test-key',
|
||||
MEMIND_EMBEDDING_BASE_URL: 'https://example.com/v1',
|
||||
MEMIND_EMBEDDING_MODEL: 'text-embedding-3-small',
|
||||
MEMIND_EMBEDDING_CACHE_PATH: '',
|
||||
},
|
||||
fetchImpl,
|
||||
persist: false,
|
||||
});
|
||||
const second = await embedText('Sarah closed the curtains', {
|
||||
env: {
|
||||
MEMIND_EMBEDDING_PROVIDER: 'openai',
|
||||
MEMIND_EMBEDDING_API_KEY: 'test-key',
|
||||
MEMIND_EMBEDDING_BASE_URL: 'https://example.com/v1',
|
||||
MEMIND_EMBEDDING_MODEL: 'text-embedding-3-small',
|
||||
MEMIND_EMBEDDING_CACHE_PATH: '',
|
||||
},
|
||||
fetchImpl,
|
||||
persist: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(first, [0.1, 0.2, 0.3]);
|
||||
assert.deepEqual(second, first);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].url, 'https://example.com/v1/embeddings');
|
||||
assert.equal(calls[0].body.model, 'text-embedding-3-small');
|
||||
});
|
||||
|
||||
test('embedQuery delegates to embedText', async () => {
|
||||
__resetEmbeddingCacheForTests();
|
||||
const vector = await embedQuery('probe question', {}, {
|
||||
env: {
|
||||
MEMIND_EMBEDDING_PROVIDER: 'openai',
|
||||
MEMIND_EMBEDDING_API_KEY: 'test-key',
|
||||
MEMIND_EMBEDDING_BASE_URL: 'https://example.com/v1',
|
||||
MEMIND_EMBEDDING_CACHE_PATH: '',
|
||||
},
|
||||
fetchImpl: mockFetch(() => ({
|
||||
ok: true,
|
||||
async json() {
|
||||
return { data: [{ embedding: [0.5, 0.6] }] };
|
||||
},
|
||||
})),
|
||||
persist: false,
|
||||
});
|
||||
assert.deepEqual(vector, [0.5, 0.6]);
|
||||
});
|
||||
|
||||
test('ollama provider uses /api/embed without API key', async () => {
|
||||
__resetEmbeddingCacheForTests();
|
||||
let seenUrl = null;
|
||||
const vector = await embedText('local semantic probe', {
|
||||
env: {
|
||||
MEMIND_EMBEDDING_PROVIDER: 'ollama',
|
||||
MEMIND_EMBEDDING_BASE_URL: 'http://127.0.0.1:11434',
|
||||
MEMIND_EMBEDDING_MODEL: 'nomic-embed-text',
|
||||
MEMIND_EMBEDDING_CACHE_PATH: '',
|
||||
},
|
||||
fetchImpl: async (url) => {
|
||||
seenUrl = String(url);
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return { embeddings: [[0.9, 0.1, 0.4]] };
|
||||
},
|
||||
};
|
||||
},
|
||||
persist: false,
|
||||
});
|
||||
assert.equal(seenUrl, 'http://127.0.0.1:11434/api/embed');
|
||||
assert.deepEqual(vector, [0.9, 0.1, 0.4]);
|
||||
});
|
||||
|
||||
test('probeEmbeddingAvailability reports missing API key for openai provider', async () => {
|
||||
__resetEmbeddingCacheForTests();
|
||||
const probe = await probeEmbeddingAvailability({
|
||||
env: {
|
||||
MEMIND_EMBEDDING_PROVIDER: 'openai',
|
||||
MEMIND_EMBEDDING_CACHE_PATH: '',
|
||||
},
|
||||
});
|
||||
assert.equal(probe.available, false);
|
||||
assert.equal(probe.reason, 'embedding_api_key_missing');
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { createDbPool } from '../db.mjs';
|
||||
import { decryptSecret } from '../llm-providers.mjs';
|
||||
|
||||
function isDashScopeProviderRow(row) {
|
||||
if (!row) return false;
|
||||
const providerId = String(row.provider_id ?? '').trim();
|
||||
const apiUrl = String(row.api_url ?? '').trim().toLowerCase();
|
||||
if (providerId === 'custom_qwen') return true;
|
||||
return apiUrl.includes('dashscope.aliyuncs.com');
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(apiUrl) {
|
||||
const raw = String(apiUrl ?? '').trim().replace(/\/$/, '');
|
||||
if (!raw) return 'https://dashscope.aliyuncs.com/compatible-mode/v1';
|
||||
if (raw.endsWith('/embeddings')) return raw.slice(0, -'/embeddings'.length);
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve DashScope / Qwen embedding credentials from memind_adm-managed
|
||||
* `h5_llm_provider_keys`, the same store used by Providers in memind_adm.
|
||||
*/
|
||||
export async function resolveLlmEmbeddingCredentials({
|
||||
pool = null,
|
||||
keyId = null,
|
||||
env = process.env,
|
||||
} = {}) {
|
||||
const ownsPool = !pool;
|
||||
const db = pool ?? createDbPool();
|
||||
try {
|
||||
const explicitKeyId = String(keyId ?? env.MEMIND_EMBEDDING_LLM_KEY_ID ?? '').trim();
|
||||
let row = null;
|
||||
if (explicitKeyId) {
|
||||
const [rows] = await db.query(
|
||||
'SELECT * FROM h5_llm_provider_keys WHERE id = ? AND status = ? LIMIT 1',
|
||||
[explicitKeyId, 'active'],
|
||||
);
|
||||
row = rows[0] ?? null;
|
||||
} else {
|
||||
const [selectedRows] = await db.query(
|
||||
'SELECT * FROM h5_llm_provider_keys WHERE is_selected = 1 AND status = ? LIMIT 1',
|
||||
['active'],
|
||||
);
|
||||
if (isDashScopeProviderRow(selectedRows[0])) {
|
||||
row = selectedRows[0];
|
||||
}
|
||||
if (!row) {
|
||||
const [dashRows] = await db.query(
|
||||
`SELECT * FROM h5_llm_provider_keys
|
||||
WHERE status = ?
|
||||
AND (
|
||||
provider_id = 'custom_qwen'
|
||||
OR api_url LIKE '%dashscope.aliyuncs.com%'
|
||||
)
|
||||
ORDER BY is_selected DESC, updated_at DESC
|
||||
LIMIT 1`,
|
||||
['active'],
|
||||
);
|
||||
row = dashRows[0] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!row) {
|
||||
return { available: false, reason: 'dashscope_llm_key_not_found' };
|
||||
}
|
||||
if (!isDashScopeProviderRow(row)) {
|
||||
return { available: false, reason: 'llm_key_not_dashscope', providerId: row.provider_id };
|
||||
}
|
||||
|
||||
const apiKey = decryptSecret(
|
||||
{
|
||||
ciphertext: row.api_key_ciphertext,
|
||||
iv: row.api_key_iv,
|
||||
tag: row.api_key_tag,
|
||||
},
|
||||
env.H5_SETTINGS_ENCRYPTION_KEY ?? env.TKMIND_SERVER__SECRET_KEY,
|
||||
);
|
||||
if (!apiKey) {
|
||||
return { available: false, reason: 'dashscope_api_key_decrypt_failed', keyName: row.name };
|
||||
}
|
||||
|
||||
return {
|
||||
available: true,
|
||||
provider: 'dashscope',
|
||||
keyId: row.id,
|
||||
keyName: row.name,
|
||||
providerId: row.provider_id,
|
||||
apiKey,
|
||||
baseUrl: normalizeBaseUrl(row.api_url),
|
||||
chatModel: row.default_model,
|
||||
model: String(env.MEMIND_EMBEDDING_MODEL ?? 'text-embedding-v3').trim(),
|
||||
};
|
||||
} finally {
|
||||
if (ownsPool) await db.end();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { encryptSecret } from '../llm-providers.mjs';
|
||||
import { resolveLlmEmbeddingCredentials } from './resolve-llm-embedding-credentials.mjs';
|
||||
|
||||
function makePool(rowsByQuery) {
|
||||
return {
|
||||
async query(sql, params = []) {
|
||||
const text = String(sql);
|
||||
if (text.includes('WHERE id = ?')) {
|
||||
const row = rowsByQuery.byId?.get(params[0]);
|
||||
return [row ? [row] : []];
|
||||
}
|
||||
if (text.includes('is_selected = 1')) {
|
||||
return [rowsByQuery.selected ?? []];
|
||||
}
|
||||
if (text.includes('dashscope.aliyuncs.com')) {
|
||||
return [rowsByQuery.dashscope ?? []];
|
||||
}
|
||||
return [[]];
|
||||
},
|
||||
async end() {},
|
||||
};
|
||||
}
|
||||
|
||||
test('resolveLlmEmbeddingCredentials decrypts selected DashScope key', async () => {
|
||||
const secret = encryptSecret('dashscope-test-key', 'unit-test-secret');
|
||||
const row = {
|
||||
id: 'key-1',
|
||||
name: 'qwen3-max',
|
||||
provider_id: 'custom_qwen3-max_1',
|
||||
api_url: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
default_model: 'qwen3-max',
|
||||
api_key_ciphertext: secret.ciphertext,
|
||||
api_key_iv: secret.iv,
|
||||
api_key_tag: secret.tag,
|
||||
};
|
||||
const resolved = await resolveLlmEmbeddingCredentials({
|
||||
pool: makePool({ selected: [row] }),
|
||||
env: { TKMIND_SERVER__SECRET_KEY: 'unit-test-secret' },
|
||||
});
|
||||
assert.equal(resolved.available, true);
|
||||
assert.equal(resolved.apiKey, 'dashscope-test-key');
|
||||
assert.equal(resolved.baseUrl, 'https://dashscope.aliyuncs.com/compatible-mode/v1');
|
||||
assert.equal(resolved.keyName, 'qwen3-max');
|
||||
});
|
||||
|
||||
test('resolveLlmEmbeddingCredentials falls back when selected key is not DashScope', async () => {
|
||||
const secret = encryptSecret('dashscope-fallback-key', 'unit-test-secret');
|
||||
const dashRow = {
|
||||
id: 'ds-1',
|
||||
name: 'qwen chat',
|
||||
provider_id: 'custom_qwen_chat',
|
||||
api_url: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
default_model: 'qwen-plus',
|
||||
api_key_ciphertext: secret.ciphertext,
|
||||
api_key_iv: secret.iv,
|
||||
api_key_tag: secret.tag,
|
||||
};
|
||||
const resolved = await resolveLlmEmbeddingCredentials({
|
||||
pool: makePool({
|
||||
selected: [{
|
||||
id: 'deepseek',
|
||||
name: 'DeepSeek',
|
||||
provider_id: 'custom_deepseek',
|
||||
api_url: null,
|
||||
default_model: 'deepseek-v4-pro',
|
||||
api_key_ciphertext: secret.ciphertext,
|
||||
api_key_iv: secret.iv,
|
||||
api_key_tag: secret.tag,
|
||||
}],
|
||||
dashscope: [dashRow],
|
||||
}),
|
||||
env: { TKMIND_SERVER__SECRET_KEY: 'unit-test-secret' },
|
||||
});
|
||||
assert.equal(resolved.available, true);
|
||||
assert.equal(resolved.apiKey, 'dashscope-fallback-key');
|
||||
assert.equal(resolved.keyName, 'qwen chat');
|
||||
});
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
summarizeMemFuseBench,
|
||||
} from '../memory-v2-memfuse-bench.mjs';
|
||||
import { resolveEmbeddingModuleSpecifier } from '../memory-v2-recall-benchmark.mjs';
|
||||
import { loadMemindEnvFiles } from './memind-runtime-profile.mjs';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
@@ -102,6 +103,12 @@ async function loadEmbedder(specifier) {
|
||||
if (typeof embed !== 'function') {
|
||||
throw new Error(`Embedding module must export embedText, embedQuery or default: ${specifier}`);
|
||||
}
|
||||
if (typeof imported.prefetchEmbedTexts === 'function') {
|
||||
embed.prefetchEmbedTexts = imported.prefetchEmbedTexts.bind(imported);
|
||||
}
|
||||
if (typeof imported.flushEmbeddingCache === 'function') {
|
||||
embed.flushEmbeddingCache = imported.flushEmbeddingCache.bind(imported);
|
||||
}
|
||||
return embed;
|
||||
}
|
||||
|
||||
@@ -168,6 +175,7 @@ function printReport(report) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
loadMemindEnvFiles(process.cwd());
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
usage();
|
||||
@@ -195,6 +203,27 @@ async function main() {
|
||||
const embedText = options.embeddingModule
|
||||
? await loadEmbedder(options.embeddingModule)
|
||||
: null;
|
||||
const prefetchEmbedTexts = embedText?.prefetchEmbedTexts ?? null;
|
||||
|
||||
if (options.embeddingModule) {
|
||||
const resolved = resolveEmbeddingModuleSpecifier(options.embeddingModule);
|
||||
const imported = await import(resolved);
|
||||
if (typeof imported.probeEmbeddingAvailability === 'function') {
|
||||
const probe = await imported.probeEmbeddingAvailability();
|
||||
if (!probe.available) {
|
||||
process.stderr.write(
|
||||
`Embedding probe failed (${probe.reason}).\n` +
|
||||
'Set MEMIND_EMBEDDING_API_KEY / OPENAI_API_KEY, or MEMIND_EMBEDDING_PROVIDER=ollama.\n',
|
||||
);
|
||||
return options.strict ? 1 : 0;
|
||||
}
|
||||
if (!options.quiet) {
|
||||
process.stderr.write(
|
||||
`embedding probe ok: provider=${probe.provider} model=${probe.model} dims=${probe.dimensions}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let lastScenario = null;
|
||||
const report = await runMemFuseBench({
|
||||
@@ -207,6 +236,7 @@ async function main() {
|
||||
candidateLimit: options.candidateLimit,
|
||||
includeSourceTags: options.sourceTags,
|
||||
embedText,
|
||||
prefetchEmbedTexts,
|
||||
onProgress: options.quiet
|
||||
? null
|
||||
: (event) => {
|
||||
|
||||
Reference in New Issue
Block a user