From ab1095af7da440ee1b053ad2e15dcd61b14f5cef Mon Sep 17 00:00:00 2001 From: zm2231 Date: Fri, 20 Mar 2026 01:32:49 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20address=20review=20feedback=20=E2=80=94?= =?UTF-8?q?=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; + }); + }); +});