fix: address review feedback — timeout, retry, guards, tests

- Add AbortSignal.timeout(30s) on all fetch calls
- Add retry with backoff for 429/5xx (core: 2 retries, MCP: 1 retry)
- Guard initEmbedder() and getEmbedder() to throw in HTTP mode
- Discard cached embeddings on dimension mismatch during incremental re-index
- Add MCP embedQuery retry for transient failures
- Add 16 unit tests covering both core and MCP HTTP paths
- Fix README: concise, accurate env var docs
This commit is contained in:
zm2231 2026-03-20 01:32:49 -04:00
parent 7e5747d26b
commit ab1095af7d
9 changed files with 331 additions and 51 deletions

View file

@ -10,7 +10,7 @@
"dependencies": {
"@huggingface/transformers": "^3.0.0",
"@isomorphic-git/lightning-fs": "^4.6.2",
"@ladybugdb/wasm-core": "^0.15.1",
"@ladybugdb/wasm-core": "^0.15.2",
"@langchain/anthropic": "^1.3.10",
"@langchain/core": "^1.1.15",
"@langchain/google-genai": "^2.1.10",
@ -131,6 +131,7 @@
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6",
@ -1644,9 +1645,9 @@
}
},
"node_modules/@ladybugdb/wasm-core": {
"version": "0.15.1",
"resolved": "https://registry.npmjs.org/@ladybugdb/wasm-core/-/wasm-core-0.15.1.tgz",
"integrity": "sha512-dHEq8inJQBkHnJrqZMKGdltSfeSv9OHECkzWQixqDLApXXGlbJ5Ugq5rRfk2PLJuZ74LVHT0cZvcn4JLmsnAIA==",
"version": "0.15.2",
"resolved": "https://registry.npmjs.org/@ladybugdb/wasm-core/-/wasm-core-0.15.2.tgz",
"integrity": "sha512-KIR+DBKPMEJlyBxESJF0t/hwQhtlshyhJwj7L5d8nF98R0+J/4bFYZN3mqzagQyi5CfMeIVqRp7gTxfJZ/gEuw==",
"license": "MIT",
"dependencies": {
"threads": "^1.7.0",
@ -1688,6 +1689,7 @@
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.15.tgz",
"integrity": "sha512-b8RN5DkWAmDAlMu/UpTZEluYwCLpm63PPWniRKlE8ie3KkkE7IuMQ38pf4kV1iaiI+d99BEQa2vafQHfCujsRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@cfworker/json-schema": "^4.0.2",
"ansi-styles": "^5.0.0",
@ -3336,6 +3338,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz",
"integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@ -3357,6 +3360,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@ -3572,6 +3576,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -3851,6 +3856,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@ -4294,6 +4300,7 @@
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz",
"integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10"
}
@ -4694,6 +4701,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@ -8351,6 +8359,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@ -8363,6 +8372,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@ -8625,6 +8635,7 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz",
"integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@ -8866,6 +8877,7 @@
"resolved": "https://registry.npmjs.org/sigma/-/sigma-3.0.2.tgz",
"integrity": "sha512-/BUbeOwPGruiBOm0YQQ6ZMcLIZ6tf/W+Jcm7dxZyAX0tK3WP9/sq7/NAWBxPIxVahdGjCJoGwej0Gdrv0DxlQQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"events": "^3.3.0",
"graphology-utils": "^2.5.2"
@ -9303,6 +9315,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -9538,6 +9551,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@ -10184,6 +10198,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View file

@ -151,6 +151,20 @@ gitnexus wiki [path] # Generate LLM-powered docs from knowledge grap
gitnexus wiki --model <model> # Wiki with custom LLM model (default: gpt-4o-mini)
```
## Remote Embeddings
Set these env vars to use a remote OpenAI-compatible `/v1/embeddings` endpoint instead of the local model:
```bash
export GITNEXUS_EMBEDDING_URL=http://your-server:8080/v1
export GITNEXUS_EMBEDDING_MODEL=BAAI/bge-large-en-v1.5
export GITNEXUS_EMBEDDING_DIMS=1024 # optional, default 384
export GITNEXUS_EMBEDDING_API_KEY=your-key # optional, default: "unused"
gitnexus analyze . --embeddings
```
Works with Infinity, vLLM, TEI, llama.cpp, Ollama, LM Studio, or OpenAI. When unset, local embeddings are used unchanged.
## Multi-Repo Support
GitNexus supports indexing multiple repositories. Each `gitnexus analyze` registers the repo in a global registry (`~/.gitnexus/registry.json`). The MCP server serves all indexed repos automatically.

View file

@ -3119,6 +3119,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@ -4203,6 +4204,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -4982,6 +4984,7 @@
"integrity": "sha512-7dxoA6kYvtgWw80265MyqJlkRl4yawIjO7S5MigytjELkX43fV2WsAXzsNfO7sBpPPCF5Gp0+XzHk0DwLCq3xQ==",
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"node-addon-api": "^8.0.0",
"node-gyp-build": "^4.8.0"
@ -5384,6 +5387,7 @@
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
@ -5504,6 +5508,7 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@ -5579,6 +5584,7 @@
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.0.18",
"@vitest/mocker": "4.0.18",
@ -5862,6 +5868,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View file

@ -246,17 +246,27 @@ export const analyzeCommand = async (
// ── Phase 3.5: Re-insert cached embeddings ────────────────────────
if (cachedEmbeddings.length > 0) {
updateBar(88, `Restoring ${cachedEmbeddings.length} cached embeddings...`);
const EMBED_BATCH = 200;
for (let i = 0; i < cachedEmbeddings.length; i += EMBED_BATCH) {
const batch = cachedEmbeddings.slice(i, i + EMBED_BATCH);
const paramsList = batch.map(e => ({ nodeId: e.nodeId, embedding: e.embedding }));
try {
await executeWithReusedStatement(
`CREATE (e:CodeEmbedding {nodeId: $nodeId, embedding: $embedding})`,
paramsList,
);
} catch { /* some may fail if node was removed, that's fine */ }
// Check if cached embedding dimensions match current schema
const cachedDims = cachedEmbeddings[0].embedding.length;
const { EMBEDDING_DIMS } = await import('../core/lbug/schema.js');
if (cachedDims !== EMBEDDING_DIMS) {
// Dimensions changed (e.g. switched embedding model) — discard cache and re-embed all
console.error(`⚠️ Embedding dimensions changed (${cachedDims}d → ${EMBEDDING_DIMS}d), discarding cache`);
cachedEmbeddings = [];
cachedEmbeddingNodeIds = new Set();
} else {
updateBar(88, `Restoring ${cachedEmbeddings.length} cached embeddings...`);
const EMBED_BATCH = 200;
for (let i = 0; i < cachedEmbeddings.length; i += EMBED_BATCH) {
const batch = cachedEmbeddings.slice(i, i + EMBED_BATCH);
const paramsList = batch.map(e => ({ nodeId: e.nodeId, embedding: e.embedding }));
try {
await executeWithReusedStatement(
`CREATE (e:CodeEmbedding {nodeId: $nodeId, embedding: $embedding})`,
paramsList,
);
} catch { /* some may fail if node was removed, that's fine */ }
}
}
}

View file

@ -21,11 +21,8 @@ import { join } from 'path';
import { DEFAULT_EMBEDDING_CONFIG, type EmbeddingConfig, type HttpEmbeddingConfig, type ModelProgress } from './types.js';
// ─── HTTP Embedding Backend ───────────────────────────────────────────────────
// When GITNEXUS_EMBEDDING_URL is set, all embedding calls go to the HTTP
// endpoint instead of loading a local transformers.js model. This enables:
// - Self-hosted servers (Infinity, vLLM, TEI) over Tailscale/VPN
// - Higher-quality models (bge-large 1024d vs arctic-xs 384d)
// - Shared embedding infrastructure across tools
// When GITNEXUS_EMBEDDING_URL + GITNEXUS_EMBEDDING_MODEL are set, embedding
// calls proxy to a remote OpenAI-compatible /v1/embeddings endpoint.
function getHttpConfig(): HttpEmbeddingConfig | null {
const baseUrl = process.env.GITNEXUS_EMBEDDING_URL;
@ -108,8 +105,6 @@ function isHttpMode(): boolean {
return httpConfig !== null;
}
// ─── End HTTP Backend ─────────────────────────────────────────────────────────
/**
* Check whether CUDA libraries are actually available on this system.
* ONNX Runtime's native layer crashes (uncatchable) if we attempt CUDA
@ -297,7 +292,8 @@ export const isEmbedderReady = (): boolean => {
/**
* Get the effective embedding dimensions.
* HTTP mode may use different dimensions than the local default.
* Returns configured dimensions. In HTTP mode, uses GITNEXUS_EMBEDDING_DIMS
* or falls back to auto-detected dims from the last HTTP response.
*/
export const getEmbeddingDimensions = (): number => {
if (isHttpMode()) {
@ -311,6 +307,9 @@ export const getEmbeddingDimensions = (): number => {
* Get the embedder instance (throws if not initialized)
*/
export const getEmbedder = (): FeatureExtractionPipeline => {
if (isHttpMode()) {
throw new Error('getEmbedder() is not available in HTTP embedding mode. Use embedText()/embedBatch() instead.');
}
if (!embedderInstance) {
throw new Error('Embedder not initialized. Call initEmbedder() first.');
}

View file

@ -66,17 +66,8 @@ export interface EmbeddingConfig {
}
/**
* Configuration for HTTP embedding endpoint (OpenAI-compatible).
* Set via environment variables:
* GITNEXUS_EMBEDDING_URL - Base URL (e.g. http://localhost:8080/v1)
* GITNEXUS_EMBEDDING_MODEL - Model name (e.g. BAAI/bge-large-en-v1.5)
* GITNEXUS_EMBEDDING_API_KEY - API key (default: "unused")
* GITNEXUS_EMBEDDING_DIMS - Dimensions (default: auto-detected from first response)
*
* Supports any OpenAI-compatible /v1/embeddings endpoint:
* - Self-hosted: Infinity, vLLM, TEI, llama.cpp
* - Cloud: OpenAI, Ollama (remote), LM Studio
* - VPS/Tailscale: any endpoint reachable over the network
* Configuration for HTTP embedding endpoint (OpenAI-compatible /v1/embeddings).
* Populated from GITNEXUS_EMBEDDING_* environment variables.
*/
export interface HttpEmbeddingConfig {
/** Base URL for the embedding API (must include /v1) */
@ -85,7 +76,7 @@ export interface HttpEmbeddingConfig {
model: string;
/** API key for authentication */
apiKey: string;
/** Override dimensions (auto-detected if not set) */
/** Vector dimensions — must match model output (default: 384) */
dimensions?: number;
}

View file

@ -398,6 +398,7 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
// Separate table for vector storage to avoid copy-on-write overhead
// ============================================================================
/** Embedding vector dimensions. Default 384 (snowflake-arctic-embed-xs). */
export const EMBEDDING_DIMS = parseInt(process.env.GITNEXUS_EMBEDDING_DIMS ?? '384', 10);
export const EMBEDDING_SCHEMA = `

View file

@ -7,16 +7,16 @@
import { pipeline, env, type FeatureExtractionPipeline } from '@huggingface/transformers';
// Model config
const MODEL_ID = 'Snowflake/snowflake-arctic-embed-xs';
const EMBEDDING_DIMS = parseInt(process.env.GITNEXUS_EMBEDDING_DIMS ?? '384', 10);
// HTTP embedding config
const HTTP_URL = process.env.GITNEXUS_EMBEDDING_URL ?? '';
const HTTP_MODEL = process.env.GITNEXUS_EMBEDDING_MODEL ?? '';
const HTTP_KEY = process.env.GITNEXUS_EMBEDDING_API_KEY ?? 'unused';
const USE_HTTP = !!(HTTP_URL && HTTP_MODEL);
// Model config
const MODEL_ID = 'Snowflake/snowflake-arctic-embed-xs';
const EMBEDDING_DIMS = parseInt(process.env.GITNEXUS_EMBEDDING_DIMS ?? '384', 10);
// Module-level state for singleton pattern
let embedderInstance: FeatureExtractionPipeline | null = null;
let isInitializing = false;
@ -104,18 +104,31 @@ export const isEmbedderReady = (): boolean => USE_HTTP || embedderInstance !== n
*/
export const embedQuery = async (query: string): Promise<number[]> => {
if (USE_HTTP) {
const resp = await fetch(`${HTTP_URL.replace(/\/+$/, '')}/embeddings`, {
method: 'POST',
signal: AbortSignal.timeout(30_000),
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${HTTP_KEY}`,
},
body: JSON.stringify({ input: [query], model: HTTP_MODEL }),
});
if (!resp.ok) throw new Error(`Embedding endpoint returned ${resp.status}`);
const data = (await resp.json()) as { data: Array<{ embedding: number[] }> };
return data.data[0].embedding;
const url = `${HTTP_URL.replace(/\/+$/, '')}/embeddings`;
const body = JSON.stringify({ input: [query], model: HTTP_MODEL });
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${HTTP_KEY}`,
};
for (let attempt = 0; attempt <= 1; attempt++) {
const resp = await fetch(url, {
method: 'POST',
signal: AbortSignal.timeout(30_000),
headers,
body,
});
if (!resp.ok) {
if ((resp.status === 429 || resp.status >= 500) && attempt < 1) {
await new Promise(r => setTimeout(r, 1_000));
continue;
}
throw new Error(`Embedding endpoint returned ${resp.status}`);
}
const data = (await resp.json()) as { data: Array<{ embedding: number[] }> };
return data.data[0].embedding;
}
throw new Error('Embedding request failed after retry');
}
const embedder = await initEmbedder();

View file

@ -0,0 +1,230 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { getEmbeddingDims, isEmbedderReady } from '../../src/mcp/core/embedder.js';
describe('HTTP embedding backend', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.resetModules();
});
describe('MCP embedder', () => {
it('returns 384 dimensions by default', () => {
expect(getEmbeddingDims()).toBe(384);
});
it('returns false before initialization', () => {
expect(isEmbedderReady()).toBe(false);
});
it('returns true when HTTP environment variables are set', async () => {
process.env.GITNEXUS_EMBEDDING_URL = 'http://localhost:8080/v1';
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
const mod = await import('../../src/mcp/core/embedder.js');
expect(mod.isEmbedderReady()).toBe(true);
delete process.env.GITNEXUS_EMBEDDING_URL;
delete process.env.GITNEXUS_EMBEDDING_MODEL;
});
it('reads custom dimensions from environment', async () => {
process.env.GITNEXUS_EMBEDDING_URL = 'http://localhost:8080/v1';
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
process.env.GITNEXUS_EMBEDDING_DIMS = '1024';
const mod = await import('../../src/mcp/core/embedder.js');
expect(mod.getEmbeddingDims()).toBe(1024);
delete process.env.GITNEXUS_EMBEDDING_URL;
delete process.env.GITNEXUS_EMBEDDING_MODEL;
delete process.env.GITNEXUS_EMBEDDING_DIMS;
});
it('retries query on transient server error', async () => {
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
const ok = { ok: true, json: async () => ({ data: [{ embedding: [0.1, 0.2] }] }) };
vi.stubGlobal('fetch', vi.fn()
.mockResolvedValueOnce({ ok: false, status: 503 })
.mockResolvedValueOnce(ok));
const mod = await import('../../src/mcp/core/embedder.js');
const result = await mod.embedQuery('test query');
expect(fetch).toHaveBeenCalledTimes(2);
expect(result).toEqual([0.1, 0.2]);
delete process.env.GITNEXUS_EMBEDDING_URL;
delete process.env.GITNEXUS_EMBEDDING_MODEL;
});
});
describe('core embedder HTTP path', () => {
it('sends correct request payload', async () => {
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
process.env.GITNEXUS_EMBEDDING_API_KEY = 'test-key';
const mockEmbedding = Array.from({ length: 384 }, (_, i) => i * 0.001);
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ data: [{ embedding: mockEmbedding }] }),
}));
const { embedText } = await import('../../src/core/embeddings/embedder.js');
const result = await embedText('test text');
expect(fetch).toHaveBeenCalledOnce();
const body = JSON.parse((fetch as any).mock.calls[0][1].body);
expect(body.model).toBe('test-model');
expect(body.input).toEqual(['test text']);
expect(result).toBeInstanceOf(Float32Array);
expect(result.length).toBe(384);
delete process.env.GITNEXUS_EMBEDDING_URL;
delete process.env.GITNEXUS_EMBEDDING_MODEL;
delete process.env.GITNEXUS_EMBEDDING_API_KEY;
});
it('retries on server error', async () => {
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
const ok = { ok: true, json: async () => ({ data: [{ embedding: [0.1] }] }) };
vi.stubGlobal('fetch', vi.fn()
.mockResolvedValueOnce({ ok: false, status: 503 })
.mockResolvedValueOnce(ok));
const { embedText } = await import('../../src/core/embeddings/embedder.js');
await embedText('test');
expect(fetch).toHaveBeenCalledTimes(2);
delete process.env.GITNEXUS_EMBEDDING_URL;
delete process.env.GITNEXUS_EMBEDDING_MODEL;
});
it('retries on rate limit', async () => {
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
const ok = { ok: true, json: async () => ({ data: [{ embedding: [0.1] }] }) };
vi.stubGlobal('fetch', vi.fn()
.mockResolvedValueOnce({ ok: false, status: 429 })
.mockResolvedValueOnce(ok));
const { embedText } = await import('../../src/core/embeddings/embedder.js');
await embedText('test');
expect(fetch).toHaveBeenCalledTimes(2);
delete process.env.GITNEXUS_EMBEDDING_URL;
delete process.env.GITNEXUS_EMBEDDING_MODEL;
});
it('throws when all retries are exhausted', async () => {
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 }));
const { embedText } = await import('../../src/core/embeddings/embedder.js');
await expect(embedText('test')).rejects.toThrow('500');
delete process.env.GITNEXUS_EMBEDDING_URL;
delete process.env.GITNEXUS_EMBEDDING_MODEL;
});
it('excludes API key from error messages', async () => {
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
process.env.GITNEXUS_EMBEDDING_API_KEY = 'secret-key-12345';
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 }));
const { embedText } = await import('../../src/core/embeddings/embedder.js');
try {
await embedText('test');
} catch (e: any) {
expect(e.message).not.toContain('secret-key-12345');
expect(e.message).not.toContain('Authorization');
}
delete process.env.GITNEXUS_EMBEDDING_URL;
delete process.env.GITNEXUS_EMBEDDING_MODEL;
delete process.env.GITNEXUS_EMBEDDING_API_KEY;
});
it('includes abort signal for timeout', async () => {
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ data: [{ embedding: [0.1] }] }),
}));
const { embedText } = await import('../../src/core/embeddings/embedder.js');
await embedText('test');
const opts = (fetch as any).mock.calls[0][1];
expect(opts.signal).toBeDefined();
delete process.env.GITNEXUS_EMBEDDING_URL;
delete process.env.GITNEXUS_EMBEDDING_MODEL;
});
it('splits large inputs into batches', async () => {
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
const makeResp = (n: number) => ({
ok: true,
json: async () => ({ data: Array.from({ length: n }, () => ({ embedding: [0.1] })) }),
});
vi.stubGlobal('fetch', vi.fn()
.mockResolvedValueOnce(makeResp(64))
.mockResolvedValueOnce(makeResp(6)));
const { embedBatch } = await import('../../src/core/embeddings/embedder.js');
const results = await embedBatch(Array.from({ length: 70 }, (_, i) => `text ${i}`));
expect(fetch).toHaveBeenCalledTimes(2);
expect(results).toHaveLength(70);
delete process.env.GITNEXUS_EMBEDDING_URL;
delete process.env.GITNEXUS_EMBEDDING_MODEL;
});
it('rejects initEmbedder when using HTTP backend', async () => {
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
const { initEmbedder } = await import('../../src/core/embeddings/embedder.js');
await expect(initEmbedder()).rejects.toThrow('HTTP mode');
delete process.env.GITNEXUS_EMBEDDING_URL;
delete process.env.GITNEXUS_EMBEDDING_MODEL;
});
it('rejects getEmbedder when using HTTP backend', async () => {
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
const { getEmbedder } = await import('../../src/core/embeddings/embedder.js');
expect(() => getEmbedder()).toThrow('HTTP embedding mode');
delete process.env.GITNEXUS_EMBEDDING_URL;
delete process.env.GITNEXUS_EMBEDDING_MODEL;
});
});
describe('schema dimensions', () => {
it('defaults to 384 dimensions', async () => {
const { EMBEDDING_DIMS } = await import('../../src/core/lbug/schema.js');
expect(EMBEDDING_DIMS).toBe(384);
});
it('reads dimensions from environment variable', async () => {
process.env.GITNEXUS_EMBEDDING_DIMS = '1024';
const { EMBEDDING_DIMS } = await import('../../src/core/lbug/schema.js');
expect(EMBEDDING_DIMS).toBe(1024);
delete process.env.GITNEXUS_EMBEDDING_DIMS;
});
});
});