diff --git a/gitnexus/src/core/embeddings/http-client.ts b/gitnexus/src/core/embeddings/http-client.ts index e3fb06045..e8e9073ff 100644 --- a/gitnexus/src/core/embeddings/http-client.ts +++ b/gitnexus/src/core/embeddings/http-client.ts @@ -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; @@ -91,7 +94,13 @@ 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, @@ -99,7 +108,16 @@ const httpEmbedBatch = async ( model: string, apiKey: string, batchIndex = 0, + dimensions?: number, ): Promise => { + const requestBody: { input: string[]; model: string; dimensions?: number } = { + input: batch, + model, + }; + if (dimensions !== undefined) { + requestBody.dimensions = dimensions; + } + let resp: Response; try { resp = await resilientFetch( @@ -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, @@ -169,7 +187,14 @@ export const httpEmbed = async (texts: string[]): Promise => { 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( @@ -212,7 +237,14 @@ export const httpEmbedQuery = async (text: string): Promise => { 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)})`); } diff --git a/gitnexus/test/unit/http-embedder.test.ts b/gitnexus/test/unit/http-embedder.test.ts index 0dea77758..4ac683095 100644 --- a/gitnexus/test/unit/http-embedder.test.ts +++ b/gitnexus/test/unit/http-embedder.test.ts @@ -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'; @@ -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';