diff --git a/gitnexus/README.md b/gitnexus/README.md index 7544508ba..09c3ba464 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -204,6 +204,7 @@ Set these env vars to use a remote OpenAI-compatible `/v1/embeddings` endpoint i export GITNEXUS_EMBEDDING_URL=http://your-server:8080/v1 export GITNEXUS_EMBEDDING_MODEL=BAAI/bge-large-en-v1.5 export GITNEXUS_EMBEDDING_DIMS=1024 # optional, default 384 +export GITNEXUS_EMBEDDING_OMIT_DIMENSIONS=1 # optional; omit the `dimensions` field for providers that reject it (e.g. Voyage), still validating against GITNEXUS_EMBEDDING_DIMS export GITNEXUS_EMBEDDING_API_KEY=your-key # optional, default: "unused" gitnexus analyze . --embeddings ``` diff --git a/gitnexus/src/core/embeddings/http-client.ts b/gitnexus/src/core/embeddings/http-client.ts index e8e9073ff..10297178e 100644 --- a/gitnexus/src/core/embeddings/http-client.ts +++ b/gitnexus/src/core/embeddings/http-client.ts @@ -25,6 +25,7 @@ interface HttpConfig { model: string; apiKey: string; dimensions?: number; + omitDimensionsField?: boolean; } /** @@ -50,11 +51,19 @@ const readConfig = (): HttpConfig | null => { dimensions = parsed; } + // Some OpenAI-compatible providers (e.g. Voyage) reject the `dimensions` request field with a 400 + // yet still return a fixed-size vector. When this is set the field is not sent, while + // GITNEXUS_EMBEDDING_DIMS is still honoured for validating the returned vector length. + const omitDimensionsField = + process.env.GITNEXUS_EMBEDDING_OMIT_DIMENSIONS === '1' || + process.env.GITNEXUS_EMBEDDING_OMIT_DIMENSIONS === 'true'; + return { baseUrl: baseUrl.replace(/\/+$/, ''), model, apiKey: process.env.GITNEXUS_EMBEDDING_API_KEY ?? 'unused', dimensions, + omitDimensionsField, }; }; @@ -96,11 +105,14 @@ interface EmbeddingItem { * @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 + * Matryoshka truncation (OpenAI text-embedding-3-*, Cohere embed-v3) + * 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. + * unknown fields, or set `GITNEXUS_EMBEDDING_OMIT_DIMENSIONS=1` to omit + * the field while still validating the response against + * `GITNEXUS_EMBEDDING_DIMS` (e.g. Voyage, which rejects the field but + * always returns a fixed-size vector). */ const httpEmbedBatch = async ( url: string, @@ -193,7 +205,7 @@ export const httpEmbed = async (texts: string[]): Promise => { config.model, config.apiKey, batchIndex, - config.dimensions, + config.omitDimensionsField ? undefined : config.dimensions, ); if (items.length !== batch.length) { @@ -243,7 +255,7 @@ export const httpEmbedQuery = async (text: string): Promise => { config.model, config.apiKey, 0, - config.dimensions, + config.omitDimensionsField ? undefined : 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 c19bb4825..7f3ae79c6 100644 --- a/gitnexus/test/unit/http-embedder.test.ts +++ b/gitnexus/test/unit/http-embedder.test.ts @@ -6,6 +6,7 @@ const ENV_KEYS = [ 'GITNEXUS_EMBEDDING_MODEL', 'GITNEXUS_EMBEDDING_API_KEY', 'GITNEXUS_EMBEDDING_DIMS', + 'GITNEXUS_EMBEDDING_OMIT_DIMENSIONS', ] as const; /** 384d mock vector matching the default schema dimensions. */ @@ -163,6 +164,29 @@ describe('HTTP embedding backend', () => { expect(result.length).toBe(512); }); + it('omits the dimensions field on the single-query path when GITNEXUS_EMBEDDING_OMIT_DIMENSIONS is set', async () => { + process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1'; + process.env.GITNEXUS_EMBEDDING_MODEL = 'voyage-code-3'; + process.env.GITNEXUS_EMBEDDING_DIMS = '1024'; + process.env.GITNEXUS_EMBEDDING_OMIT_DIMENSIONS = '1'; + + const vec1024 = Array.from({ length: 1024 }, (_, i) => i / 1024); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: [{ embedding: vec1024 }] }), + }), + ); + + 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('dimensions' in body).toBe(false); + expect(result.length).toBe(1024); + }); + it('retries on server error', async () => { process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1'; process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';