Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 37 additions & 5 deletions gitnexus/src/core/embeddings/http-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,11 @@ const readConfig = (): HttpConfig | null => {
const rawDims = process.env.GITNEXUS_EMBEDDING_DIMS;
let dimensions: number | 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);
if (Number.isNaN(parsed) || parsed <= 0) {
if (parsed <= 0) {
throw new Error(`GITNEXUS_EMBEDDING_DIMS must be a positive integer, got "${rawDims}"`);
}
dimensions = parsed;
Expand Down Expand Up @@ -91,15 +94,30 @@ interface EmbeddingItem {
* @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)
* @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,
): Promise<EmbeddingItem[]> => {
const requestBody: { input: string[]; model: string; dimensions?: number } = {
input: batch,
model,
};
if (dimensions !== undefined) {
requestBody.dimensions = dimensions;
}

let resp: Response;
try {
resp = await resilientFetch(
Expand All @@ -111,7 +129,7 @@ const httpEmbedBatch = async (
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({ input: batch, model }),
body: JSON.stringify(requestBody),
},
{
breakerKey: HTTP_BREAKER_KEY,
Expand Down Expand Up @@ -169,7 +187,14 @@ export const httpEmbed = async (texts: string[]): Promise<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);
const items = await httpEmbedBatch(
url,
batch,
config.model,
config.apiKey,
batchIndex,
config.dimensions,
);

if (items.length !== batch.length) {
throw new Error(
Expand Down Expand Up @@ -212,7 +237,14 @@ export const httpEmbedQuery = async (text: string): Promise<number[]> => {
if (!config) throw new Error('HTTP embedding not configured');

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) {
throw new Error(`Embedding endpoint returned empty response (${safeUrl(url)})`);
}
Expand Down
112 changes: 112 additions & 0 deletions gitnexus/test/unit/http-embedder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,73 @@ describe('HTTP embedding backend', () => {
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 () => {
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
Expand Down Expand Up @@ -191,6 +258,51 @@ describe('HTTP embedding backend', () => {
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 () => {
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
Expand Down
Loading