From 9dc00e47a70b57f3c1328821d8323f48bd13d7cc Mon Sep 17 00:00:00 2001 From: zm2231 Date: Thu, 19 Mar 2026 22:16:52 -0400 Subject: [PATCH 01/10] feat: HTTP embedding backend for self-hosted/remote endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds support for OpenAI-compatible embedding endpoints as an alternative to the local transformers.js pipeline. Enables using self-hosted servers (Infinity, vLLM, TEI, llama.cpp) over Tailscale/VPN, or any cloud endpoint — with higher-quality models like bge-large-en-v1.5 (1024d). Configuration via environment variables: GITNEXUS_EMBEDDING_URL=http://your-server:8080/v1 GITNEXUS_EMBEDDING_MODEL=BAAI/bge-large-en-v1.5 GITNEXUS_EMBEDDING_API_KEY=your-key (default: 'unused') GITNEXUS_EMBEDDING_DIMS=1024 (auto-detected if omitted) When env vars are set: - initEmbedder() skips local model download entirely - embedText() and embedBatch() call the HTTP endpoint - Dimensions auto-detected from first response - Batches in groups of 64 When env vars are NOT set: - Existing local transformers.js behavior is completely unchanged Build: tsc clean Tests: 1776 passed, 0 failed --- gitnexus/src/core/embeddings/embedder.ts | 105 ++++++++++++++++++++++- gitnexus/src/core/embeddings/types.ts | 26 +++++- 2 files changed, 127 insertions(+), 4 deletions(-) diff --git a/gitnexus/src/core/embeddings/embedder.ts b/gitnexus/src/core/embeddings/embedder.ts index c3c0b5f88..2768909c0 100644 --- a/gitnexus/src/core/embeddings/embedder.ts +++ b/gitnexus/src/core/embeddings/embedder.ts @@ -18,7 +18,79 @@ import { pipeline, env, type FeatureExtractionPipeline } from '@huggingface/tran import { existsSync } from 'fs'; import { execFileSync } from 'child_process'; import { join } from 'path'; -import { DEFAULT_EMBEDDING_CONFIG, type EmbeddingConfig, type ModelProgress } from './types.js'; +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 + +function getHttpConfig(): HttpEmbeddingConfig | null { + const baseUrl = process.env.GITNEXUS_EMBEDDING_URL; + const model = process.env.GITNEXUS_EMBEDDING_MODEL; + if (!baseUrl || !model) return null; + return { + baseUrl: baseUrl.replace(/\/+$/, ''), + model, + apiKey: process.env.GITNEXUS_EMBEDDING_API_KEY ?? 'unused', + dimensions: process.env.GITNEXUS_EMBEDDING_DIMS + ? parseInt(process.env.GITNEXUS_EMBEDDING_DIMS, 10) + : undefined, + }; +} + +let httpConfig: HttpEmbeddingConfig | null | undefined; +let httpDimensions: number | null = null; + +async function httpEmbed(texts: string[]): Promise { + if (httpConfig === undefined) httpConfig = getHttpConfig(); + if (!httpConfig) throw new Error('HTTP embedding not configured'); + + const url = `${httpConfig.baseUrl}/embeddings`; + const batchSize = 64; + const allVectors: Float32Array[] = []; + + for (let i = 0; i < texts.length; i += batchSize) { + const batch = texts.slice(i, i + batchSize); + const resp = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${httpConfig.apiKey}`, + }, + body: JSON.stringify({ input: batch, model: httpConfig.model }), + }); + + if (!resp.ok) { + const body = await resp.text(); + throw new Error(`Embedding endpoint ${resp.status}: ${body}`); + } + + const data = (await resp.json()) as { + data: Array<{ embedding: number[] }>; + }; + + for (const item of data.data) { + allVectors.push(new Float32Array(item.embedding)); + } + + // Auto-detect dimensions from first response + if (httpDimensions === null && data.data.length > 0) { + httpDimensions = data.data[0].embedding.length; + } + } + + return allVectors; +} + +function isHttpMode(): boolean { + if (httpConfig === undefined) httpConfig = getHttpConfig(); + return httpConfig !== null; +} + +// ─── End HTTP Backend ───────────────────────────────────────────────────────── /** * Check whether CUDA libraries are actually available on this system. @@ -83,6 +155,12 @@ export const initEmbedder = async ( config: Partial = {}, forceDevice?: 'dml' | 'cuda' | 'cpu' | 'wasm' ): Promise => { + // HTTP mode: skip local model loading entirely + if (isHttpMode()) { + // Return a dummy pipeline — embedText/embedBatch bypass it via isHttpMode() + return null as unknown as FeatureExtractionPipeline; + } + // Return existing instance if available if (embedderInstance) { return embedderInstance; @@ -195,7 +273,19 @@ export const initEmbedder = async ( * Check if the embedder is initialized and ready */ export const isEmbedderReady = (): boolean => { - return embedderInstance !== null; + return isHttpMode() || embedderInstance !== null; +}; + +/** + * Get the effective embedding dimensions. + * HTTP mode may use different dimensions than the local default. + */ +export const getEmbeddingDimensions = (): number => { + if (isHttpMode()) { + const cfg = getHttpConfig(); + return cfg?.dimensions ?? httpDimensions ?? DEFAULT_EMBEDDING_CONFIG.dimensions; + } + return DEFAULT_EMBEDDING_CONFIG.dimensions; }; /** @@ -212,9 +302,14 @@ export const getEmbedder = (): FeatureExtractionPipeline => { * Embed a single text string * * @param text - Text to embed - * @returns Float32Array of embedding vector (384 dimensions) + * @returns Float32Array of embedding vector */ export const embedText = async (text: string): Promise => { + if (isHttpMode()) { + const [vec] = await httpEmbed([text]); + return vec; + } + const embedder = getEmbedder(); const result = await embedder(text, { @@ -238,6 +333,10 @@ export const embedBatch = async (texts: string[]): Promise => { return []; } + if (isHttpMode()) { + return httpEmbed(texts); + } + const embedder = getEmbedder(); // Process batch diff --git a/gitnexus/src/core/embeddings/types.ts b/gitnexus/src/core/embeddings/types.ts index 7978bf4c0..b249cf3c9 100644 --- a/gitnexus/src/core/embeddings/types.ts +++ b/gitnexus/src/core/embeddings/types.ts @@ -53,7 +53,7 @@ export interface EmbeddingProgress { * Configuration for the embedding pipeline */ export interface EmbeddingConfig { - /** Model identifier for transformers.js */ + /** Model identifier for transformers.js (local) or the HTTP endpoint model name */ modelId: string; /** Number of nodes to embed in each batch */ batchSize: number; @@ -65,6 +65,30 @@ export interface EmbeddingConfig { maxSnippetLength: number; } +/** + * 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 + */ +export interface HttpEmbeddingConfig { + /** Base URL for the embedding API (must include /v1) */ + baseUrl: string; + /** Model name to send in the request */ + model: string; + /** API key for authentication */ + apiKey: string; + /** Override dimensions (auto-detected if not set) */ + dimensions?: number; +} + /** * Default embedding configuration * Uses snowflake-arctic-embed-xs for browser efficiency From c79c717790e407b74e67912acd24f857b6a9f83b Mon Sep 17 00:00:00 2001 From: zm2231 Date: Fri, 20 Mar 2026 00:31:39 -0400 Subject: [PATCH 02/10] fix: dynamic embedding dimensions for HTTP backend - schema.ts: FLOAT[${EMBEDDING_DIMS}] reads from GITNEXUS_EMBEDDING_DIMS env - embedding-pipeline.ts: vector search CAST uses actual query vector length - mcp/core/embedder.ts: HTTP embedding support for MCP query-time search Without this, using a 1024d model (e.g. bge-large) fails with 'Expected: 384, Actual: 1024' on LadybugDB vector insert. --- .../src/core/embeddings/embedding-pipeline.ts | 2 +- gitnexus/src/core/lbug/schema.ts | 4 ++- gitnexus/src/mcp/core/embedder.ts | 26 +++++++++++++++++-- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/gitnexus/src/core/embeddings/embedding-pipeline.ts b/gitnexus/src/core/embeddings/embedding-pipeline.ts index 5fbd5cd0b..bee01ed5f 100644 --- a/gitnexus/src/core/embeddings/embedding-pipeline.ts +++ b/gitnexus/src/core/embeddings/embedding-pipeline.ts @@ -326,7 +326,7 @@ export const semanticSearch = async ( // Query the vector index on CodeEmbedding to get nodeIds and distances const vectorQuery = ` CALL QUERY_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx', - CAST(${queryVecStr} AS FLOAT[384]), ${k}) + CAST(${queryVecStr} AS FLOAT[${queryVec.length}]), ${k}) YIELD node AS emb, distance WITH emb, distance WHERE distance < ${maxDistance} diff --git a/gitnexus/src/core/lbug/schema.ts b/gitnexus/src/core/lbug/schema.ts index ef1fbad50..689fb4df0 100644 --- a/gitnexus/src/core/lbug/schema.ts +++ b/gitnexus/src/core/lbug/schema.ts @@ -398,10 +398,12 @@ CREATE REL TABLE ${REL_TABLE_NAME} ( // Separate table for vector storage to avoid copy-on-write overhead // ============================================================================ +export const EMBEDDING_DIMS = parseInt(process.env.GITNEXUS_EMBEDDING_DIMS ?? '384', 10); + export const EMBEDDING_SCHEMA = ` CREATE NODE TABLE ${EMBEDDING_TABLE_NAME} ( nodeId STRING, - embedding FLOAT[384], + embedding FLOAT[${EMBEDDING_DIMS}], PRIMARY KEY (nodeId) )`; diff --git a/gitnexus/src/mcp/core/embedder.ts b/gitnexus/src/mcp/core/embedder.ts index ee480a6a9..404e6dd88 100644 --- a/gitnexus/src/mcp/core/embedder.ts +++ b/gitnexus/src/mcp/core/embedder.ts @@ -9,7 +9,13 @@ import { pipeline, env, type FeatureExtractionPipeline } from '@huggingface/tran // Model config const MODEL_ID = 'Snowflake/snowflake-arctic-embed-xs'; -const EMBEDDING_DIMS = 384; +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); // Module-level state for singleton pattern let embedderInstance: FeatureExtractionPipeline | null = null; @@ -20,6 +26,8 @@ let initPromise: Promise | null = null; * Initialize the embedding model (lazy, on first search) */ export const initEmbedder = async (): Promise => { + if (USE_HTTP) return null as unknown as FeatureExtractionPipeline; + if (embedderInstance) { return embedderInstance; } @@ -87,12 +95,26 @@ export const initEmbedder = async (): Promise => { /** * Check if embedder is ready */ -export const isEmbedderReady = (): boolean => embedderInstance !== null; +export const isEmbedderReady = (): boolean => USE_HTTP || embedderInstance !== null; /** * Embed a query text for semantic search */ export const embedQuery = async (query: string): Promise => { + if (USE_HTTP) { + const resp = await fetch(`${HTTP_URL.replace(/\/+$/, '')}/embeddings`, { + method: 'POST', + 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 ${resp.status}`); + const data = (await resp.json()) as { data: Array<{ embedding: number[] }> }; + return data.data[0].embedding; + } + const embedder = await initEmbedder(); const result = await embedder(query, { From 5dcc567870f59952fb172c2f54ac6af812273070 Mon Sep 17 00:00:00 2001 From: zm2231 Date: Fri, 20 Mar 2026 00:37:17 -0400 Subject: [PATCH 03/10] =?UTF-8?q?fix:=20address=20code=20review=20?= =?UTF-8?q?=E2=80=94=20timeout,=20retry,=20safe=20initEmbedder=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add AbortSignal.timeout(30s) to all HTTP fetch calls - Add retry with backoff for 429/5xx (max 2 retries) - initEmbedder() throws descriptive error in HTTP mode instead of null cast - embedding-pipeline.ts guards initEmbedder() behind isEmbedderReady() check - Error messages omit request details to avoid leaking API keys in logs --- gitnexus/src/core/embeddings/embedder.ts | 67 ++++++++++++------- .../src/core/embeddings/embedding-pipeline.ts | 18 ++--- gitnexus/src/mcp/core/embedder.ts | 7 +- 3 files changed, 58 insertions(+), 34 deletions(-) diff --git a/gitnexus/src/core/embeddings/embedder.ts b/gitnexus/src/core/embeddings/embedder.ts index 2768909c0..bbe9a44ad 100644 --- a/gitnexus/src/core/embeddings/embedder.ts +++ b/gitnexus/src/core/embeddings/embedder.ts @@ -44,6 +44,41 @@ function getHttpConfig(): HttpEmbeddingConfig | null { let httpConfig: HttpEmbeddingConfig | null | undefined; let httpDimensions: number | null = null; +const HTTP_TIMEOUT_MS = 30_000; +const HTTP_MAX_RETRIES = 2; +const HTTP_RETRY_BACKOFF_MS = 1_000; + +async function httpEmbedBatch( + url: string, + batch: string[], + model: string, + apiKey: string, + attempt = 0, +): Promise> { + const resp = await fetch(url, { + method: 'POST', + signal: AbortSignal.timeout(HTTP_TIMEOUT_MS), + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify({ input: batch, model }), + }); + + if (!resp.ok) { + const status = resp.status; + if ((status === 429 || status >= 500) && attempt < HTTP_MAX_RETRIES) { + const delay = HTTP_RETRY_BACKOFF_MS * (attempt + 1); + await new Promise(r => setTimeout(r, delay)); + return httpEmbedBatch(url, batch, model, apiKey, attempt + 1); + } + throw new Error(`Embedding endpoint returned ${status}`); + } + + const data = (await resp.json()) as { data: Array<{ embedding: number[] }> }; + return data.data; +} + async function httpEmbed(texts: string[]): Promise { if (httpConfig === undefined) httpConfig = getHttpConfig(); if (!httpConfig) throw new Error('HTTP embedding not configured'); @@ -54,31 +89,14 @@ async function httpEmbed(texts: string[]): Promise { for (let i = 0; i < texts.length; i += batchSize) { const batch = texts.slice(i, i + batchSize); - const resp = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${httpConfig.apiKey}`, - }, - body: JSON.stringify({ input: batch, model: httpConfig.model }), - }); + const items = await httpEmbedBatch(url, batch, httpConfig.model, httpConfig.apiKey); - if (!resp.ok) { - const body = await resp.text(); - throw new Error(`Embedding endpoint ${resp.status}: ${body}`); - } - - const data = (await resp.json()) as { - data: Array<{ embedding: number[] }>; - }; - - for (const item of data.data) { + for (const item of items) { allVectors.push(new Float32Array(item.embedding)); } - // Auto-detect dimensions from first response - if (httpDimensions === null && data.data.length > 0) { - httpDimensions = data.data[0].embedding.length; + if (httpDimensions === null && items.length > 0) { + httpDimensions = items[0].embedding.length; } } @@ -155,10 +173,11 @@ export const initEmbedder = async ( config: Partial = {}, forceDevice?: 'dml' | 'cuda' | 'cpu' | 'wasm' ): Promise => { - // HTTP mode: skip local model loading entirely if (isHttpMode()) { - // Return a dummy pipeline — embedText/embedBatch bypass it via isHttpMode() - return null as unknown as FeatureExtractionPipeline; + throw new Error( + 'initEmbedder() should not be called in HTTP mode. ' + + 'Use embedText()/embedBatch() which handle HTTP transparently.' + ); } // Return existing instance if available diff --git a/gitnexus/src/core/embeddings/embedding-pipeline.ts b/gitnexus/src/core/embeddings/embedding-pipeline.ts index bee01ed5f..bb99a6ee2 100644 --- a/gitnexus/src/core/embeddings/embedding-pipeline.ts +++ b/gitnexus/src/core/embeddings/embedding-pipeline.ts @@ -161,14 +161,16 @@ export const runEmbeddingPipeline = async ( modelDownloadPercent: 0, }); - await initEmbedder((modelProgress: ModelProgress) => { - const downloadPercent = modelProgress.progress ?? 0; - onProgress({ - phase: 'loading-model', - percent: Math.round(downloadPercent * 0.2), - modelDownloadPercent: downloadPercent, - }); - }, finalConfig); + if (!isEmbedderReady()) { + await initEmbedder((modelProgress: ModelProgress) => { + const downloadPercent = modelProgress.progress ?? 0; + onProgress({ + phase: 'loading-model', + percent: Math.round(downloadPercent * 0.2), + modelDownloadPercent: downloadPercent, + }); + }, finalConfig); + } onProgress({ phase: 'loading-model', diff --git a/gitnexus/src/mcp/core/embedder.ts b/gitnexus/src/mcp/core/embedder.ts index 404e6dd88..12bc054eb 100644 --- a/gitnexus/src/mcp/core/embedder.ts +++ b/gitnexus/src/mcp/core/embedder.ts @@ -26,7 +26,9 @@ let initPromise: Promise | null = null; * Initialize the embedding model (lazy, on first search) */ export const initEmbedder = async (): Promise => { - if (USE_HTTP) return null as unknown as FeatureExtractionPipeline; + if (USE_HTTP) { + throw new Error('initEmbedder() should not be called in HTTP mode.'); + } if (embedderInstance) { return embedderInstance; @@ -104,13 +106,14 @@ export const embedQuery = async (query: string): Promise => { 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 ${resp.status}`); + 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; } From 7dcafb647b329a1a9420896143633d74028a11cd Mon Sep 17 00:00:00 2001 From: zm2231 Date: Fri, 20 Mar 2026 01:32:49 -0400 Subject: [PATCH 04/10] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20timeout,=20retry,=20guards,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- gitnexus-web/package-lock.json | 23 ++- gitnexus/README.md | 14 ++ gitnexus/package-lock.json | 7 + gitnexus/src/cli/analyze.ts | 32 ++-- gitnexus/src/core/embeddings/embedder.ts | 15 +- gitnexus/src/core/embeddings/types.ts | 15 +- gitnexus/src/core/lbug/schema.ts | 1 + gitnexus/src/mcp/core/embedder.ts | 45 +++-- gitnexus/test/unit/http-embedder.test.ts | 230 +++++++++++++++++++++++ 9 files changed, 331 insertions(+), 51 deletions(-) create mode 100644 gitnexus/test/unit/http-embedder.test.ts diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index dd7bce991..cc332486b 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -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" } diff --git a/gitnexus/README.md b/gitnexus/README.md index bb90ebb04..ffa6f0337 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -151,6 +151,20 @@ gitnexus wiki [path] # Generate LLM-powered docs from knowledge grap gitnexus wiki --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. diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index dbc672aaa..7e61ba6e7 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -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" } diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 4965563ea..7bd355830 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -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 */ } + } } } diff --git a/gitnexus/src/core/embeddings/embedder.ts b/gitnexus/src/core/embeddings/embedder.ts index bbe9a44ad..072aaa6f7 100644 --- a/gitnexus/src/core/embeddings/embedder.ts +++ b/gitnexus/src/core/embeddings/embedder.ts @@ -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.'); } diff --git a/gitnexus/src/core/embeddings/types.ts b/gitnexus/src/core/embeddings/types.ts index b249cf3c9..12362097d 100644 --- a/gitnexus/src/core/embeddings/types.ts +++ b/gitnexus/src/core/embeddings/types.ts @@ -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; } diff --git a/gitnexus/src/core/lbug/schema.ts b/gitnexus/src/core/lbug/schema.ts index 689fb4df0..f995a730f 100644 --- a/gitnexus/src/core/lbug/schema.ts +++ b/gitnexus/src/core/lbug/schema.ts @@ -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 = ` diff --git a/gitnexus/src/mcp/core/embedder.ts b/gitnexus/src/mcp/core/embedder.ts index 12bc054eb..ab6f64752 100644 --- a/gitnexus/src/mcp/core/embedder.ts +++ b/gitnexus/src/mcp/core/embedder.ts @@ -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 => { 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(); diff --git a/gitnexus/test/unit/http-embedder.test.ts b/gitnexus/test/unit/http-embedder.test.ts new file mode 100644 index 000000000..04cb95171 --- /dev/null +++ b/gitnexus/test/unit/http-embedder.test.ts @@ -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; + }); + }); +}); From c8480f899d45f89db34fc9c27df24b8b9342daad Mon Sep 17 00:00:00 2001 From: zm2231 Date: Fri, 20 Mar 2026 18:23:26 -0400 Subject: [PATCH 05/10] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20deduplicate=20HTTP=20client,=20remove=20config=20ca?= =?UTF-8?q?che,=20add=20guards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract shared HTTP client (http-client.ts) used by both core and MCP embedders - Remove module-level httpConfig cache — read env vars fresh on every call so config set after module load (e.g. via dotenv) takes effect - Add NaN/non-positive guard on GITNEXUS_EMBEDDING_DIMS in schema.ts - Include scrubbed URL and batch index in error messages (no API key) - Wrap fetch rejections (DNS/timeout/connection) with same scrubbed context - MCP embedder delegates to shared httpEmbedQuery() instead of inline logic - apiKey confined to http-client.ts internals — not exported in any type or accessor - Remove HttpEmbeddingConfig from types.ts (replaced by internal HttpConfig) - All 16 HTTP embedder tests pass, tsc clean --- AGENTS.md | 12 +- CLAUDE.md | 12 +- gitnexus/src/core/embeddings/embedder.ts | 94 +---------- gitnexus/src/core/embeddings/http-client.ts | 177 ++++++++++++++++++++ gitnexus/src/core/embeddings/index.ts | 1 + gitnexus/src/core/embeddings/types.ts | 14 -- gitnexus/src/core/lbug/schema.ts | 8 +- gitnexus/src/mcp/core/embedder.ts | 44 +---- 8 files changed, 209 insertions(+), 153 deletions(-) create mode 100644 gitnexus/src/core/embeddings/http-client.ts diff --git a/AGENTS.md b/AGENTS.md index cd70281f9..f9bd21836 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **GitNexus** (2169 symbols, 5213 relationships, 165 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **gitnexus-fork** (2179 symbols, 5243 relationships, 166 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. @@ -17,7 +17,7 @@ This project is indexed by GitNexus as **GitNexus** (2169 symbols, 5213 relation 1. `gitnexus_query({query: ""})` — find execution flows related to the issue 2. `gitnexus_context({name: ""})` — see all callers, callees, and process participation -3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step +3. `READ gitnexus://repo/gitnexus-fork/process/{processName}` — trace the full execution flow step by step 4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed ## When Refactoring @@ -56,10 +56,10 @@ This project is indexed by GitNexus as **GitNexus** (2169 symbols, 5213 relation | Resource | Use for | |----------|---------| -| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness | -| `gitnexus://repo/GitNexus/clusters` | All functional areas | -| `gitnexus://repo/GitNexus/processes` | All execution flows | -| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace | +| `gitnexus://repo/gitnexus-fork/context` | Codebase overview, check index freshness | +| `gitnexus://repo/gitnexus-fork/clusters` | All functional areas | +| `gitnexus://repo/gitnexus-fork/processes` | All execution flows | +| `gitnexus://repo/gitnexus-fork/process/{name}` | Step-by-step execution trace | ## Self-Check Before Finishing diff --git a/CLAUDE.md b/CLAUDE.md index cd70281f9..f9bd21836 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **GitNexus** (2169 symbols, 5213 relationships, 165 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **gitnexus-fork** (2179 symbols, 5243 relationships, 166 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. @@ -17,7 +17,7 @@ This project is indexed by GitNexus as **GitNexus** (2169 symbols, 5213 relation 1. `gitnexus_query({query: ""})` — find execution flows related to the issue 2. `gitnexus_context({name: ""})` — see all callers, callees, and process participation -3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step +3. `READ gitnexus://repo/gitnexus-fork/process/{processName}` — trace the full execution flow step by step 4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed ## When Refactoring @@ -56,10 +56,10 @@ This project is indexed by GitNexus as **GitNexus** (2169 symbols, 5213 relation | Resource | Use for | |----------|---------| -| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness | -| `gitnexus://repo/GitNexus/clusters` | All functional areas | -| `gitnexus://repo/GitNexus/processes` | All execution flows | -| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace | +| `gitnexus://repo/gitnexus-fork/context` | Codebase overview, check index freshness | +| `gitnexus://repo/gitnexus-fork/clusters` | All functional areas | +| `gitnexus://repo/gitnexus-fork/processes` | All execution flows | +| `gitnexus://repo/gitnexus-fork/process/{name}` | Step-by-step execution trace | ## Self-Check Before Finishing diff --git a/gitnexus/src/core/embeddings/embedder.ts b/gitnexus/src/core/embeddings/embedder.ts index 072aaa6f7..96cd2a57d 100644 --- a/gitnexus/src/core/embeddings/embedder.ts +++ b/gitnexus/src/core/embeddings/embedder.ts @@ -18,92 +18,8 @@ import { pipeline, env, type FeatureExtractionPipeline } from '@huggingface/tran import { existsSync } from 'fs'; import { execFileSync } from 'child_process'; import { join } from 'path'; -import { DEFAULT_EMBEDDING_CONFIG, type EmbeddingConfig, type HttpEmbeddingConfig, type ModelProgress } from './types.js'; - -// ─── HTTP Embedding Backend ─────────────────────────────────────────────────── -// 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; - const model = process.env.GITNEXUS_EMBEDDING_MODEL; - if (!baseUrl || !model) return null; - return { - baseUrl: baseUrl.replace(/\/+$/, ''), - model, - apiKey: process.env.GITNEXUS_EMBEDDING_API_KEY ?? 'unused', - dimensions: process.env.GITNEXUS_EMBEDDING_DIMS - ? parseInt(process.env.GITNEXUS_EMBEDDING_DIMS, 10) - : undefined, - }; -} - -let httpConfig: HttpEmbeddingConfig | null | undefined; -let httpDimensions: number | null = null; - -const HTTP_TIMEOUT_MS = 30_000; -const HTTP_MAX_RETRIES = 2; -const HTTP_RETRY_BACKOFF_MS = 1_000; - -async function httpEmbedBatch( - url: string, - batch: string[], - model: string, - apiKey: string, - attempt = 0, -): Promise> { - const resp = await fetch(url, { - method: 'POST', - signal: AbortSignal.timeout(HTTP_TIMEOUT_MS), - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${apiKey}`, - }, - body: JSON.stringify({ input: batch, model }), - }); - - if (!resp.ok) { - const status = resp.status; - if ((status === 429 || status >= 500) && attempt < HTTP_MAX_RETRIES) { - const delay = HTTP_RETRY_BACKOFF_MS * (attempt + 1); - await new Promise(r => setTimeout(r, delay)); - return httpEmbedBatch(url, batch, model, apiKey, attempt + 1); - } - throw new Error(`Embedding endpoint returned ${status}`); - } - - const data = (await resp.json()) as { data: Array<{ embedding: number[] }> }; - return data.data; -} - -async function httpEmbed(texts: string[]): Promise { - if (httpConfig === undefined) httpConfig = getHttpConfig(); - if (!httpConfig) throw new Error('HTTP embedding not configured'); - - const url = `${httpConfig.baseUrl}/embeddings`; - const batchSize = 64; - const allVectors: Float32Array[] = []; - - for (let i = 0; i < texts.length; i += batchSize) { - const batch = texts.slice(i, i + batchSize); - const items = await httpEmbedBatch(url, batch, httpConfig.model, httpConfig.apiKey); - - for (const item of items) { - allVectors.push(new Float32Array(item.embedding)); - } - - if (httpDimensions === null && items.length > 0) { - httpDimensions = items[0].embedding.length; - } - } - - return allVectors; -} - -function isHttpMode(): boolean { - if (httpConfig === undefined) httpConfig = getHttpConfig(); - return httpConfig !== null; -} +import { DEFAULT_EMBEDDING_CONFIG, type EmbeddingConfig, type ModelProgress } from './types.js'; +import { isHttpMode, getHttpDimensions, httpEmbed } from './http-client.js'; /** * Check whether CUDA libraries are actually available on this system. @@ -292,13 +208,11 @@ export const isEmbedderReady = (): boolean => { /** * Get the effective embedding dimensions. - * Returns configured dimensions. In HTTP mode, uses GITNEXUS_EMBEDDING_DIMS - * or falls back to auto-detected dims from the last HTTP response. + * In HTTP mode, uses GITNEXUS_EMBEDDING_DIMS if set, otherwise the default. */ export const getEmbeddingDimensions = (): number => { if (isHttpMode()) { - const cfg = getHttpConfig(); - return cfg?.dimensions ?? httpDimensions ?? DEFAULT_EMBEDDING_CONFIG.dimensions; + return getHttpDimensions() ?? DEFAULT_EMBEDDING_CONFIG.dimensions; } return DEFAULT_EMBEDDING_CONFIG.dimensions; }; diff --git a/gitnexus/src/core/embeddings/http-client.ts b/gitnexus/src/core/embeddings/http-client.ts new file mode 100644 index 000000000..331e45e55 --- /dev/null +++ b/gitnexus/src/core/embeddings/http-client.ts @@ -0,0 +1,177 @@ +/** + * HTTP Embedding Client + * + * Shared fetch+retry logic for OpenAI-compatible /v1/embeddings endpoints. + * Imported by both the core embedder (batch) and MCP embedder (query). + */ + +const HTTP_TIMEOUT_MS = 30_000; +const HTTP_MAX_RETRIES = 2; +const HTTP_RETRY_BACKOFF_MS = 1_000; +const HTTP_BATCH_SIZE = 64; + +interface HttpConfig { + baseUrl: string; + model: string; + apiKey: string; + dimensions?: number; +} + +/** + * Build config from the current process.env snapshot. + * Returns null when GITNEXUS_EMBEDDING_URL + GITNEXUS_EMBEDDING_MODEL are unset. + * Not cached — env vars are read fresh so late configuration takes effect. + */ +const readConfig = (): HttpConfig | null => { + const baseUrl = process.env.GITNEXUS_EMBEDDING_URL; + const model = process.env.GITNEXUS_EMBEDDING_MODEL; + if (!baseUrl || !model) return null; + + const rawDims = process.env.GITNEXUS_EMBEDDING_DIMS; + let dimensions: number | undefined; + if (rawDims !== undefined) { + const parsed = parseInt(rawDims, 10); + if (Number.isNaN(parsed) || parsed <= 0) { + throw new Error( + `GITNEXUS_EMBEDDING_DIMS must be a positive integer, got "${rawDims}"`, + ); + } + dimensions = parsed; + } + + return { + baseUrl: baseUrl.replace(/\/+$/, ''), + model, + apiKey: process.env.GITNEXUS_EMBEDDING_API_KEY ?? 'unused', + dimensions, + }; +}; + +/** + * Check whether HTTP embedding mode is active (env vars are set). + */ +export const isHttpMode = (): boolean => readConfig() !== null; + +/** + * Return the configured embedding dimensions for HTTP mode, or undefined + * if HTTP mode is not active or no explicit dimensions are set. + */ +export const getHttpDimensions = (): number | undefined => readConfig()?.dimensions; + +/** + * Return a safe representation of a URL for error messages. + * Strips query string (may contain tokens) and userinfo. + */ +const safeUrl = (url: string): string => { + try { + const u = new URL(url); + return `${u.protocol}//${u.host}${u.pathname}`; + } catch { + return ''; + } +}; + +interface EmbeddingItem { + embedding: number[]; +} + +/** + * Send a single batch of texts to the embedding endpoint with retry. + * + * @param url - Full endpoint URL (e.g. https://host/v1/embeddings) + * @param batch - Texts to embed + * @param model - Model name for the request body + * @param apiKey - Bearer token (only used in Authorization header) + * @param batchIndex - Logical batch number (for error context) + * @param attempt - Current retry attempt (internal) + */ +const httpEmbedBatch = async ( + url: string, + batch: string[], + model: string, + apiKey: string, + batchIndex = 0, + attempt = 0, +): Promise => { + let resp: Response; + try { + resp = await fetch(url, { + method: 'POST', + signal: AbortSignal.timeout(HTTP_TIMEOUT_MS), + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify({ input: batch, model }), + }); + } catch (err) { + // DNS, timeout, connection errors — add context without leaking the key + if (attempt < HTTP_MAX_RETRIES) { + const delay = HTTP_RETRY_BACKOFF_MS * (attempt + 1); + await new Promise(r => setTimeout(r, delay)); + return httpEmbedBatch(url, batch, model, apiKey, batchIndex, attempt + 1); + } + const reason = err instanceof Error ? err.message : String(err); + throw new Error( + `Embedding request failed (${safeUrl(url)}, batch ${batchIndex}): ${reason}`, + ); + } + + if (!resp.ok) { + const status = resp.status; + if ((status === 429 || status >= 500) && attempt < HTTP_MAX_RETRIES) { + const delay = HTTP_RETRY_BACKOFF_MS * (attempt + 1); + await new Promise(r => setTimeout(r, delay)); + return httpEmbedBatch(url, batch, model, apiKey, batchIndex, attempt + 1); + } + throw new Error( + `Embedding endpoint returned ${status} (${safeUrl(url)}, batch ${batchIndex})`, + ); + } + + const data = (await resp.json()) as { data: EmbeddingItem[] }; + return data.data; +}; + +/** + * Embed texts via the HTTP backend, splitting into batches. + * Reads config from env vars on every call. + * + * @param texts - Array of texts to embed + * @returns Array of Float32Array embedding vectors + */ +export const httpEmbed = async (texts: string[]): Promise => { + const config = readConfig(); + if (!config) throw new Error('HTTP embedding not configured'); + + const url = `${config.baseUrl}/embeddings`; + const allVectors: Float32Array[] = []; + + for (let i = 0; i < texts.length; i += HTTP_BATCH_SIZE) { + const batch = texts.slice(i, i + HTTP_BATCH_SIZE); + const batchIndex = Math.floor(i / HTTP_BATCH_SIZE); + const items = await httpEmbedBatch(url, batch, config.model, config.apiKey, batchIndex); + + for (const item of items) { + allVectors.push(new Float32Array(item.embedding)); + } + } + + return allVectors; +}; + +/** + * Embed a single query text via the HTTP backend. + * Convenience for MCP search where only one vector is needed. + * + * @param text - Query text to embed + * @returns Embedding vector as number array + */ +export const httpEmbedQuery = async (text: string): Promise => { + const config = readConfig(); + if (!config) throw new Error('HTTP embedding not configured'); + + const url = `${config.baseUrl}/embeddings`; + const items = await httpEmbedBatch(url, [text], config.model, config.apiKey); + return items[0].embedding; +}; diff --git a/gitnexus/src/core/embeddings/index.ts b/gitnexus/src/core/embeddings/index.ts index 4b4f10bb5..19b326187 100644 --- a/gitnexus/src/core/embeddings/index.ts +++ b/gitnexus/src/core/embeddings/index.ts @@ -5,6 +5,7 @@ */ export * from './types.js'; +export * from './http-client.js'; export * from './embedder.js'; export * from './text-generator.js'; export * from './embedding-pipeline.js'; diff --git a/gitnexus/src/core/embeddings/types.ts b/gitnexus/src/core/embeddings/types.ts index 12362097d..25af985c8 100644 --- a/gitnexus/src/core/embeddings/types.ts +++ b/gitnexus/src/core/embeddings/types.ts @@ -65,20 +65,6 @@ export interface EmbeddingConfig { maxSnippetLength: number; } -/** - * 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) */ - baseUrl: string; - /** Model name to send in the request */ - model: string; - /** API key for authentication */ - apiKey: string; - /** Vector dimensions — must match model output (default: 384) */ - dimensions?: number; -} /** * Default embedding configuration diff --git a/gitnexus/src/core/lbug/schema.ts b/gitnexus/src/core/lbug/schema.ts index f995a730f..5a74ef78e 100644 --- a/gitnexus/src/core/lbug/schema.ts +++ b/gitnexus/src/core/lbug/schema.ts @@ -399,7 +399,13 @@ CREATE REL TABLE ${REL_TABLE_NAME} ( // ============================================================================ /** Embedding vector dimensions. Default 384 (snowflake-arctic-embed-xs). */ -export const EMBEDDING_DIMS = parseInt(process.env.GITNEXUS_EMBEDDING_DIMS ?? '384', 10); +const _rawDims = parseInt(process.env.GITNEXUS_EMBEDDING_DIMS ?? '384', 10); +if (Number.isNaN(_rawDims) || _rawDims <= 0) { + throw new Error( + `GITNEXUS_EMBEDDING_DIMS must be a positive integer, got "${process.env.GITNEXUS_EMBEDDING_DIMS}"`, + ); +} +export const EMBEDDING_DIMS = _rawDims; export const EMBEDDING_SCHEMA = ` CREATE NODE TABLE ${EMBEDDING_TABLE_NAME} ( diff --git a/gitnexus/src/mcp/core/embedder.ts b/gitnexus/src/mcp/core/embedder.ts index ab6f64752..11261ff36 100644 --- a/gitnexus/src/mcp/core/embedder.ts +++ b/gitnexus/src/mcp/core/embedder.ts @@ -6,16 +6,10 @@ */ import { pipeline, env, type FeatureExtractionPipeline } from '@huggingface/transformers'; - -// 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); +import { isHttpMode, getHttpDimensions, httpEmbedQuery } from '../../core/embeddings/http-client.js'; // 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; @@ -26,7 +20,7 @@ let initPromise: Promise | null = null; * Initialize the embedding model (lazy, on first search) */ export const initEmbedder = async (): Promise => { - if (USE_HTTP) { + if (isHttpMode()) { throw new Error('initEmbedder() should not be called in HTTP mode.'); } @@ -97,38 +91,14 @@ export const initEmbedder = async (): Promise => { /** * Check if embedder is ready */ -export const isEmbedderReady = (): boolean => USE_HTTP || embedderInstance !== null; +export const isEmbedderReady = (): boolean => isHttpMode() || embedderInstance !== null; /** * Embed a query text for semantic search */ export const embedQuery = async (query: string): Promise => { - if (USE_HTTP) { - 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'); + if (isHttpMode()) { + return httpEmbedQuery(query); } const embedder = await initEmbedder(); @@ -144,7 +114,9 @@ export const embedQuery = async (query: string): Promise => { /** * Get embedding dimensions */ -export const getEmbeddingDims = (): number => EMBEDDING_DIMS; +export const getEmbeddingDims = (): number => { + return getHttpDimensions() ?? 384; +}; /** * Cleanup embedder From 13b22e9879760656aca0fcafd550bcd5bc662cd0 Mon Sep 17 00:00:00 2001 From: zm2231 Date: Fri, 20 Mar 2026 18:53:05 -0400 Subject: [PATCH 06/10] fix: revert auto-generated AGENTS.md/CLAUDE.md changes gitnexus analyze rewrote repo name to local clone name (gitnexus-fork). Restore canonical GitNexus references. --- AGENTS.md | 12 ++++++------ CLAUDE.md | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f9bd21836..cd70281f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **gitnexus-fork** (2179 symbols, 5243 relationships, 166 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **GitNexus** (2169 symbols, 5213 relationships, 165 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. @@ -17,7 +17,7 @@ This project is indexed by GitNexus as **gitnexus-fork** (2179 symbols, 5243 rel 1. `gitnexus_query({query: ""})` — find execution flows related to the issue 2. `gitnexus_context({name: ""})` — see all callers, callees, and process participation -3. `READ gitnexus://repo/gitnexus-fork/process/{processName}` — trace the full execution flow step by step +3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step 4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed ## When Refactoring @@ -56,10 +56,10 @@ This project is indexed by GitNexus as **gitnexus-fork** (2179 symbols, 5243 rel | Resource | Use for | |----------|---------| -| `gitnexus://repo/gitnexus-fork/context` | Codebase overview, check index freshness | -| `gitnexus://repo/gitnexus-fork/clusters` | All functional areas | -| `gitnexus://repo/gitnexus-fork/processes` | All execution flows | -| `gitnexus://repo/gitnexus-fork/process/{name}` | Step-by-step execution trace | +| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness | +| `gitnexus://repo/GitNexus/clusters` | All functional areas | +| `gitnexus://repo/GitNexus/processes` | All execution flows | +| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace | ## Self-Check Before Finishing diff --git a/CLAUDE.md b/CLAUDE.md index f9bd21836..cd70281f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **gitnexus-fork** (2179 symbols, 5243 relationships, 166 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **GitNexus** (2169 symbols, 5213 relationships, 165 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. @@ -17,7 +17,7 @@ This project is indexed by GitNexus as **gitnexus-fork** (2179 symbols, 5243 rel 1. `gitnexus_query({query: ""})` — find execution flows related to the issue 2. `gitnexus_context({name: ""})` — see all callers, callees, and process participation -3. `READ gitnexus://repo/gitnexus-fork/process/{processName}` — trace the full execution flow step by step +3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step 4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed ## When Refactoring @@ -56,10 +56,10 @@ This project is indexed by GitNexus as **gitnexus-fork** (2179 symbols, 5243 rel | Resource | Use for | |----------|---------| -| `gitnexus://repo/gitnexus-fork/context` | Codebase overview, check index freshness | -| `gitnexus://repo/gitnexus-fork/clusters` | All functional areas | -| `gitnexus://repo/gitnexus-fork/processes` | All execution flows | -| `gitnexus://repo/gitnexus-fork/process/{name}` | Step-by-step execution trace | +| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness | +| `gitnexus://repo/GitNexus/clusters` | All functional areas | +| `gitnexus://repo/GitNexus/processes` | All execution flows | +| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace | ## Self-Check Before Finishing From c2c694ac4213a1450454a2b5818b3ec6aef88b9d Mon Sep 17 00:00:00 2001 From: zm2231 Date: Fri, 20 Mar 2026 20:15:59 -0400 Subject: [PATCH 07/10] feat: add .env.example, empty batch guard, dimension mismatch warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add .env.example with all HTTP embedding env vars documented - Early return in httpEmbed() for empty text arrays - Warn once if API returns vectors with different dimensions than GITNEXUS_EMBEDDING_DIMS — helps catch misconfiguration early --- gitnexus/.env.example | 12 ++++++++++++ gitnexus/src/core/embeddings/http-client.ts | 16 ++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 gitnexus/.env.example diff --git a/gitnexus/.env.example b/gitnexus/.env.example new file mode 100644 index 000000000..0c4cd297d --- /dev/null +++ b/gitnexus/.env.example @@ -0,0 +1,12 @@ +# GitNexus HTTP Embedding Configuration +# Copy to .env and uncomment to use a remote OpenAI-compatible endpoint +# instead of the local snowflake-arctic-embed-xs model. +# When unset, local embeddings are used unchanged. + +# GITNEXUS_EMBEDDING_URL=http://your-server:8080/v1 +# GITNEXUS_EMBEDDING_MODEL=BAAI/bge-large-en-v1.5 +# GITNEXUS_EMBEDDING_DIMS=1024 +# GITNEXUS_EMBEDDING_API_KEY=your-key + +# Works with Infinity, vLLM, TEI, llama.cpp, Ollama, LM Studio, or OpenAI. +# See README for details. diff --git a/gitnexus/src/core/embeddings/http-client.ts b/gitnexus/src/core/embeddings/http-client.ts index 331e45e55..7395f3bc5 100644 --- a/gitnexus/src/core/embeddings/http-client.ts +++ b/gitnexus/src/core/embeddings/http-client.ts @@ -133,6 +133,8 @@ const httpEmbedBatch = async ( return data.data; }; +let dimsMismatchWarned = false; + /** * Embed texts via the HTTP backend, splitting into batches. * Reads config from env vars on every call. @@ -141,6 +143,8 @@ const httpEmbedBatch = async ( * @returns Array of Float32Array embedding vectors */ export const httpEmbed = async (texts: string[]): Promise => { + if (texts.length === 0) return []; + const config = readConfig(); if (!config) throw new Error('HTTP embedding not configured'); @@ -157,6 +161,18 @@ export const httpEmbed = async (texts: string[]): Promise => { } } + // Warn once if the API returned a different dimension than configured + if (config.dimensions && allVectors.length > 0 && !dimsMismatchWarned) { + const actual = allVectors[0].length; + if (actual !== config.dimensions) { + console.warn( + `⚠️ HTTP embeddings returned ${actual}d vectors, expected ${config.dimensions}d (GITNEXUS_EMBEDDING_DIMS). ` + + `Update GITNEXUS_EMBEDDING_DIMS to match your model.`, + ); + dimsMismatchWarned = true; + } + } + return allVectors; }; From 9baef90ae21945cbe28d3666341172409dccdbe2 Mon Sep 17 00:00:00 2001 From: zm2231 Date: Fri, 20 Mar 2026 22:27:27 -0400 Subject: [PATCH 08/10] fix: edge case guards, dim mismatch hard-throw, UX label - Guard against empty endpoint response in httpEmbedQuery (Bug 1) - Validate response item count matches batch size in httpEmbed (Bug 2) - Dimension mismatch now hard-throws instead of warn-and-continue (Bug 3) - Progress bar shows Connecting to embedding endpoint in HTTP mode (UX gap) - Added 3 tests: empty response, truncated batch, dim mismatch throw - All 19 HTTP embedder tests pass --- gitnexus/src/cli/analyze.ts | 8 +++- gitnexus/src/core/embeddings/http-client.ts | 25 +++++++---- gitnexus/test/unit/http-embedder.test.ts | 50 +++++++++++++++++++++ 3 files changed, 73 insertions(+), 10 deletions(-) diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 7bd355830..7c5b25b76 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -285,7 +285,9 @@ export const analyzeCommand = async ( } if (!embeddingSkipped) { - updateBar(90, 'Loading embedding model...'); + const { isHttpMode } = await import('../core/embeddings/http-client.js'); + const httpMode = isHttpMode(); + updateBar(90, httpMode ? 'Connecting to embedding endpoint...' : 'Loading embedding model...'); const t0Emb = Date.now(); const { runEmbeddingPipeline } = await import('../core/embeddings/embedding-pipeline.js'); await runEmbeddingPipeline( @@ -293,7 +295,9 @@ export const analyzeCommand = async ( executeWithReusedStatement, (progress) => { const scaled = 90 + Math.round((progress.percent / 100) * 8); - const label = progress.phase === 'loading-model' ? 'Loading embedding model...' : `Embedding ${progress.nodesProcessed || 0}/${progress.totalNodes || '?'}`; + const label = progress.phase === 'loading-model' + ? (httpMode ? 'Connecting to embedding endpoint...' : 'Loading embedding model...') + : `Embedding ${progress.nodesProcessed || 0}/${progress.totalNodes || '?'}`; updateBar(scaled, label); }, {}, diff --git a/gitnexus/src/core/embeddings/http-client.ts b/gitnexus/src/core/embeddings/http-client.ts index 7395f3bc5..b72b7504b 100644 --- a/gitnexus/src/core/embeddings/http-client.ts +++ b/gitnexus/src/core/embeddings/http-client.ts @@ -133,8 +133,6 @@ const httpEmbedBatch = async ( return data.data; }; -let dimsMismatchWarned = false; - /** * Embed texts via the HTTP backend, splitting into batches. * Reads config from env vars on every call. @@ -156,20 +154,28 @@ export const httpEmbed = async (texts: string[]): Promise => { const batchIndex = Math.floor(i / HTTP_BATCH_SIZE); const items = await httpEmbedBatch(url, batch, config.model, config.apiKey, batchIndex); + if (items.length !== batch.length) { + throw new Error( + `Embedding endpoint returned ${items.length} vectors for ${batch.length} texts ` + + `(${safeUrl(url)}, batch ${batchIndex})`, + ); + } + for (const item of items) { allVectors.push(new Float32Array(item.embedding)); } } - // Warn once if the API returned a different dimension than configured - if (config.dimensions && allVectors.length > 0 && !dimsMismatchWarned) { + // Fail fast if the API returned vectors with unexpected dimensions — + // inserting them into the FLOAT[N] column would cause a cryptic Kuzu error. + if (config.dimensions && allVectors.length > 0) { const actual = allVectors[0].length; if (actual !== config.dimensions) { - console.warn( - `⚠️ HTTP embeddings returned ${actual}d vectors, expected ${config.dimensions}d (GITNEXUS_EMBEDDING_DIMS). ` + - `Update GITNEXUS_EMBEDDING_DIMS to match your model.`, + throw new Error( + `Embedding dimension mismatch: endpoint returned ${actual}d vectors, ` + + `but GITNEXUS_EMBEDDING_DIMS is set to ${config.dimensions}. ` + + `Update GITNEXUS_EMBEDDING_DIMS to match your model output.`, ); - dimsMismatchWarned = true; } } @@ -189,5 +195,8 @@ export const httpEmbedQuery = async (text: string): Promise => { const url = `${config.baseUrl}/embeddings`; const items = await httpEmbedBatch(url, [text], config.model, config.apiKey); + if (!items.length) { + throw new Error(`Embedding endpoint returned empty response (${safeUrl(url)})`); + } return items[0].embedding; }; diff --git a/gitnexus/test/unit/http-embedder.test.ts b/gitnexus/test/unit/http-embedder.test.ts index 04cb95171..d35dd2bad 100644 --- a/gitnexus/test/unit/http-embedder.test.ts +++ b/gitnexus/test/unit/http-embedder.test.ts @@ -212,6 +212,56 @@ describe('HTTP embedding backend', () => { delete process.env.GITNEXUS_EMBEDDING_URL; delete process.env.GITNEXUS_EMBEDDING_MODEL; }); + + it('throws on empty response from endpoint', 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: [] }), + })); + + const mod = await import('../../src/mcp/core/embedder.js'); + await expect(mod.embedQuery('test')).rejects.toThrow('empty response'); + + delete process.env.GITNEXUS_EMBEDDING_URL; + delete process.env.GITNEXUS_EMBEDDING_MODEL; + }); + + it('throws when endpoint returns fewer embeddings than texts', 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 { embedBatch } = await import('../../src/core/embeddings/embedder.js'); + await expect(embedBatch(['text1', 'text2', 'text3'])).rejects.toThrow('1 vectors for 3 texts'); + + delete process.env.GITNEXUS_EMBEDDING_URL; + delete process.env.GITNEXUS_EMBEDDING_MODEL; + }); + + it('throws on dimension mismatch when GITNEXUS_EMBEDDING_DIMS is set', async () => { + process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1'; + process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model'; + process.env.GITNEXUS_EMBEDDING_DIMS = '512'; + + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: [{ embedding: [0.1, 0.2, 0.3] }] }), + })); + + const { embedText } = await import('../../src/core/embeddings/embedder.js'); + await expect(embedText('test')).rejects.toThrow('Embedding dimension mismatch'); + + delete process.env.GITNEXUS_EMBEDDING_URL; + delete process.env.GITNEXUS_EMBEDDING_MODEL; + delete process.env.GITNEXUS_EMBEDDING_DIMS; + }); }); describe('schema dimensions', () => { From 89a24d866e842d1352f11aea372b28e748408ff3 Mon Sep 17 00:00:00 2001 From: zm2231 Date: Fri, 20 Mar 2026 22:42:25 -0400 Subject: [PATCH 09/10] fix: validate dimensions on every vector, not just the first --- gitnexus/src/core/embeddings/http-client.ts | 25 +++++++++------------ 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/gitnexus/src/core/embeddings/http-client.ts b/gitnexus/src/core/embeddings/http-client.ts index b72b7504b..d0805269a 100644 --- a/gitnexus/src/core/embeddings/http-client.ts +++ b/gitnexus/src/core/embeddings/http-client.ts @@ -162,20 +162,17 @@ export const httpEmbed = async (texts: string[]): Promise => { } for (const item of items) { - allVectors.push(new Float32Array(item.embedding)); - } - } - - // Fail fast if the API returned vectors with unexpected dimensions — - // inserting them into the FLOAT[N] column would cause a cryptic Kuzu error. - if (config.dimensions && allVectors.length > 0) { - const actual = allVectors[0].length; - if (actual !== config.dimensions) { - throw new Error( - `Embedding dimension mismatch: endpoint returned ${actual}d vectors, ` + - `but GITNEXUS_EMBEDDING_DIMS is set to ${config.dimensions}. ` + - `Update GITNEXUS_EMBEDDING_DIMS to match your model output.`, - ); + const vec = new Float32Array(item.embedding); + // Fail fast on dimension mismatch rather than inserting bad vectors + // into the FLOAT[N] column which would cause a cryptic Kuzu error. + if (config.dimensions && vec.length !== config.dimensions) { + throw new Error( + `Embedding dimension mismatch: endpoint returned ${vec.length}d vector, ` + + `but GITNEXUS_EMBEDDING_DIMS is set to ${config.dimensions}. ` + + `Update GITNEXUS_EMBEDDING_DIMS to match your model output.`, + ); + } + allVectors.push(vec); } } From 9954f6fdfd2a52d7188a6311423f1245cc86b8ec Mon Sep 17 00:00:00 2001 From: zm2231 Date: Sun, 22 Mar 2026 20:21:08 -0400 Subject: [PATCH 10/10] fix: timeout detection, always-on dim validation, test hardening - Fix timeout detection: AbortSignal.timeout() throws TimeoutError, not AbortError. Timeouts are no longer retried (30s fail, not 93s). - Validate embedding dimensions in both httpEmbed and httpEmbedQuery against config.dimensions or the 384d schema default. When DIMS is unset, the error says 'Set GITNEXUS_EMBEDDING_DIMS=N' to guide users. - Centralize test env var cleanup in afterEach via savedEnv snapshot. - Test mocks use 384d vectors matching schema default. - 4 new tests: timeout not retried, network retry success, query path dim mismatch, unset-dims hint. 23 total, all pass. --- gitnexus/src/core/embeddings/http-client.ts | 37 +++++- gitnexus/test/unit/http-embedder.test.ts | 131 +++++++++++++------- 2 files changed, 121 insertions(+), 47 deletions(-) diff --git a/gitnexus/src/core/embeddings/http-client.ts b/gitnexus/src/core/embeddings/http-client.ts index d0805269a..b16496440 100644 --- a/gitnexus/src/core/embeddings/http-client.ts +++ b/gitnexus/src/core/embeddings/http-client.ts @@ -9,6 +9,7 @@ const HTTP_TIMEOUT_MS = 30_000; const HTTP_MAX_RETRIES = 2; const HTTP_RETRY_BACKOFF_MS = 1_000; const HTTP_BATCH_SIZE = 64; +const DEFAULT_DIMS = 384; interface HttpConfig { baseUrl: string; @@ -105,7 +106,15 @@ const httpEmbedBatch = async ( body: JSON.stringify({ input: batch, model }), }); } catch (err) { - // DNS, timeout, connection errors — add context without leaking the key + // Timeouts should not be retried — the server is unresponsive. + // AbortSignal.timeout() throws DOMException with name 'TimeoutError'. + const isTimeout = err instanceof DOMException && err.name === 'TimeoutError'; + if (isTimeout) { + throw new Error( + `Embedding request timed out after ${HTTP_TIMEOUT_MS}ms (${safeUrl(url)}, batch ${batchIndex})`, + ); + } + // DNS, connection errors — retry with backoff if (attempt < HTTP_MAX_RETRIES) { const delay = HTTP_RETRY_BACKOFF_MS * (attempt + 1); await new Promise(r => setTimeout(r, delay)); @@ -165,13 +174,17 @@ export const httpEmbed = async (texts: string[]): Promise => { const vec = new Float32Array(item.embedding); // Fail fast on dimension mismatch rather than inserting bad vectors // into the FLOAT[N] column which would cause a cryptic Kuzu error. - if (config.dimensions && vec.length !== config.dimensions) { + const expected = config.dimensions ?? DEFAULT_DIMS; + if (vec.length !== expected) { + const hint = config.dimensions + ? 'Update GITNEXUS_EMBEDDING_DIMS to match your model output.' + : `Set GITNEXUS_EMBEDDING_DIMS=${vec.length} to match your model output.`; throw new Error( `Embedding dimension mismatch: endpoint returned ${vec.length}d vector, ` + - `but GITNEXUS_EMBEDDING_DIMS is set to ${config.dimensions}. ` + - `Update GITNEXUS_EMBEDDING_DIMS to match your model output.`, + `but expected ${expected}d. ${hint}`, ); } + allVectors.push(vec); } } @@ -195,5 +208,19 @@ export const httpEmbedQuery = async (text: string): Promise => { if (!items.length) { throw new Error(`Embedding endpoint returned empty response (${safeUrl(url)})`); } - return items[0].embedding; + + const embedding = items[0].embedding; + // Same dimension checks as httpEmbed — catch mismatches before they + // reach the Kuzu FLOAT[N] cast in search queries. + const expected = config.dimensions ?? DEFAULT_DIMS; + if (embedding.length !== expected) { + const hint = config.dimensions + ? 'Update GITNEXUS_EMBEDDING_DIMS to match your model output.' + : `Set GITNEXUS_EMBEDDING_DIMS=${embedding.length} to match your model output.`; + throw new Error( + `Embedding dimension mismatch: endpoint returned ${embedding.length}d vector, ` + + `but expected ${expected}d. ${hint}`, + ); + } + return embedding; }; diff --git a/gitnexus/test/unit/http-embedder.test.ts b/gitnexus/test/unit/http-embedder.test.ts index d35dd2bad..e0e9150be 100644 --- a/gitnexus/test/unit/http-embedder.test.ts +++ b/gitnexus/test/unit/http-embedder.test.ts @@ -1,10 +1,33 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { getEmbeddingDims, isEmbedderReady } from '../../src/mcp/core/embedder.js'; +const ENV_KEYS = [ + 'GITNEXUS_EMBEDDING_URL', + 'GITNEXUS_EMBEDDING_MODEL', + 'GITNEXUS_EMBEDDING_API_KEY', + 'GITNEXUS_EMBEDDING_DIMS', +] as const; + +/** 384d mock vector matching the default schema dimensions. */ +const mockVec = Array.from({ length: 384 }, (_, i) => i / 384); + describe('HTTP embedding backend', () => { + // Save original env state before any test mutates it + const savedEnv = Object.fromEntries( + ENV_KEYS.map(k => [k, process.env[k]]), + ); + afterEach(() => { vi.unstubAllGlobals(); vi.resetModules(); + // Restore env vars to pre-test state so a mid-test throw can't leak + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } + } }); describe('MCP embedder', () => { @@ -21,8 +44,6 @@ describe('HTTP embedding backend', () => { 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 () => { @@ -31,16 +52,13 @@ describe('HTTP embedding backend', () => { 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] }] }) }; + const ok = { ok: true, json: async () => ({ data: [{ embedding: mockVec }] }) }; vi.stubGlobal('fetch', vi.fn() .mockResolvedValueOnce({ ok: false, status: 503 }) .mockResolvedValueOnce(ok)); @@ -49,10 +67,8 @@ describe('HTTP embedding backend', () => { const result = await mod.embedQuery('test query'); expect(fetch).toHaveBeenCalledTimes(2); - expect(result).toEqual([0.1, 0.2]); + expect(result).toEqual(mockVec); - delete process.env.GITNEXUS_EMBEDDING_URL; - delete process.env.GITNEXUS_EMBEDDING_MODEL; }); }); @@ -78,16 +94,13 @@ describe('HTTP embedding backend', () => { 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] }] }) }; + const ok = { ok: true, json: async () => ({ data: [{ embedding: mockVec }] }) }; vi.stubGlobal('fetch', vi.fn() .mockResolvedValueOnce({ ok: false, status: 503 }) .mockResolvedValueOnce(ok)); @@ -96,15 +109,13 @@ describe('HTTP embedding backend', () => { 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] }] }) }; + const ok = { ok: true, json: async () => ({ data: [{ embedding: mockVec }] }) }; vi.stubGlobal('fetch', vi.fn() .mockResolvedValueOnce({ ok: false, status: 429 }) .mockResolvedValueOnce(ok)); @@ -113,8 +124,6 @@ describe('HTTP embedding backend', () => { 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 () => { @@ -126,8 +135,6 @@ describe('HTTP embedding backend', () => { 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 () => { @@ -145,9 +152,6 @@ describe('HTTP embedding backend', () => { 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 () => { @@ -156,7 +160,7 @@ describe('HTTP embedding backend', () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, - json: async () => ({ data: [{ embedding: [0.1] }] }), + json: async () => ({ data: [{ embedding: mockVec }] }), })); const { embedText } = await import('../../src/core/embeddings/embedder.js'); @@ -165,8 +169,6 @@ describe('HTTP embedding backend', () => { 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 () => { @@ -175,7 +177,7 @@ describe('HTTP embedding backend', () => { const makeResp = (n: number) => ({ ok: true, - json: async () => ({ data: Array.from({ length: n }, () => ({ embedding: [0.1] })) }), + json: async () => ({ data: Array.from({ length: n }, () => ({ embedding: mockVec })) }), }); vi.stubGlobal('fetch', vi.fn() .mockResolvedValueOnce(makeResp(64)) @@ -187,8 +189,6 @@ describe('HTTP embedding backend', () => { 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 () => { @@ -198,8 +198,6 @@ describe('HTTP embedding backend', () => { 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 () => { @@ -209,8 +207,6 @@ describe('HTTP embedding backend', () => { 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; }); it('throws on empty response from endpoint', async () => { @@ -225,8 +221,6 @@ describe('HTTP embedding backend', () => { const mod = await import('../../src/mcp/core/embedder.js'); await expect(mod.embedQuery('test')).rejects.toThrow('empty response'); - delete process.env.GITNEXUS_EMBEDDING_URL; - delete process.env.GITNEXUS_EMBEDDING_MODEL; }); it('throws when endpoint returns fewer embeddings than texts', async () => { @@ -235,14 +229,12 @@ describe('HTTP embedding backend', () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, - json: async () => ({ data: [{ embedding: [0.1] }] }), + json: async () => ({ data: [{ embedding: mockVec }] }), })); const { embedBatch } = await import('../../src/core/embeddings/embedder.js'); await expect(embedBatch(['text1', 'text2', 'text3'])).rejects.toThrow('1 vectors for 3 texts'); - delete process.env.GITNEXUS_EMBEDDING_URL; - delete process.env.GITNEXUS_EMBEDDING_MODEL; }); it('throws on dimension mismatch when GITNEXUS_EMBEDDING_DIMS is set', async () => { @@ -258,9 +250,6 @@ describe('HTTP embedding backend', () => { const { embedText } = await import('../../src/core/embeddings/embedder.js'); await expect(embedText('test')).rejects.toThrow('Embedding dimension mismatch'); - delete process.env.GITNEXUS_EMBEDDING_URL; - delete process.env.GITNEXUS_EMBEDDING_MODEL; - delete process.env.GITNEXUS_EMBEDDING_DIMS; }); }); @@ -274,7 +263,65 @@ describe('HTTP embedding backend', () => { 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; + }); + }); + + describe('timeout and network error handling', () => { + it('does not retry on timeout', async () => { + process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1'; + process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model'; + + const timeoutErr = new DOMException('The operation was aborted due to timeout', 'TimeoutError'); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(timeoutErr)); + + const { embedText } = await import('../../src/core/embeddings/embedder.js'); + await expect(embedText('test')).rejects.toThrow('timed out'); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('retries on network error then succeeds', 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: mockVec }] }) }; + vi.stubGlobal('fetch', vi.fn() + .mockRejectedValueOnce(new TypeError('fetch failed')) + .mockResolvedValueOnce(ok)); + + const { embedText } = await import('../../src/core/embeddings/embedder.js'); + const result = await embedText('test'); + expect(fetch).toHaveBeenCalledTimes(2); + expect(result).toBeInstanceOf(Float32Array); + }); + }); + + describe('dimension mismatch on query path', () => { + it('throws on explicit dim mismatch in embedQuery', async () => { + process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1'; + process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model'; + process.env.GITNEXUS_EMBEDDING_DIMS = '512'; + + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: [{ embedding: mockVec }] }), + })); + + const mod = await import('../../src/mcp/core/embedder.js'); + await expect(mod.embedQuery('test')).rejects.toThrow('dimension mismatch'); + }); + + it('throws with Set hint when GITNEXUS_EMBEDDING_DIMS is unset', async () => { + process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1'; + process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model'; + + const vec768 = Array.from({ length: 768 }, (_, i) => i / 768); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: [{ embedding: vec768 }] }), + })); + + const { embedText } = await import('../../src/core/embeddings/embedder.js'); + await expect(embedText('test')).rejects.toThrow('Set GITNEXUS_EMBEDDING_DIMS=768'); }); }); });