feat(embeddings): add GITNEXUS_EMBEDDING_OMIT_DIMENSIONS for providers that reject the dimensions field

GITNEXUS_EMBEDDING_DIMS drives both the `dimensions` field sent in the request
and the length the returned vector is validated against. Some OpenAI-compatible
endpoints (e.g. Voyage) reject the `dimensions` field with a 400 while still
returning a fixed-size vector, so they cannot be configured today: setting DIMS
triggers the 400, and leaving it unset makes validation expect the 384 default
and reject the real vector.

Add an opt-in GITNEXUS_EMBEDDING_OMIT_DIMENSIONS flag that suppresses only the
sent field. DIMS is still honoured for validation. Default behaviour is unchanged.
This commit is contained in:
ChequePickerUpper 2026-06-04 17:04:05 -04:00
parent 3b195ec100
commit a84cbce159
3 changed files with 42 additions and 5 deletions

View file

@ -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
```

View file

@ -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<Float32Array[]> => {
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<number[]> => {
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)})`);

View file

@ -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';