mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-30 06:42:43 +00:00
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:
parent
7e5747d26b
commit
ab1095af7d
9 changed files with 331 additions and 51 deletions
23
gitnexus-web/package-lock.json
generated
23
gitnexus-web/package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
7
gitnexus/package-lock.json
generated
7
gitnexus/package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = `
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
230
gitnexus/test/unit/http-embedder.test.ts
Normal file
230
gitnexus/test/unit/http-embedder.test.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue