fix(embeddings): send Voyage output dimensions

This commit is contained in:
Eva 2026-09-08 16:04:13 +07:00
parent a4e70ec3b4
commit c031c67f11
2 changed files with 95 additions and 7 deletions

View file

@ -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<EmbeddingItem[]> => {
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 —

View file

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