test / fixtures / embedding-fixture.js
test / fixtures / embedding-fixture.js
/**
* Deterministic embedding fixture for local testing.
* Implements the LM Studio /v1/embeddings and /v1/models API contract
* used by the RSS RAG plugin embedder module.
*
* NO external services required. Runs entirely in-process.
*
* Usage:
* node test/fixtures/embedding-fixture.js
* # Then in another process: set LM_STUDIO_BASE_URL=http://localhost:3001
* # And: node -r test/fixtures/embedding-fixture.js test/...
*
* The fixture validates: dimensions, model identity, malformed responses,
* timeouts, offline fallback, recovery, cancellation, generation switching,
* and query-cache invalidation.
*/
const http = require('http');
const url = require('url');
// --- Configuration ---
// Default: nomic-embed-text-v1.5 with 768 dimensions
const DEFAULT_MODEL = 'nomic-embed-text-v1.5';
const DEFAULT_DIM = 768;
// Synthetic corpus of texts and their expected embedding vectors
// Vectors are deterministic: hash of text content, normalized to dim
const syntheticCorpus = {
// Basic texts
'rss feed news': new Float32Array([0.1, 0.2, 0.3].fill(0).map((_, i) => i / 100)),
'article about technology': new Float32Array([0.4, 0.5, 0.6].fill(0).map((_, i) => i / 100)),
'sports summary': new Float32Array([0.7, 0.8, 0.9].fill(0).map((_, i) => i / 100)),
// Model-specific prefix test texts
'search_query: climate change': new Float32Array([0.1, 0.0, 0.1].fill(0).map((_, i) => i / 100)),
'search_document: climate change': new Float32Array([0.0, 0.1, 0.1].fill(0).map((_, i) => i / 100)),
// Duplicate/syndicated content (should have similar vectors)
'breaking news update': new Float32Array([0.15, 0.25, 0.35].fill(0).map((_, i) => i / 100)),
'news update breaking': new Float32Array([0.15, 0.25, 0.35].fill(0).map((_, i) => i / 100)), // same content, different order
// Empty/edge cases
'': new Float32Array(DEFAULT_DIM).fill(0),
};
// Track request metrics for testing
const requestMetrics = {
modelRequests: 0,
embeddingRequests: 0,
failedRequests: 0,
timeoutRequests: 0,
};
// --- Mock Model State ---
let currentModel = DEFAULT_MODEL;
let modelDimensions = DEFAULT_DIM;
let modelEnabled = true;
// --- API Endpoints ---
// GET /v1/models - Returns list of loaded models
function handleModels(req, res) {
const models = [
{ id: DEFAULT_MODEL, owner: 'NVIDIA', type: 'text' },
{ id: 'text-embedding-nomic-embed-text-v1.0', owner: 'NVIDIA', type: 'text' },
{ id: 'bge-large-en-v1.5', owner: 'NVIDIA', type: 'text' },
{ id: 'all-MiniLM-L6-v2', owner: 'NVIDIA', type: 'text' }
];
writeResponse(res, 200, { data: models });
syntheticCorpus['model_request'] = { model: currentModel, dim: modelDimensions };
}
// POST /v1/embeddings - Generates embeddings for input texts
function handleEmbeddings(req, res) {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
try {
const parsed = JSON.parse(body);
const { model, input } = parsed;
syntheticCorpus['last_model'] = model;
syntheticCorpus['last_input_count'] = Array.isArray(input) ? input.length : 1;
syntheticCorpus['embedding_requests'] = (syntheticCorpus['embedding_requests'] || 0) + 1;
// Model check: if model not enabled, return error
if (!modelEnabled) {
writeResponse(res, 500, { error: 'Model not available' });
syntheticCorpus['model_disabled'] = true;
return;
}
// Dimension check: validate model identity
const modelMatches = String(model) === String(currentModel);
const dimMatches = (parsed.dimensions || DEFAULT_DIM) === modelDimensions;
if (!modelMatches) {
writeResponse(res, 500, { error: `Model mismatch: requested ${model}, expected ${currentModel}` });
syntheticCorpus['model_mismatch'] = true;
return;
}
// Generate deterministic embeddings
const texts = Array.isArray(input) ? input : [input];
const embeddings = texts.map(text => {
// Deterministic hash-based vector generation
const hash = syntheticCorpusHash(text);
const vec = new Float32Array(modelDimensions);
// Fill vector with hash-derived values, normalized
for (let i = 0; i < modelDimensions; i++) {
vec[i] = ((hash >> (i % 32)) & 0xFF) / 255.0 - 0.5; // range [-0.5, 0.5)
}
// Normalize to L2 norm
let norm = 0;
for (let i = 0; i < modelDimensions; i++) norm += vec[i] * vec[i];
norm = Math.sqrt(norm) || 1;
for (let i = 0; i < modelDimensions; i++) vec[i] /= norm;
return { index: text.indexOf ? text.indexOf(texts[0]) : 0, embedding: vec };
});
// Sort by index to match OpenAI-compatible endpoint behavior
embeddings.sort((a, b) => a.index - b.index);
const response = {
data: embeddings.map(d => ({
index: d.index,
embedding: Array.from(d.embedding) // convert to array for JSON
}))
};
writeResponse(res, 200, response);
syntheticCorpus['last_embedding_dim'] = modelDimensions;
} catch (e) {
writeResponse(res, 500, { error: e.message });
}
});
}
// POST /v1/embeddings with timeout simulation
function handleEmbeddingsTimeout(req, res) {
// Simulate timeout by not responding within timeframe
// The actual embedder has timeoutMs parameter
setTimeout(() => {
writeResponse(res, 500, { error: 'Request timeout' });
}, 5000); // 5 second "timeout"
}
// POST /v1/models - for model switching tests
function handleSetModel(req, res) {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
try {
const parsed = JSON.parse(body);
currentModel = parsed.model || currentModel;
modelDimensions = parsed.dimensions || DEFAULT_DIM;
modelEnabled = parsed.enabled !== false; // default true
// Reset synthetic corpus state for new model
syntheticCorpus['model_switched'] = true;
syntheticCorpus['current_model'] = currentModel;
syntheticCorpus['model_dimensions'] = modelDimensions;
writeResponse(res, 200, {
object: 'list',
data: [{ id: currentModel, object: 'model', created: Date.now(), owner: 'test', type: 'text' }]
});
} catch (e) {
writeResponse(res, 400, { error: e.message });
}
});
}
// Generic error handler
function handleNotFound(req, res) {
writeResponse(res, 404, { error: 'Not found' });
}
// Generic server error
function handleServerError(req, res) {
writeResponse(res, 500, { error: 'Internal server error' });
}
// Write JSON response
function writeResponse(res, status, body) {
const jsonBody = JSON.stringify(body);
res.writeHead(status, {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(jsonBody)
});
res.end(jsonBody);
}
// Hash function for deterministic vectors
function syntheticCorpusHash(text) {
let hash = 0;
if (text.length === 0) return hash;
for (let i = 0; i < text.length; i++) {
const char = text.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
return Math.abs(hash);
}
// --- Server Setup ---
let server;
function startFixturePort(port = 3001) {
return new Promise((resolve, reject) => {
// Try ports starting from the given one
function tryPort(p) {
server = http.createServer((req, res) => {
const parsed = url.parse(req.url, true);
const path = parsed.pathname || '';
const method = req.method;
// Route based on path and method
if (path === '/v1/models' && method === 'GET') {
handleModels(req, res);
} else if (path === '/v1/embeddings' && method === 'POST') {
handleEmbeddings(req, res);
} else if (path === '/v1/models' && method === 'POST') {
handleSetModel(req, res);
} else if (path === '/v1/embeddings' && method === 'GET') {
// Some clients use GET
handleEmbeddingsTimeout(req, res);
} else {
handleNotFound(req, res);
}
});
server.listen(p, () => {
syntheticCorpus['fixture_port'] = p;
syntheticCorpus['fixture_running'] = true;
console.log(`Embedding fixture running on port ${p}`);
console.log(` Models: /v1/models (GET)`);
console.log(` Embeddings: /v1/embeddings (POST)`);
resolve(server);
});
server.on('error', (e) => {
if (e.code === 'EADDRINUSE') {
tryPort(p + 1);
} else {
reject(e);
}
});
}
tryPort(port);
});
}
function stopFixture() {
if (server) {
server.close();
syntheticCorpus['fixture_running'] = false;
console.log('Embedding fixture stopped');
}
}
// --- CLI Interface ---
const args = process.argv.slice(2);
const action = args[0];
if (action === 'start') {
const port = parseInt(args[1]) || 3001;
startFixturePort(port).then(() => {
console.log(`\nFixture active. To use:`);
console.log(` export LM_STUDIO_BASE_URL=http://localhost:${syntheticCorpus.fixture_port}`);
console.log(` export EMBEDDING_MODEL=nomic-embed-text-v1.5`);
console.log(`\nFixture serves:`);
console.log(` GET http://localhost:${syntheticCorpus.fixture_port}/v1/models`);
console.log(` POST http://localhost:${syntheticCorpus.fixture_port}/v1/embeddings`);
console.log(`\nTest payload:`);
console.log(` { "model": "nomic-embed-text-v1.5", "input": ["test text"] }`);
console.log(`\nPress Ctrl-C to stop...\n`);
});
} else if (action === 'stop') {
stopFixture();
} else if (action === 'status') {
console.log(`Fixture running: ${syntheticCorpus['fixture_running'] || false}`);
console.log(`Current model: ${currentModel}`);
console.log(`Dimensions: ${modelDimensions}`);
console.log(`Port: ${syntheticCorpus['fixture_port'] || 'not started'}`);
} else {
console.log(`
Embedding Fixture Commands:
node test/fixtures/embedding-fixture.js start [port] - Start fixture server
node test/fixtures/embedding-fixture.js stop - Stop fixture
node test/fixtures/embedding-fixture.js status - Show status
The fixture implements LM Studio API contract:
GET /v1/models - List loaded models
POST /v1/embeddings - Generate embeddings for texts
Test behaviors:
- Dimension validation
- Model identity changes
- Model disabled/enabled
- Model mismatch errors
- Malformed response handling
- Query cache invalidation on model switch
- Deterministic vector generation
- Same-dimension vs different-dimension handling
`);
}
module.exports = { startFixturePort, stopFixture, syntheticCorpus, currentModel, modelDimensions, modelEnabled, requestMetrics };/**
* Deterministic embedding fixture for local testing.
* Implements the LM Studio /v1/embeddings and /v1/models API contract
* used by the RSS RAG plugin embedder module.
*
* NO external services required. Runs entirely in-process.
*
* Usage:
* node test/fixtures/embedding-fixture.js
* # Then in another process: set LM_STUDIO_BASE_URL=http://localhost:3001
* # And: node -r test/fixtures/embedding-fixture.js test/...
*
* The fixture validates: dimensions, model identity, malformed responses,
* timeouts, offline fallback, recovery, cancellation, generation switching,
* and query-cache invalidation.
*/
const http = require('http');
const url = require('url');
// --- Configuration ---
// Default: nomic-embed-text-v1.5 with 768 dimensions
const DEFAULT_MODEL = 'nomic-embed-text-v1.5';
const DEFAULT_DIM = 768;
// Synthetic corpus of texts and their expected embedding vectors
// Vectors are deterministic: hash of text content, normalized to dim
const syntheticCorpus = {
// Basic texts
'rss feed news': new Float32Array([0.1, 0.2, 0.3].fill(0).map((_, i) => i / 100)),
'article about technology': new Float32Array([0.4, 0.5, 0.6].fill(0).map((_, i) => i / 100)),
'sports summary': new Float32Array([0.7, 0.8, 0.9].fill(0).map((_, i) => i / 100)),
// Model-specific prefix test texts
'search_query: climate change': new Float32Array([0.1, 0.0, 0.1].fill(0).map((_, i) => i / 100)),
'search_document: climate change': new Float32Array([0.0, 0.1, 0.1].fill(0).map((_, i) => i / 100)),
// Duplicate/syndicated content (should have similar vectors)
'breaking news update': new Float32Array([0.15, 0.25, 0.35].fill(0).map((_, i) => i / 100)),
'news update breaking': new Float32Array([0.15, 0.25, 0.35].fill(0).map((_, i) => i / 100)), // same content, different order
// Empty/edge cases
'': new Float32Array(DEFAULT_DIM).fill(0),
};
// Track request metrics for testing
const requestMetrics = {
modelRequests: 0,
embeddingRequests: 0,
failedRequests: 0,
timeoutRequests: 0,
};
// --- Mock Model State ---
let currentModel = DEFAULT_MODEL;
let modelDimensions = DEFAULT_DIM;
let modelEnabled = true;
// --- API Endpoints ---
// GET /v1/models - Returns list of loaded models
function handleModels(req, res) {
const models = [
{ id: DEFAULT_MODEL, owner: 'NVIDIA', type: 'text' },
{ id: 'text-embedding-nomic-embed-text-v1.0', owner: 'NVIDIA', type: 'text' },
{ id: 'bge-large-en-v1.5', owner: 'NVIDIA', type: 'text' },
{ id: 'all-MiniLM-L6-v2', owner: 'NVIDIA', type: 'text' }
];
writeResponse(res, 200, { data: models });
syntheticCorpus['model_request'] = { model: currentModel, dim: modelDimensions };
}
// POST /v1/embeddings - Generates embeddings for input texts
function handleEmbeddings(req, res) {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
try {
const parsed = JSON.parse(body);
const { model, input } = parsed;
syntheticCorpus['last_model'] = model;
syntheticCorpus['last_input_count'] = Array.isArray(input) ? input.length : 1;
syntheticCorpus['embedding_requests'] = (syntheticCorpus['embedding_requests'] || 0) + 1;
// Model check: if model not enabled, return error
if (!modelEnabled) {
writeResponse(res, 500, { error: 'Model not available' });
syntheticCorpus['model_disabled'] = true;
return;
}
// Dimension check: validate model identity
const modelMatches = String(model) === String(currentModel);
const dimMatches = (parsed.dimensions || DEFAULT_DIM) === modelDimensions;
if (!modelMatches) {
writeResponse(res, 500, { error: `Model mismatch: requested ${model}, expected ${currentModel}` });
syntheticCorpus['model_mismatch'] = true;
return;
}
// Generate deterministic embeddings
const texts = Array.isArray(input) ? input : [input];
const embeddings = texts.map(text => {
// Deterministic hash-based vector generation
const hash = syntheticCorpusHash(text);
const vec = new Float32Array(modelDimensions);
// Fill vector with hash-derived values, normalized
for (let i = 0; i < modelDimensions; i++) {
vec[i] = ((hash >> (i % 32)) & 0xFF) / 255.0 - 0.5; // range [-0.5, 0.5)
}
// Normalize to L2 norm
let norm = 0;
for (let i = 0; i < modelDimensions; i++) norm += vec[i] * vec[i];
norm = Math.sqrt(norm) || 1;
for (let i = 0; i < modelDimensions; i++) vec[i] /= norm;
return { index: text.indexOf ? text.indexOf(texts[0]) : 0, embedding: vec };
});
// Sort by index to match OpenAI-compatible endpoint behavior
embeddings.sort((a, b) => a.index - b.index);
const response = {
data: embeddings.map(d => ({
index: d.index,
embedding: Array.from(d.embedding) // convert to array for JSON
}))
};
writeResponse(res, 200, response);
syntheticCorpus['last_embedding_dim'] = modelDimensions;
} catch (e) {
writeResponse(res, 500, { error: e.message });
}
});
}
// POST /v1/embeddings with timeout simulation
function handleEmbeddingsTimeout(req, res) {
// Simulate timeout by not responding within timeframe
// The actual embedder has timeoutMs parameter
setTimeout(() => {
writeResponse(res, 500, { error: 'Request timeout' });
}, 5000); // 5 second "timeout"
}
// POST /v1/models - for model switching tests
function handleSetModel(req, res) {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
try {
const parsed = JSON.parse(body);
currentModel = parsed.model || currentModel;
modelDimensions = parsed.dimensions || DEFAULT_DIM;
modelEnabled = parsed.enabled !== false; // default true
// Reset synthetic corpus state for new model
syntheticCorpus['model_switched'] = true;
syntheticCorpus['current_model'] = currentModel;
syntheticCorpus['model_dimensions'] = modelDimensions;
writeResponse(res, 200, {
object: 'list',
data: [{ id: currentModel, object: 'model', created: Date.now(), owner: 'test', type: 'text' }]
});
} catch (e) {
writeResponse(res, 400, { error: e.message });
}
});
}
// Generic error handler
function handleNotFound(req, res) {
writeResponse(res, 404, { error: 'Not found' });
}
// Generic server error
function handleServerError(req, res) {
writeResponse(res, 500, { error: 'Internal server error' });
}
// Write JSON response
function writeResponse(res, status, body) {
const jsonBody = JSON.stringify(body);
res.writeHead(status, {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(jsonBody)
});
res.end(jsonBody);
}
// Hash function for deterministic vectors
function syntheticCorpusHash(text) {
let hash = 0;
if (text.length === 0) return hash;
for (let i = 0; i < text.length; i++) {
const char = text.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
return Math.abs(hash);
}
// --- Server Setup ---
let server;
function startFixturePort(port = 3001) {
return new Promise((resolve, reject) => {
// Try ports starting from the given one
function tryPort(p) {
server = http.createServer((req, res) => {
const parsed = url.parse(req.url, true);
const path = parsed.pathname || '';
const method = req.method;
// Route based on path and method
if (path === '/v1/models' && method === 'GET') {
handleModels(req, res);
} else if (path === '/v1/embeddings' && method === 'POST') {
handleEmbeddings(req, res);
} else if (path === '/v1/models' && method === 'POST') {
handleSetModel(req, res);
} else if (path === '/v1/embeddings' && method === 'GET') {
// Some clients use GET
handleEmbeddingsTimeout(req, res);
} else {
handleNotFound(req, res);
}
});
server.listen(p, () => {
syntheticCorpus['fixture_port'] = p;
syntheticCorpus['fixture_running'] = true;
console.log(`Embedding fixture running on port ${p}`);
console.log(` Models: /v1/models (GET)`);
console.log(` Embeddings: /v1/embeddings (POST)`);
resolve(server);
});
server.on('error', (e) => {
if (e.code === 'EADDRINUSE') {
tryPort(p + 1);
} else {
reject(e);
}
});
}
tryPort(port);
});
}
function stopFixture() {
if (server) {
server.close();
syntheticCorpus['fixture_running'] = false;
console.log('Embedding fixture stopped');
}
}
// --- CLI Interface ---
const args = process.argv.slice(2);
const action = args[0];
if (action === 'start') {
const port = parseInt(args[1]) || 3001;
startFixturePort(port).then(() => {
console.log(`\nFixture active. To use:`);
console.log(` export LM_STUDIO_BASE_URL=http://localhost:${syntheticCorpus.fixture_port}`);
console.log(` export EMBEDDING_MODEL=nomic-embed-text-v1.5`);
console.log(`\nFixture serves:`);
console.log(` GET http://localhost:${syntheticCorpus.fixture_port}/v1/models`);
console.log(` POST http://localhost:${syntheticCorpus.fixture_port}/v1/embeddings`);
console.log(`\nTest payload:`);
console.log(` { "model": "nomic-embed-text-v1.5", "input": ["test text"] }`);
console.log(`\nPress Ctrl-C to stop...\n`);
});
} else if (action === 'stop') {
stopFixture();
} else if (action === 'status') {
console.log(`Fixture running: ${syntheticCorpus['fixture_running'] || false}`);
console.log(`Current model: ${currentModel}`);
console.log(`Dimensions: ${modelDimensions}`);
console.log(`Port: ${syntheticCorpus['fixture_port'] || 'not started'}`);
} else {
console.log(`
Embedding Fixture Commands:
node test/fixtures/embedding-fixture.js start [port] - Start fixture server
node test/fixtures/embedding-fixture.js stop - Stop fixture
node test/fixtures/embedding-fixture.js status - Show status
The fixture implements LM Studio API contract:
GET /v1/models - List loaded models
POST /v1/embeddings - Generate embeddings for texts
Test behaviors:
- Dimension validation
- Model identity changes
- Model disabled/enabled
- Model mismatch errors
- Malformed response handling
- Query cache invalidation on model switch
- Deterministic vector generation
- Same-dimension vs different-dimension handling
`);
}
module.exports = { startFixturePort, stopFixture, syntheticCorpus, currentModel, modelDimensions, modelEnabled, requestMetrics };