Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions gitnexus/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,13 +284,23 @@ 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_REQUEST_DIMS=omit # optional: omit "dimensions", or an integer to override it
export GITNEXUS_EMBEDDING_API_KEY=your-key # optional, default: "unused"
export GITNEXUS_EMBEDDING_MAX_ATTEMPTS=3 # optional, total attempts (1-20)
export GITNEXUS_EMBEDDING_RETRY_CAP_MS=5000 # optional, maximum retry delay
export GITNEXUS_EMBEDDING_MIN_INTERVAL_MS=0 # optional, minimum request spacing
gitnexus analyze . --embeddings
```

`GITNEXUS_EMBEDDING_REQUEST_DIMS` controls only the `dimensions` field sent in
the request body, independently of `GITNEXUS_EMBEDDING_DIMS` (which still
validates the returned vector's length):

- `omit` (or `none`, `off`, `false`, `0`) — do not send `dimensions` at all, for
strict backends that return the right vector size but reject the field.
- a positive integer — send that value instead of `GITNEXUS_EMBEDDING_DIMS`.
- unset — send `GITNEXUS_EMBEDDING_DIMS` (the previous behavior).

Works with Infinity, vLLM, TEI, llama.cpp, Ollama, LM Studio, or OpenAI. Retry and pacing settings are provider-neutral; provider-specific limits should be supplied through configuration. When unset, local embeddings are used unchanged.

## Multi-Repo Support
Expand Down
53 changes: 39 additions & 14 deletions gitnexus/src/core/embeddings/http-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ interface HttpConfig {
maxAttempts: number;
retryCapMs: number;
minIntervalMs: number;
requestDimensions?: number;
}

export interface EmbeddingRequestOptions {
Expand Down Expand Up @@ -106,20 +107,26 @@ const paceHttpRequest = async (minIntervalMs: number, signal?: AbortSignal): Pro
};

/**
* Stable lead of the {@link readConfig} malformed-`GITNEXUS_EMBEDDING_DIMS`
* error. `readConfig` throws a plain `Error` (not an {@link HttpEmbeddingError})
* because this is a *config* mistake, not an endpoint failure — so the CLI
* recognizes it by this lead ({@link isHttpEmbeddingDimsError}) and prints a
* clean config message instead of a raw stack dump. See #2385.
* Stable lead of a {@link readConfig} malformed dims-env error. `readConfig`
* throws a plain `Error` (not an {@link HttpEmbeddingError}) for a malformed
* `GITNEXUS_EMBEDDING_DIMS` or `GITNEXUS_EMBEDDING_REQUEST_DIMS` because it's a
* *config* mistake, not an endpoint failure — so the CLI recognizes it by this
* lead ({@link isHttpEmbeddingDimsError}) and prints a clean config message
* instead of a raw stack dump. Each var names itself so the message points the
* operator at the variable they actually set, not a sibling. See #2385.
*/
const EMBEDDING_DIMS_ENV_ERROR_LEAD = 'GITNEXUS_EMBEDDING_DIMS must be a positive integer';
const dimsEnvErrorLead = (name: string): string => `${name} must be a positive integer`;
const EMBEDDING_DIMS_ENV_ERROR_LEAD = dimsEnvErrorLead('GITNEXUS_EMBEDDING_DIMS');
const EMBEDDING_REQUEST_DIMS_ENV_ERROR_LEAD = dimsEnvErrorLead('GITNEXUS_EMBEDDING_REQUEST_DIMS');

/**
* @internal Exported for the CLI analyze error handler. True when `message` is
* the {@link readConfig} malformed-DIMS config error (a plain `Error`).
* @internal Exported for the CLI analyze error handler. True when `message` is a
* {@link readConfig} malformed dims-env config error (a plain `Error`) — for
* either `GITNEXUS_EMBEDDING_DIMS` or `GITNEXUS_EMBEDDING_REQUEST_DIMS`.
*/
export const isHttpEmbeddingDimsError = (message: string): boolean =>
message.includes(EMBEDDING_DIMS_ENV_ERROR_LEAD);
message.includes(EMBEDDING_DIMS_ENV_ERROR_LEAD) ||
message.includes(EMBEDDING_REQUEST_DIMS_ENV_ERROR_LEAD);

/**
* Build config from the current process.env snapshot.
Expand Down Expand Up @@ -147,6 +154,23 @@ const readConfig = (): HttpConfig | null => {
dimensions = parsed;
}

const rawRequestDims = process.env.GITNEXUS_EMBEDDING_REQUEST_DIMS?.trim();
let requestDimensions = dimensions;
if (rawRequestDims) {
if (/^(omit|none|off|false|0)$/i.test(rawRequestDims)) {
requestDimensions = undefined;
} else {
if (!/^\d+$/.test(rawRequestDims)) {
throw new Error(`${EMBEDDING_REQUEST_DIMS_ENV_ERROR_LEAD}, got "${rawRequestDims}"`);
}
const parsed = parseInt(rawRequestDims, 10);
if (parsed <= 0) {
throw new Error(`${EMBEDDING_REQUEST_DIMS_ENV_ERROR_LEAD}, got "${rawRequestDims}"`);
}
requestDimensions = parsed;
}
}

return {
baseUrl: baseUrl.replace(/\/+$/, ''),
model,
Expand All @@ -163,6 +187,7 @@ const readConfig = (): HttpConfig | null => {
300_000,
),
minIntervalMs: parseNonNegativeIntegerEnv('GITNEXUS_EMBEDDING_MIN_INTERVAL_MS', 0, 300_000),
requestDimensions,
};
};

Expand Down Expand Up @@ -283,9 +308,9 @@ const isEmbeddingItem = (item: unknown): item is EmbeddingItem =>
* 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.
* 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.
*/
const httpEmbedBatch = async (
url: string,
Expand Down Expand Up @@ -434,7 +459,7 @@ export const httpEmbed = async (
config.model,
config.apiKey,
batchIndex,
config.dimensions,
config.requestDimensions,
requestOptions,
config.maxAttempts,
config.retryCapMs,
Expand Down Expand Up @@ -491,7 +516,7 @@ export const httpEmbedQuery = async (
config.model,
config.apiKey,
0,
config.dimensions,
config.requestDimensions,
requestOptions,
config.maxAttempts,
config.retryCapMs,
Expand Down
112 changes: 112 additions & 0 deletions gitnexus/test/unit/http-embedder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const ENV_KEYS = [
'GITNEXUS_EMBEDDING_MAX_ATTEMPTS',
'GITNEXUS_EMBEDDING_RETRY_CAP_MS',
'GITNEXUS_EMBEDDING_MIN_INTERVAL_MS',
'GITNEXUS_EMBEDDING_REQUEST_DIMS',
] as const;

/** 384d mock vector matching the default schema dimensions. */
Expand Down Expand Up @@ -166,6 +167,30 @@ describe('HTTP embedding backend', () => {
expect(result.length).toBe(1024);
});

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';
process.env.GITNEXUS_EMBEDDING_DIMS = '1024';
process.env.GITNEXUS_EMBEDDING_REQUEST_DIMS = 'omit';

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('dimensions' in body).toBe(false);
expect(body.model).toBe('bge-m3');
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';
Expand All @@ -188,6 +213,93 @@ describe('HTTP embedding backend', () => {
expect(result.length).toBe(512);
});

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';
process.env.GITNEXUS_EMBEDDING_DIMS = '1024';
process.env.GITNEXUS_EMBEDDING_REQUEST_DIMS = 'omit';

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.each(['none', 'off', 'false', '0'])(
'treats GITNEXUS_EMBEDDING_REQUEST_DIMS=%s as omit and drops the request dimensions field',
async (alias) => {
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
process.env.GITNEXUS_EMBEDDING_MODEL = 'bge-m3';
process.env.GITNEXUS_EMBEDDING_DIMS = '1024';
process.env.GITNEXUS_EMBEDDING_REQUEST_DIMS = alias;

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('dimensions' in body).toBe(false);
expect(result.length).toBe(1024);
},
);

it('sends REQUEST_DIMS as the request dimensions while DIMS validates the response', 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';
process.env.GITNEXUS_EMBEDDING_REQUEST_DIMS = '512';

// Response keeps the DIMS-validated length; only the outgoing request differs.
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(512);
expect(result.length).toBe(1024);
});

it('rejects a malformed GITNEXUS_EMBEDDING_REQUEST_DIMS with an error naming that var', async () => {
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
process.env.GITNEXUS_EMBEDDING_REQUEST_DIMS = 'garbage';

const { embedText } = await import('../../src/core/embeddings/embedder.js');
const { isHttpEmbeddingDimsError } = await import('../../src/core/embeddings/http-client.js');
const err = await embedText('test').catch((e: unknown) => e);
// Recognizable as a config error so the CLI prints a clean message...
expect(isHttpEmbeddingDimsError(String(err))).toBe(true);
// ...and it points the operator at the var they set, not GITNEXUS_EMBEDDING_DIMS.
expect(String(err)).toContain('GITNEXUS_EMBEDDING_REQUEST_DIMS must be a positive integer');
});

it('retries on server error', async () => {
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
Expand Down
Loading