mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
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:
parent
55b7a79beb
commit
622f98ade5
2 changed files with 149 additions and 5 deletions
|
|
@ -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)})`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue