diff --git a/gitnexus/src/core/embeddings/http-client.ts b/gitnexus/src/core/embeddings/http-client.ts index 7919b2376..f5f0443dd 100644 --- a/gitnexus/src/core/embeddings/http-client.ts +++ b/gitnexus/src/core/embeddings/http-client.ts @@ -364,11 +364,10 @@ const countMismatchMessage = ( * @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. Set + * @param dimensions - Optional output-vector size. Voyage uses its + * `output_dimension` field; other OpenAI-compatible endpoints use + * `dimensions`. Endpoints that do not recognise the field may ignore it or + * return 400. Set * `GITNEXUS_EMBEDDING_REQUEST_DIMS=omit` for strict backends while keeping * `GITNEXUS_EMBEDDING_DIMS` set to the returned vector size. */ @@ -385,12 +384,27 @@ const httpEmbedBatch = async ( minIntervalMs = 0, timeoutMs = DEFAULT_HTTP_TIMEOUT_MS, ): Promise => { - const requestBody: { input: string[]; model: string; dimensions?: number } = { + const requestBody: { + input: string[]; + model: string; + dimensions?: number; + output_dimension?: number; + } = { input: batch, model, }; if (dimensions !== undefined) { - requestBody.dimensions = dimensions; + let hostname = ''; + try { + hostname = new URL(url).hostname.toLowerCase().replace(/\.$/, ''); + } catch { + // Fetch below owns malformed-URL reporting. + } + if (hostname === 'voyageai.com' || hostname.endsWith('.voyageai.com')) { + requestBody.output_dimension = dimensions; + } else { + requestBody.dimensions = dimensions; + } } // Built on demand, not up front. Both describe faults, so in a healthy run — diff --git a/gitnexus/test/unit/http-embedder.test.ts b/gitnexus/test/unit/http-embedder.test.ts index 397481569..9cded176d 100644 --- a/gitnexus/test/unit/http-embedder.test.ts +++ b/gitnexus/test/unit/http-embedder.test.ts @@ -168,6 +168,56 @@ describe('HTTP embedding backend', () => { expect(result.length).toBe(1024); }); + it('uses output_dimension for Voyage document batches', async () => { + process.env.GITNEXUS_EMBEDDING_URL = 'https://api.voyageai.com/v1'; + process.env.GITNEXUS_EMBEDDING_MODEL = 'voyage-code-3'; + process.env.GITNEXUS_EMBEDDING_API_KEY = 'test-key'; + process.env.GITNEXUS_EMBEDDING_DIMS = '2048'; + + const vec2048 = Array.from({ length: 2048 }, (_, i) => i / 2048); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: [{ embedding: vec2048 }] }), + }), + ); + + 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).toMatchObject({ + input: ['test text'], + model: 'voyage-code-3', + output_dimension: 2048, + }); + expect(body.dimensions).toBeUndefined(); + expect(result.length).toBe(2048); + }); + + it('does not treat a voyageai.com lookalike host as Voyage', async () => { + process.env.GITNEXUS_EMBEDDING_URL = 'https://voyageai.com.example/v1'; + process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model'; + 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 { embedText } = await import('../../src/core/embeddings/embedder.js'); + await embedText('test text'); + + const body = JSON.parse((fetch as any).mock.calls[0][1].body); + expect(body.dimensions).toBe(512); + expect(body.output_dimension).toBeUndefined(); + }); + it('can validate custom dims without forwarding dimensions to strict backends', async () => { process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1'; process.env.GITNEXUS_EMBEDDING_MODEL = 'bge-m3'; @@ -214,6 +264,30 @@ describe('HTTP embedding backend', () => { expect(result.length).toBe(512); }); + it('uses output_dimension for Voyage on the single-query path', async () => { + process.env.GITNEXUS_EMBEDDING_URL = 'https://voyageai.com/v1'; + process.env.GITNEXUS_EMBEDDING_MODEL = 'voyage-code-3'; + process.env.GITNEXUS_EMBEDDING_API_KEY = 'test-key'; + process.env.GITNEXUS_EMBEDDING_DIMS = '2048'; + + const vec2048 = Array.from({ length: 2048 }, (_, i) => i / 2048); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: [{ embedding: vec2048 }] }), + }), + ); + + 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.output_dimension).toBe(2048); + expect(body.dimensions).toBeUndefined(); + expect(result.length).toBe(2048); + }); + it('can omit dimensions on the single-query path while validating custom dims', async () => { process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1'; process.env.GITNEXUS_EMBEDDING_MODEL = 'bge-m3';