fix: address review feedback — deduplicate HTTP client, remove config cache, add guards

- 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
This commit is contained in:
zm2231 2026-03-20 18:23:26 -04:00
parent 7dcafb647b
commit c8480f899d
8 changed files with 209 additions and 153 deletions

View file

@ -1,7 +1,7 @@
<!-- gitnexus:start -->
# 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: "<error or symptom>"})` — find execution flows related to the issue
2. `gitnexus_context({name: "<suspect function>"})` — 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

View file

@ -1,7 +1,7 @@
<!-- gitnexus:start -->
# 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: "<error or symptom>"})` — find execution flows related to the issue
2. `gitnexus_context({name: "<suspect function>"})` — 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

View file

@ -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<Array<{ embedding: number[] }>> {
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<Float32Array[]> {
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;
};

View file

@ -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 '<invalid-url>';
}
};
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<EmbeddingItem[]> => {
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<Float32Array[]> => {
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<number[]> => {
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;
};

View file

@ -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';

View file

@ -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

View file

@ -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} (

View file

@ -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<FeatureExtractionPipeline> | null = null;
* Initialize the embedding model (lazy, on first search)
*/
export const initEmbedder = async (): Promise<FeatureExtractionPipeline> => {
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<FeatureExtractionPipeline> => {
/**
* 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<number[]> => {
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<number[]> => {
/**
* Get embedding dimensions
*/
export const getEmbeddingDims = (): number => EMBEDDING_DIMS;
export const getEmbeddingDims = (): number => {
return getHttpDimensions() ?? 384;
};
/**
* Cleanup embedder