mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-24 00:51:53 +00:00
518 lines
19 KiB
TypeScript
518 lines
19 KiB
TypeScript
/**
|
|
* HTTP Embedding Client
|
|
*
|
|
* Shared fetch+retry logic for OpenAI-compatible /v1/embeddings endpoints.
|
|
* Imported by both the core embedder (batch) and MCP embedder (query).
|
|
*
|
|
* Network resilience is delegated to `resilientFetch` from
|
|
* `gitnexus-shared` — bounded retries with exponential-backoff jitter,
|
|
* `Retry-After` honored on 429, and an in-process circuit breaker that
|
|
* fails fast on a flapping endpoint. Per-attempt timeout is enforced
|
|
* via `AbortSignal.timeout` on the underlying fetch.
|
|
*/
|
|
|
|
import { CircuitOpenError, ResilientFetchExhaustedError, resilientFetch } from 'gitnexus-shared';
|
|
|
|
const HTTP_TIMEOUT_MS = 30_000;
|
|
const HTTP_MAX_RETRIES = 2;
|
|
const HTTP_RETRY_BACKOFF_MS = 1_000;
|
|
const HTTP_RETRY_CAP_MS = 5_000;
|
|
const HTTP_BATCH_SIZE = 64;
|
|
const DEFAULT_DIMS = 384;
|
|
const HTTP_BREAKER_KEY = 'embeddings-http';
|
|
|
|
interface HttpConfig {
|
|
baseUrl: string;
|
|
model: string;
|
|
apiKey: string;
|
|
dimensions?: number;
|
|
maxAttempts: number;
|
|
retryCapMs: number;
|
|
minIntervalMs: number;
|
|
}
|
|
|
|
export interface EmbeddingRequestOptions {
|
|
signal?: AbortSignal;
|
|
}
|
|
|
|
let lastHttpRequestStartedAt: number | undefined;
|
|
let httpPaceQueue: Promise<void> = Promise.resolve();
|
|
|
|
const parsePositiveIntegerEnv = (name: string, fallback: number, max: number): number => {
|
|
const raw = process.env[name];
|
|
if (raw === undefined || raw === '') return fallback;
|
|
if (!/^\d+$/u.test(raw)) {
|
|
throw new Error(`${name} must be a positive integer, got "${raw}"`);
|
|
}
|
|
const parsed = Number(raw);
|
|
if (!Number.isSafeInteger(parsed) || parsed <= 0 || parsed > max) {
|
|
throw new Error(`${name} must be a positive integer <= ${max}, got "${raw}"`);
|
|
}
|
|
return parsed;
|
|
};
|
|
|
|
const parseNonNegativeIntegerEnv = (name: string, fallback: number, max: number): number => {
|
|
const raw = process.env[name];
|
|
if (raw === undefined || raw === '') return fallback;
|
|
if (!/^\d+$/u.test(raw)) {
|
|
throw new Error(`${name} must be a non-negative integer, got "${raw}"`);
|
|
}
|
|
const parsed = Number(raw);
|
|
if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > max) {
|
|
throw new Error(`${name} must be a non-negative integer <= ${max}, got "${raw}"`);
|
|
}
|
|
return parsed;
|
|
};
|
|
|
|
const cancelledError = (): DOMException =>
|
|
new DOMException('Embedding request cancelled', 'AbortError');
|
|
|
|
const throwIfAborted = (signal?: AbortSignal): void => {
|
|
if (signal?.aborted) throw cancelledError();
|
|
};
|
|
|
|
const abortableSleep = (ms: number, signal?: AbortSignal): Promise<void> => {
|
|
throwIfAborted(signal);
|
|
if (ms <= 0) return Promise.resolve();
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
signal?.removeEventListener('abort', onAbort);
|
|
resolve();
|
|
}, ms);
|
|
const onAbort = () => {
|
|
clearTimeout(timer);
|
|
signal?.removeEventListener('abort', onAbort);
|
|
reject(cancelledError());
|
|
};
|
|
signal?.addEventListener('abort', onAbort, { once: true });
|
|
});
|
|
};
|
|
|
|
const paceHttpRequest = async (minIntervalMs: number, signal?: AbortSignal): Promise<void> => {
|
|
throwIfAborted(signal);
|
|
if (minIntervalMs <= 0) return;
|
|
const waitTurn = httpPaceQueue.then(async () => {
|
|
throwIfAborted(signal);
|
|
const waitMs =
|
|
lastHttpRequestStartedAt === undefined
|
|
? 0
|
|
: Math.max(0, lastHttpRequestStartedAt + minIntervalMs - Date.now());
|
|
await abortableSleep(waitMs, signal);
|
|
throwIfAborted(signal);
|
|
lastHttpRequestStartedAt = Date.now();
|
|
});
|
|
httpPaceQueue = waitTurn.catch(() => undefined);
|
|
await waitTurn;
|
|
};
|
|
|
|
/**
|
|
* Stable lead of the {@link readConfig} malformed-`GITNEXUS_EMBEDDING_DIMS`
|
|
* error. `readConfig` throws a plain `Error` (not an {@link HttpEmbeddingError})
|
|
* because this is a *config* mistake, not an endpoint failure — so the CLI
|
|
* recognizes it by this lead ({@link isHttpEmbeddingDimsError}) and prints a
|
|
* clean config message instead of a raw stack dump. See #2385.
|
|
*/
|
|
const EMBEDDING_DIMS_ENV_ERROR_LEAD = 'GITNEXUS_EMBEDDING_DIMS must be a positive integer';
|
|
|
|
/**
|
|
* @internal Exported for the CLI analyze error handler. True when `message` is
|
|
* the {@link readConfig} malformed-DIMS config error (a plain `Error`).
|
|
*/
|
|
export const isHttpEmbeddingDimsError = (message: string): boolean =>
|
|
message.includes(EMBEDDING_DIMS_ENV_ERROR_LEAD);
|
|
|
|
/**
|
|
* 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.
|
|
* Validates GITNEXUS_EMBEDDING_DIMS and throws on a malformed value; callers
|
|
* that only need to know whether HTTP mode is *configured* must use
|
|
* {@link isHttpMode} (a presence probe that never throws), not this.
|
|
*/
|
|
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) {
|
|
if (!/^\d+$/.test(rawDims)) {
|
|
throw new Error(`${EMBEDDING_DIMS_ENV_ERROR_LEAD}, got "${rawDims}"`);
|
|
}
|
|
const parsed = parseInt(rawDims, 10);
|
|
if (parsed <= 0) {
|
|
throw new Error(`${EMBEDDING_DIMS_ENV_ERROR_LEAD}, got "${rawDims}"`);
|
|
}
|
|
dimensions = parsed;
|
|
}
|
|
|
|
return {
|
|
baseUrl: baseUrl.replace(/\/+$/, ''),
|
|
model,
|
|
apiKey: process.env.GITNEXUS_EMBEDDING_API_KEY ?? 'unused',
|
|
dimensions,
|
|
maxAttempts: parsePositiveIntegerEnv(
|
|
'GITNEXUS_EMBEDDING_MAX_ATTEMPTS',
|
|
HTTP_MAX_RETRIES + 1,
|
|
20,
|
|
),
|
|
retryCapMs: parsePositiveIntegerEnv(
|
|
'GITNEXUS_EMBEDDING_RETRY_CAP_MS',
|
|
HTTP_RETRY_CAP_MS,
|
|
300_000,
|
|
),
|
|
minIntervalMs: parseNonNegativeIntegerEnv('GITNEXUS_EMBEDDING_MIN_INTERVAL_MS', 0, 300_000),
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Whether HTTP embedding mode is active — i.e. both `GITNEXUS_EMBEDDING_URL` and
|
|
* `GITNEXUS_EMBEDDING_MODEL` are set. A pure presence probe: it deliberately does
|
|
* NOT call {@link readConfig}, so it never throws on a malformed
|
|
* `GITNEXUS_EMBEDDING_DIMS`. This lets its ~13 call sites (analyze, doctor,
|
|
* run-analyze, embedder, mcp) probe the mode without a defensive try/catch; the
|
|
* DIMS value is validated where it is actually used (`readConfig` in
|
|
* `httpEmbed`/`httpEmbedQuery`), surfacing a recognizable config error. See #2385.
|
|
*/
|
|
export const isHttpMode = (): boolean =>
|
|
Boolean(process.env.GITNEXUS_EMBEDDING_URL && process.env.GITNEXUS_EMBEDDING_MODEL);
|
|
|
|
/**
|
|
* 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 logs and error messages.
|
|
* Strips query string (may contain tokens) and userinfo (may contain
|
|
* credentials), keeping protocol + host + path. Exported so the CLI's
|
|
* custom-endpoint confirmation can mask the same way.
|
|
*/
|
|
export const safeUrl = (url: string): string => {
|
|
try {
|
|
const u = new URL(url);
|
|
return `${u.protocol}//${u.host}${u.pathname}`;
|
|
} catch {
|
|
return '<invalid-url>';
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Strip credentials from an underlying transport error message before it is
|
|
* surfaced. A credential-bearing endpoint URL (`https://user:secret@host/v1`)
|
|
* makes undici throw `TypeError: Request cannot be constructed from a URL that
|
|
* includes credentials: <that full URL>`; interpolating `err.message` verbatim
|
|
* would re-leak the secret to stderr + logs even though the URL argument is
|
|
* already masked with {@link safeUrl}. First swap the exact configured `url` for
|
|
* its masked form, then strip any residual `scheme://userinfo@` the transport may
|
|
* have echoed in a normalized (non-exact) form. See #2385.
|
|
*/
|
|
const sanitizeReason = (reason: string, url: string, apiKey?: string): string => {
|
|
const withoutUrlCredentials = reason
|
|
.split(url)
|
|
.join(safeUrl(url))
|
|
.replace(/([a-z][a-z0-9+.-]*:\/\/)[^/@\s]*@/gi, '$1');
|
|
return apiKey && apiKey !== 'unused'
|
|
? withoutUrlCredentials.split(apiKey).join('[redacted]')
|
|
: withoutUrlCredentials;
|
|
};
|
|
|
|
/**
|
|
* Error thrown by this module's HTTP embedding path (`httpEmbedBatch` /
|
|
* `httpEmbed` / `httpEmbedQuery`) for any endpoint failure — a
|
|
* connection/timeout/DNS error, an open circuit, a non-OK status, an
|
|
* unparseable or wrong-shape response body, an empty response, or a dimension
|
|
* mismatch.
|
|
*
|
|
* Carrying a distinct type (rather than a plain `Error`) lets the CLI tell a
|
|
* *custom endpoint* failure apart from a HuggingFace *model download* failure
|
|
* without matching message text: the two share the same underlying network
|
|
* substrings (`fetch failed`, `ECONNREFUSED`, …), which is exactly why
|
|
* `isNetworkFetchError` in `hf-env.ts` cannot tell them apart. Keying on the
|
|
* type instead of the message is also locale-proof and survives message
|
|
* rewording. The human-readable `.message` (built with `safeUrl` and the
|
|
* underlying reason) is what the CLI surfaces to the user. See #2385.
|
|
*/
|
|
export class HttpEmbeddingError extends Error {
|
|
constructor(message: string, options?: { cause?: unknown }) {
|
|
super(message, options?.cause !== undefined ? { cause: options.cause } : undefined);
|
|
this.name = 'HttpEmbeddingError';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @internal Exported for the CLI analyze error handler and unit tests.
|
|
*
|
|
* Type-guard for {@link HttpEmbeddingError}. The `name` fallback keeps the
|
|
* check working across module-realm boundaries where `instanceof` can fail
|
|
* (two loaded copies of the class) — mirroring the codebase's existing
|
|
* `err.name === 'TimeoutError'` idiom. Matches on the stable class
|
|
* discriminator, never on the human-readable (potentially localized) message.
|
|
*/
|
|
export const isHttpEmbeddingError = (err: unknown): boolean =>
|
|
err instanceof HttpEmbeddingError || (err instanceof Error && err.name === 'HttpEmbeddingError');
|
|
|
|
interface EmbeddingItem {
|
|
embedding: number[];
|
|
}
|
|
|
|
/**
|
|
* Runtime guard for a single response item. The `Array.isArray(data.data)` shape
|
|
* check only validates the outer array — a 200 body like `{"data":[null]}` passes
|
|
* it, then crashes at `new Float32Array(item.embedding)` (`httpEmbed`) or
|
|
* `items[0].embedding` (`httpEmbedQuery`) with a raw `TypeError` that escapes the
|
|
* typed boundary, landing on the CLI's generic stack-dump path — the exact class
|
|
* #2385 closes. Validate each item so every wrong-shape body stays classifiable.
|
|
*/
|
|
const isEmbeddingItem = (item: unknown): item is EmbeddingItem =>
|
|
typeof item === 'object' &&
|
|
item !== null &&
|
|
Array.isArray((item as { embedding?: unknown }).embedding);
|
|
|
|
/**
|
|
* 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 dimensions - Optional output-vector size. When provided, sent as
|
|
* the `dimensions` field in the request body. Endpoints that implement
|
|
* Matryoshka truncation (OpenAI text-embedding-3-*, Cohere embed-v3,
|
|
* Voyage) return a truncated vector at that size; endpoints that do not
|
|
* recognise the field may ignore it or return 400. Leave
|
|
* `GITNEXUS_EMBEDDING_DIMS` unset for strict backends that reject
|
|
* unknown fields.
|
|
*/
|
|
const httpEmbedBatch = async (
|
|
url: string,
|
|
batch: string[],
|
|
model: string,
|
|
apiKey: string,
|
|
batchIndex = 0,
|
|
dimensions?: number,
|
|
requestOptions: EmbeddingRequestOptions = {},
|
|
maxAttempts = HTTP_MAX_RETRIES + 1,
|
|
retryCapMs = HTTP_RETRY_CAP_MS,
|
|
minIntervalMs = 0,
|
|
): Promise<EmbeddingItem[]> => {
|
|
const requestBody: { input: string[]; model: string; dimensions?: number } = {
|
|
input: batch,
|
|
model,
|
|
};
|
|
if (dimensions !== undefined) {
|
|
requestBody.dimensions = dimensions;
|
|
}
|
|
|
|
let resp: Response;
|
|
try {
|
|
throwIfAborted(requestOptions.signal);
|
|
resp = await resilientFetch(
|
|
url,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${apiKey}`,
|
|
},
|
|
body: JSON.stringify(requestBody),
|
|
},
|
|
{
|
|
fetchImpl: async (input, init) => {
|
|
await paceHttpRequest(minIntervalMs, requestOptions.signal);
|
|
throwIfAborted(requestOptions.signal);
|
|
const timeoutSignal = AbortSignal.timeout(HTTP_TIMEOUT_MS);
|
|
const signal = requestOptions.signal
|
|
? AbortSignal.any([requestOptions.signal, timeoutSignal])
|
|
: timeoutSignal;
|
|
return globalThis.fetch(input, { ...init, signal });
|
|
},
|
|
breakerKey: HTTP_BREAKER_KEY,
|
|
retry: {
|
|
maxAttempts,
|
|
baseDelayMs: HTTP_RETRY_BACKOFF_MS,
|
|
capDelayMs: retryCapMs,
|
|
retryAfterCapMs: retryCapMs,
|
|
sleep: (ms) => abortableSleep(ms, requestOptions.signal),
|
|
},
|
|
},
|
|
);
|
|
} catch (err) {
|
|
if (
|
|
requestOptions.signal?.aborted ||
|
|
(err instanceof DOMException && err.name === 'AbortError')
|
|
) {
|
|
throw new HttpEmbeddingError(
|
|
`Embedding request cancelled (${safeUrl(url)}, batch ${batchIndex})`,
|
|
{ cause: err },
|
|
);
|
|
}
|
|
if (err instanceof CircuitOpenError) {
|
|
throw new HttpEmbeddingError(
|
|
`Embedding endpoint circuit open (${safeUrl(url)}, batch ${batchIndex}): retry in ${Math.ceil(err.retryAfterMs / 1000)}s`,
|
|
{ cause: err },
|
|
);
|
|
}
|
|
if (err instanceof DOMException && err.name === 'TimeoutError') {
|
|
throw new HttpEmbeddingError(
|
|
`Embedding request timed out after ${HTTP_TIMEOUT_MS}ms (${safeUrl(url)}, batch ${batchIndex})`,
|
|
{ cause: err },
|
|
);
|
|
}
|
|
if (err instanceof ResilientFetchExhaustedError) {
|
|
throw new HttpEmbeddingError(
|
|
`Embedding endpoint returned ${err.response.status} (${safeUrl(url)}, batch ${batchIndex})`,
|
|
{ cause: err },
|
|
);
|
|
}
|
|
const reason = sanitizeReason(err instanceof Error ? err.message : String(err), url, apiKey);
|
|
const safeCause = new Error(reason);
|
|
safeCause.name = err instanceof Error ? err.name : 'EmbeddingTransportError';
|
|
throw new HttpEmbeddingError(
|
|
`Embedding request failed (${safeUrl(url)}, batch ${batchIndex}): ${reason}`,
|
|
{ cause: safeCause },
|
|
);
|
|
}
|
|
|
|
if (!resp.ok) {
|
|
// resilientFetch already retried 5xx/429; any non-OK response here is
|
|
// a terminal client error (4xx other than 429).
|
|
throw new HttpEmbeddingError(
|
|
`Embedding endpoint returned ${resp.status} (${safeUrl(url)}, batch ${batchIndex})`,
|
|
);
|
|
}
|
|
|
|
// A reachable-but-wrong endpoint (e.g. a captive portal or a non-embeddings
|
|
// service) can answer 200 with an HTML/truncated body. Parse inside the
|
|
// typed-error boundary so that lands as an endpoint failure the CLI can
|
|
// classify, not a raw SyntaxError/TypeError on the generic stack-dump path.
|
|
let data: { data: EmbeddingItem[] };
|
|
try {
|
|
data = (await resp.json()) as { data: EmbeddingItem[] };
|
|
} catch (err) {
|
|
throw new HttpEmbeddingError(
|
|
`Embedding endpoint returned an unparseable response (${safeUrl(url)}, batch ${batchIndex})`,
|
|
{ cause: err },
|
|
);
|
|
}
|
|
if (!Array.isArray(data?.data) || !data.data.every(isEmbeddingItem)) {
|
|
throw new HttpEmbeddingError(
|
|
`Embedding endpoint returned an unexpected response shape (${safeUrl(url)}, batch ${batchIndex})`,
|
|
);
|
|
}
|
|
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[],
|
|
requestOptions: EmbeddingRequestOptions = {},
|
|
): Promise<Float32Array[]> => {
|
|
if (texts.length === 0) return [];
|
|
|
|
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,
|
|
config.dimensions,
|
|
requestOptions,
|
|
config.maxAttempts,
|
|
config.retryCapMs,
|
|
config.minIntervalMs,
|
|
);
|
|
|
|
if (items.length !== batch.length) {
|
|
throw new HttpEmbeddingError(
|
|
`Embedding endpoint returned ${items.length} vectors for ${batch.length} texts ` +
|
|
`(${safeUrl(url)}, batch ${batchIndex})`,
|
|
);
|
|
}
|
|
|
|
for (const item of items) {
|
|
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.
|
|
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 HttpEmbeddingError(
|
|
`Embedding dimension mismatch: endpoint returned ${vec.length}d vector, ` +
|
|
`but expected ${expected}d. ${hint}`,
|
|
);
|
|
}
|
|
|
|
allVectors.push(vec);
|
|
}
|
|
}
|
|
|
|
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,
|
|
requestOptions: EmbeddingRequestOptions = {},
|
|
): 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,
|
|
0,
|
|
config.dimensions,
|
|
requestOptions,
|
|
config.maxAttempts,
|
|
config.retryCapMs,
|
|
config.minIntervalMs,
|
|
);
|
|
if (!items.length) {
|
|
throw new HttpEmbeddingError(`Embedding endpoint returned empty response (${safeUrl(url)})`);
|
|
}
|
|
|
|
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 HttpEmbeddingError(
|
|
`Embedding dimension mismatch: endpoint returned ${embedding.length}d vector, ` +
|
|
`but expected ${expected}d. ${hint}`,
|
|
);
|
|
}
|
|
return embedding;
|
|
};
|