feat(embeddings): forward dimensions param in HTTP embedding requests (#1498)

* feat(embeddings): forward GITNEXUS_EMBEDDING_DIMS as dimensions in HTTP request body

When GITNEXUS_EMBEDDING_DIMS is set, include it as the `dimensions` field
in the /v1/embeddings request body. This enables Matryoshka-capable models
(OpenAI text-embedding-3-*, Cohere embed-v3, Voyage) to return truncated
vectors at the requested size.

When the env var is unset, the request body remains `{ input, model }` —
no breaking change for backends that reject unknown fields.

Adds 4 unit tests covering both paths (with/without dimensions) on both
the batch embed and single-query embed code paths.

* fix(embeddings): address review findings — strict parseInt, multi-batch test, comment wording

1. Strict parseInt validation: reject non-numeric strings like '1024abc'
   by checking /^\d+$/ before parseInt (Finding 1).
2. Add multi-batch test asserting dimensions is forwarded in every fetch
   call when inputs exceed batch size (Finding 2).
3. Soften JSDoc comment: backends may ignore or reject the dimensions
   field rather than universally ignoring it (Finding 3).
4. Add test for invalid GITNEXUS_EMBEDDING_DIMS values.

---------

Co-authored-by: henry <zhangwei2017@unipus.cn>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
This commit is contained in:
henry201605 2026-05-11 20:56:10 +08:00 committed by GitHub
parent 55b7a79beb
commit 622f98ade5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 149 additions and 5 deletions

View file

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

View file

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