mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-08 03:08:13 +00:00
Merge branch 'main' into fix/no-stats-flag-effective
This commit is contained in:
commit
0ca039ef28
2 changed files with 149 additions and 5 deletions
|
|
@ -40,8 +40,11 @@ const readConfig = (): HttpConfig | null => {
|
||||||
const rawDims = process.env.GITNEXUS_EMBEDDING_DIMS;
|
const rawDims = process.env.GITNEXUS_EMBEDDING_DIMS;
|
||||||
let dimensions: number | undefined;
|
let dimensions: number | undefined;
|
||||||
if (rawDims !== undefined) {
|
if (rawDims !== undefined) {
|
||||||
|
if (!/^\d+$/.test(rawDims)) {
|
||||||
|
throw new Error(`GITNEXUS_EMBEDDING_DIMS must be a positive integer, got "${rawDims}"`);
|
||||||
|
}
|
||||||
const parsed = parseInt(rawDims, 10);
|
const parsed = parseInt(rawDims, 10);
|
||||||
if (Number.isNaN(parsed) || parsed <= 0) {
|
if (parsed <= 0) {
|
||||||
throw new Error(`GITNEXUS_EMBEDDING_DIMS must be a positive integer, got "${rawDims}"`);
|
throw new Error(`GITNEXUS_EMBEDDING_DIMS must be a positive integer, got "${rawDims}"`);
|
||||||
}
|
}
|
||||||
dimensions = parsed;
|
dimensions = parsed;
|
||||||
|
|
@ -91,7 +94,13 @@ interface EmbeddingItem {
|
||||||
* @param model - Model name for the request body
|
* @param model - Model name for the request body
|
||||||
* @param apiKey - Bearer token (only used in Authorization header)
|
* @param apiKey - Bearer token (only used in Authorization header)
|
||||||
* @param batchIndex - Logical batch number (for error context)
|
* @param batchIndex - Logical batch number (for error context)
|
||||||
* @param attempt - Current retry attempt (internal)
|
* @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 (
|
const httpEmbedBatch = async (
|
||||||
url: string,
|
url: string,
|
||||||
|
|
@ -99,7 +108,16 @@ const httpEmbedBatch = async (
|
||||||
model: string,
|
model: string,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
batchIndex = 0,
|
batchIndex = 0,
|
||||||
|
dimensions?: number,
|
||||||
): Promise<EmbeddingItem[]> => {
|
): Promise<EmbeddingItem[]> => {
|
||||||
|
const requestBody: { input: string[]; model: string; dimensions?: number } = {
|
||||||
|
input: batch,
|
||||||
|
model,
|
||||||
|
};
|
||||||
|
if (dimensions !== undefined) {
|
||||||
|
requestBody.dimensions = dimensions;
|
||||||
|
}
|
||||||
|
|
||||||
let resp: Response;
|
let resp: Response;
|
||||||
try {
|
try {
|
||||||
resp = await resilientFetch(
|
resp = await resilientFetch(
|
||||||
|
|
@ -111,7 +129,7 @@ const httpEmbedBatch = async (
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${apiKey}`,
|
Authorization: `Bearer ${apiKey}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ input: batch, model }),
|
body: JSON.stringify(requestBody),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
breakerKey: HTTP_BREAKER_KEY,
|
breakerKey: HTTP_BREAKER_KEY,
|
||||||
|
|
@ -169,7 +187,14 @@ export const httpEmbed = async (texts: string[]): Promise<Float32Array[]> => {
|
||||||
for (let i = 0; i < texts.length; i += HTTP_BATCH_SIZE) {
|
for (let i = 0; i < texts.length; i += HTTP_BATCH_SIZE) {
|
||||||
const batch = texts.slice(i, i + HTTP_BATCH_SIZE);
|
const batch = texts.slice(i, i + HTTP_BATCH_SIZE);
|
||||||
const batchIndex = Math.floor(i / HTTP_BATCH_SIZE);
|
const batchIndex = Math.floor(i / HTTP_BATCH_SIZE);
|
||||||
const items = await httpEmbedBatch(url, batch, config.model, config.apiKey, batchIndex);
|
const items = await httpEmbedBatch(
|
||||||
|
url,
|
||||||
|
batch,
|
||||||
|
config.model,
|
||||||
|
config.apiKey,
|
||||||
|
batchIndex,
|
||||||
|
config.dimensions,
|
||||||
|
);
|
||||||
|
|
||||||
if (items.length !== batch.length) {
|
if (items.length !== batch.length) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
@ -212,7 +237,14 @@ export const httpEmbedQuery = async (text: string): Promise<number[]> => {
|
||||||
if (!config) throw new Error('HTTP embedding not configured');
|
if (!config) throw new Error('HTTP embedding not configured');
|
||||||
|
|
||||||
const url = `${config.baseUrl}/embeddings`;
|
const url = `${config.baseUrl}/embeddings`;
|
||||||
const items = await httpEmbedBatch(url, [text], config.model, config.apiKey);
|
const items = await httpEmbedBatch(
|
||||||
|
url,
|
||||||
|
[text],
|
||||||
|
config.model,
|
||||||
|
config.apiKey,
|
||||||
|
0,
|
||||||
|
config.dimensions,
|
||||||
|
);
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
throw new Error(`Embedding endpoint returned empty response (${safeUrl(url)})`);
|
throw new Error(`Embedding endpoint returned empty response (${safeUrl(url)})`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,73 @@ describe('HTTP embedding backend', () => {
|
||||||
expect(result.length).toBe(384);
|
expect(result.length).toBe(384);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('omits dimensions from request body when GITNEXUS_EMBEDDING_DIMS is unset', async () => {
|
||||||
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
||||||
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
||||||
|
// GITNEXUS_EMBEDDING_DIMS intentionally unset
|
||||||
|
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ data: [{ embedding: mockVec }] }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
||||||
|
await embedText('test text');
|
||||||
|
|
||||||
|
const body = JSON.parse((fetch as any).mock.calls[0][1].body);
|
||||||
|
// Backends that reject unknown fields must see the pre-existing
|
||||||
|
// request shape. The field must be absent, not `undefined`.
|
||||||
|
expect('dimensions' in body).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards GITNEXUS_EMBEDDING_DIMS as dimensions in request body', async () => {
|
||||||
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
||||||
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'text-embedding-3-large';
|
||||||
|
process.env.GITNEXUS_EMBEDDING_DIMS = '1024';
|
||||||
|
|
||||||
|
const vec1024 = Array.from({ length: 1024 }, (_, i) => i / 1024);
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ data: [{ embedding: vec1024 }] }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
||||||
|
const result = await embedText('test text');
|
||||||
|
|
||||||
|
const body = JSON.parse((fetch as any).mock.calls[0][1].body);
|
||||||
|
expect(body.dimensions).toBe(1024);
|
||||||
|
expect(body.model).toBe('text-embedding-3-large');
|
||||||
|
expect(result.length).toBe(1024);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards dimensions on the single-query path', async () => {
|
||||||
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
||||||
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'text-embedding-3-large';
|
||||||
|
process.env.GITNEXUS_EMBEDDING_DIMS = '512';
|
||||||
|
|
||||||
|
const vec512 = Array.from({ length: 512 }, (_, i) => i / 512);
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ data: [{ embedding: vec512 }] }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const mod = await import('../../src/mcp/core/embedder.js');
|
||||||
|
const result = await mod.embedQuery('query text');
|
||||||
|
|
||||||
|
const body = JSON.parse((fetch as any).mock.calls[0][1].body);
|
||||||
|
expect(body.dimensions).toBe(512);
|
||||||
|
expect(result.length).toBe(512);
|
||||||
|
});
|
||||||
|
|
||||||
it('retries on server error', async () => {
|
it('retries on server error', async () => {
|
||||||
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
||||||
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
||||||
|
|
@ -191,6 +258,51 @@ describe('HTTP embedding backend', () => {
|
||||||
expect(results).toHaveLength(70);
|
expect(results).toHaveLength(70);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('forwards dimensions in every batch when splitting large inputs', async () => {
|
||||||
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
||||||
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
||||||
|
process.env.GITNEXUS_EMBEDDING_DIMS = '512';
|
||||||
|
|
||||||
|
const vec512 = Array.from({ length: 512 }, (_, i) => i / 512);
|
||||||
|
const makeResp = (n: number) => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ data: Array.from({ length: n }, () => ({ embedding: vec512 })) }),
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Verify dimensions is sent in BOTH batch requests
|
||||||
|
const body0 = JSON.parse((fetch as any).mock.calls[0][1].body);
|
||||||
|
const body1 = JSON.parse((fetch as any).mock.calls[1][1].body);
|
||||||
|
expect(body0.dimensions).toBe(512);
|
||||||
|
expect(body1.dimensions).toBe(512);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-numeric GITNEXUS_EMBEDDING_DIMS values', async () => {
|
||||||
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
||||||
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
||||||
|
process.env.GITNEXUS_EMBEDDING_DIMS = '1024abc';
|
||||||
|
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ data: [{ embedding: mockVec }] }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
||||||
|
await expect(embedText('test')).rejects.toThrow('must be a positive integer');
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects initEmbedder when using HTTP backend', async () => {
|
it('rejects initEmbedder when using HTTP backend', async () => {
|
||||||
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
||||||
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue