diff --git a/docs/capabilities.md b/docs/capabilities.md index 49fc4f9..8032b7b 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -318,5 +318,5 @@ Reproducible evaluation is shipped as a CLI entrypoint backed by shared scoring - **Symbol refs are not a call-graph.** `get_symbol_references` counts identifier-node occurrences in the AST (comments/strings excluded via Tree-sitter). It does not distinguish call sites from type annotations, variable assignments, or imports. Full call-site-specific analysis (`call_expression` nodes only) is a roadmap item. - **Impact is 2-hop max.** `computeImpactCandidates` walks direct importers then their importers. Full BFS reachability is on the roadmap. - **Planned for unpublished 2.4.0:** NestJS analyzer support, React and Next.js detection improvements, and reranker fixes. Dedicated Angular, React, and Next.js analyzers already ship in published 2.2.0. Other languages use the Generic analyzer for indexing and retrieval; available symbol extraction depends on parser support. File import resolution and multi-hop navigation currently support TypeScript and JavaScript. Python and Go import navigation are not supported. -- **The unreleased candidate defaults new indexes to model-free mode. Published 2.2.0 enables embeddings by default.** When semantic mode is enabled, its default model is `bge-small-en-v1.5` (512-token context). Granite (8192 context) is opt-in via `EMBEDDING_MODEL`. OpenAI is opt-in via `EMBEDDING_PROVIDER=openai` — sends code externally. +- **The unreleased candidate defaults new indexes to model-free mode. Published 2.2.0 enables embeddings by default.** When semantic mode is enabled, its default model is `bge-small-en-v1.5` (512-token context). Granite (8192 context) is opt-in via `EMBEDDING_MODEL`. OpenAI is opt-in via `EMBEDDING_PROVIDER=openai` with `OPENAI_API_KEY`; use `OPENAI_BASE_URL` for OpenAI-compatible endpoints (Ollama, LiteLLM, proxies) instead of `https://api.openai.com/v1`. - **Patterns are file-level frequency counts.** Not semantic clustering. Rising/Declining trend is derived from git commit recency for files using each pattern, not from usage semantics. diff --git a/src/embeddings/index.ts b/src/embeddings/index.ts index f8c1718..d419976 100644 --- a/src/embeddings/index.ts +++ b/src/embeddings/index.ts @@ -42,7 +42,12 @@ export async function getEmbeddingProvider( ): Promise { assertEmbeddingRuntimeAvailable(config); const mergedConfig = { ...DEFAULT_EMBEDDING_CONFIG, ...config }; - const providerKey = `${mergedConfig.provider}:${mergedConfig.model}`; + const providerKey = [ + mergedConfig.provider, + mergedConfig.model, + mergedConfig.apiEndpoint ?? '', + mergedConfig.apiKey ?? '' + ].join(':'); if (cachedProvider && cachedProviderType === providerKey) { return cachedProvider; diff --git a/src/embeddings/types.ts b/src/embeddings/types.ts index 66e5a7a..2fbe7aa 100644 --- a/src/embeddings/types.ts +++ b/src/embeddings/types.ts @@ -32,10 +32,21 @@ export function parseEmbeddingProviderName( // better conceptual search at the cost of 5-10x slower indexing and higher RAM usage export const DEFAULT_MODEL = process.env.EMBEDDING_MODEL || 'Xenova/bge-small-en-v1.5'; +const DEFAULT_OPENAI_API_ENDPOINT = 'https://api.openai.com/v1'; + +function resolveOpenAiApiEndpoint(): string { + const fromEnv = process.env.OPENAI_BASE_URL?.trim(); + if (!fromEnv) { + return DEFAULT_OPENAI_API_ENDPOINT; + } + return fromEnv.replace(/\/+$/, ''); +} + export const DEFAULT_EMBEDDING_CONFIG: EmbeddingConfig = { provider: parseEmbeddingProviderName(process.env.EMBEDDING_PROVIDER) ?? 'transformers', model: DEFAULT_MODEL, batchSize: 32, maxRetries: 3, - apiKey: process.env.OPENAI_API_KEY + apiKey: process.env.OPENAI_API_KEY, + apiEndpoint: resolveOpenAiApiEndpoint() }; diff --git a/tests/cli-entrypoint-runtime.test.ts b/tests/cli-entrypoint-runtime.test.ts index 3413363..314ad8e 100644 --- a/tests/cli-entrypoint-runtime.test.ts +++ b/tests/cli-entrypoint-runtime.test.ts @@ -142,6 +142,36 @@ describe('CLI entrypoint runtime', () => { await rm(projectRoot, { recursive: true, force: true }); } }, 70_000); + it('dispatches reindex when invoked through an npm-style symlink entrypoint', async () => { + const projectRoot = await mkdtemp(join(tmpdir(), 'cbc-cli-reindex-entrypoint-')); + const tempDirectory = join(tmpdir(), `cbc-cli-bin-${Date.now()}`); + const linkedEntrypoint = join(tempDirectory, 'codebase-context'); + + await writeFile(join(projectRoot, 'example.ts'), 'export const value = 1;\n'); + await mkdir(tempDirectory, { recursive: true }); + symlinkSync(entrypoint, linkedEntrypoint, 'file'); + + try { + const result = spawnSync( + process.execPath, + ['--import', import.meta.resolve('tsx'), linkedEntrypoint, 'reindex', '--no-embeddings'], + { + cwd: projectRoot, + env: { ...process.env }, + encoding: 'utf8', + timeout: 120_000 + } + ); + + expect(result.status, result.stderr).toBe(0); + expect(result.stderr).toContain('Complete:'); + expect(result.stderr).not.toContain('@modelcontextprotocol/sdk/server/stdio.js'); + } finally { + rmSync(tempDirectory, { recursive: true, force: true }); + await rm(projectRoot, { recursive: true, force: true }); + } + }, 120_000); + it('dispatches CLI help when invoked through a symlink entrypoint', (context) => { const tempDirectory = mkdtempSync(join(tmpdir(), 'cbc-cli-entrypoint-')); const linkedEntrypoint = join(tempDirectory, 'codebase-context.ts'); diff --git a/tests/embedding-config-env.test.ts b/tests/embedding-config-env.test.ts new file mode 100644 index 0000000..b71e669 --- /dev/null +++ b/tests/embedding-config-env.test.ts @@ -0,0 +1,20 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +describe('DEFAULT_EMBEDDING_CONFIG environment', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it('uses OPENAI_BASE_URL when set', async () => { + vi.stubEnv('OPENAI_BASE_URL', 'http://127.0.0.1:11434/v1/'); + const { DEFAULT_EMBEDDING_CONFIG } = await import('../src/embeddings/types.js'); + expect(DEFAULT_EMBEDDING_CONFIG.apiEndpoint).toBe('http://127.0.0.1:11434/v1'); + }); + + it('defaults to the official OpenAI endpoint when OPENAI_BASE_URL is unset', async () => { + vi.unstubAllEnvs(); + const { DEFAULT_EMBEDDING_CONFIG } = await import('../src/embeddings/types.js'); + expect(DEFAULT_EMBEDDING_CONFIG.apiEndpoint).toBe('https://api.openai.com/v1'); + }); +}); diff --git a/tests/embedding-provider-cache.test.ts b/tests/embedding-provider-cache.test.ts new file mode 100644 index 0000000..f4e0228 --- /dev/null +++ b/tests/embedding-provider-cache.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +describe('getEmbeddingProvider cache key', () => { + afterEach(() => { + vi.resetModules(); + vi.doUnmock('../src/embeddings/openai.js'); + }); + + it('rebuilds the OpenAI provider when apiEndpoint changes', async () => { + const endpoints: string[] = []; + vi.doMock('../src/embeddings/openai.js', () => ({ + OpenAIEmbeddingProvider: class { + constructor(_model: string, _apiKey?: string, apiEndpoint?: string) { + endpoints.push(apiEndpoint ?? ''); + } + + async initialize(): Promise { + return undefined; + } + } + })); + + const { getEmbeddingProvider } = await import('../src/embeddings/index.js'); + await getEmbeddingProvider({ + provider: 'openai', + model: 'text-embedding-3-small', + apiKey: 'sk-test', + apiEndpoint: 'http://127.0.0.1:11434/v1' + }); + await getEmbeddingProvider({ + provider: 'openai', + model: 'text-embedding-3-small', + apiKey: 'sk-test', + apiEndpoint: 'http://127.0.0.1:8080/v1' + }); + + expect(endpoints).toEqual(['http://127.0.0.1:11434/v1', 'http://127.0.0.1:8080/v1']); + }); +}); diff --git a/tests/openai-embeddings.test.ts b/tests/openai-embeddings.test.ts index 8763efd..1f22261 100644 --- a/tests/openai-embeddings.test.ts +++ b/tests/openai-embeddings.test.ts @@ -46,6 +46,25 @@ describe('OpenAIEmbeddingProvider', () => { expect(result).toEqual([]); }); + it('embedBatch() posts to a custom apiEndpoint base URL', async () => { + const mockFetch = vi.fn().mockReturnValue( + makeFetchResponse({ + data: [{ embedding: [0.1] }] + }) + ); + vi.stubGlobal('fetch', mockFetch); + + const provider = new OpenAIEmbeddingProvider( + 'text-embedding-3-small', + 'sk-test', + 'http://127.0.0.1:11434/v1' + ); + await provider.embedBatch(['hello']); + + const [url] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('http://127.0.0.1:11434/v1/embeddings'); + }); + it('embedBatch() sends correct Authorization header, model, and encoding_format', async () => { const mockFetch = vi.fn().mockReturnValue( makeFetchResponse({