diff --git a/README.md b/README.md index d93d577..62e0442 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ The unreleased source candidate starts new indexes without a model download. Sem - Better React/Next.js detection and a fix for reranker passage scoring. - Transformers.js v4 for local models. -Known limits: [Intel Mac indexing crash (#68)](https://github.com/PatrickSys/codebase-context/issues/68), [custom OpenAI endpoints (#70)](https://github.com/PatrickSys/codebase-context/issues/70) and [Ollama (#71)](https://github.com/PatrickSys/codebase-context/issues/71) remain open. +Known limits: [Intel Mac indexing crash (#68)](https://github.com/PatrickSys/codebase-context/issues/68) and [custom OpenAI endpoints (#70)](https://github.com/PatrickSys/codebase-context/issues/70) remain open on published npm; master adds native Ollama and `OPENAI_BASE_URL` wiring. The existing [benchmark](https://github.com/PatrickSys/codebase-context/blob/master/docs/benchmark.md) reports 25.7% expected-file recovery versus jCodeMunch's 27.1% in a fixed retrieval comparison; it does not establish a winner or coding quality. @@ -132,7 +132,15 @@ More examples are available in the [CLI gallery](./docs/cli.md) and [walkthrough Code and indexes stay on the machine with the default local embedding provider. Docker, a GPU, and an API key are not required. -Cloud embeddings are optional. If you select a cloud provider, code chunks are sent to that provider to create the search index. The provider, model, project root, and local HTTP port can be changed through environment variables or the project config; see the [capabilities reference](./docs/capabilities.md). +For local embeddings through [Ollama](https://ollama.com) (avoids Transformers.js on affected Macs, see #68), enable semantic indexing and set: + +```bash +EMBEDDING_PROVIDER=ollama +EMBEDDING_MODEL=nomic-embed-text # optional; default when unset +OLLAMA_HOST=http://localhost:11434 # optional +``` + +Cloud embeddings are optional. If you select OpenAI (`EMBEDDING_PROVIDER=openai`), code chunks are sent to that provider to create the search index. Override the API base with `OPENAI_BASE_URL` (compatible with Ollama’s OpenAI layer, but native `ollama` provider is preferred). The provider, model, project root, and local HTTP port can be changed through environment variables or the project config; see the [capabilities reference](./docs/capabilities.md). This is the privacy boundary of Codebase Context itself. Your AI client may send search results or file contents to the model provider configured in that client. Codebase Context does not control that connection. If you commit and push `.codebase-context/memory.json`, the recorded project memory also travels with the repository. diff --git a/docs/capabilities.md b/docs/capabilities.md index 49fc4f9..3b9bebb 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -199,7 +199,7 @@ Ordered by execution: - **Chunk size**: 50 lines, 0 overlap - **Reranker trigger**: activates when top-3 results are within 0.08 score of each other - **Embedding mode**: new indexes are model-free. Existing indexes keep their recorded mode unless explicitly changed. `reindex --embeddings` opts into semantic indexing and requires the optional model/storage packages. -- **Semantic model when enabled**: `Xenova/bge-small-en-v1.5` (512 token context, fast, local-first) via `@huggingface/transformers`. Override: `EMBEDDING_MODEL=onnx-community/granite-embedding-small-english-r2-ONNX` for Granite (8192 ctx, slower). +- **Semantic model when enabled**: `Xenova/bge-small-en-v1.5` (512 token context, fast, local-first) via `@huggingface/transformers`. Override: `EMBEDDING_MODEL=onnx-community/granite-embedding-small-english-r2-ONNX` for Granite (8192 ctx, slower). **Ollama**: `EMBEDDING_PROVIDER=ollama`, optional `OLLAMA_HOST` (default `http://localhost:11434`), default model `nomic-embed-text` — embeddings stay on your machine via Ollama’s `/api/embed`. - **Vector DB**: LanceDB with cosine distance ## Decision Card (Edit Intent) @@ -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` (`OPENAI_BASE_URL` overrides the API host). Ollama is opt-in via `EMBEDDING_PROVIDER=ollama` — local HTTP only, no API key. - **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/core/indexer.ts b/src/core/indexer.ts index 6d9ed37..5400c52 100644 --- a/src/core/indexer.ts +++ b/src/core/indexer.ts @@ -266,7 +266,9 @@ export class CodebaseIndexer { const defaultModel = defaultEmbeddingProvider === 'openai' && !process.env.EMBEDDING_MODEL ? 'text-embedding-3-small' - : DEFAULT_MODEL; + : defaultEmbeddingProvider === 'ollama' && !process.env.EMBEDDING_MODEL + ? 'nomic-embed-text' + : DEFAULT_MODEL; const defaultConfig: CodebaseConfig = { analyzers: { diff --git a/src/embeddings/index.ts b/src/embeddings/index.ts index f8c1718..af76750 100644 --- a/src/embeddings/index.ts +++ b/src/embeddings/index.ts @@ -6,14 +6,17 @@ import { EmbeddingConfig, DEFAULT_EMBEDDING_CONFIG, DEFAULT_MODEL, - parseEmbeddingProviderName + DEFAULT_OLLAMA_MODEL, + parseEmbeddingProviderName, + resolveOllamaHost } from './types.js'; import { TransformersEmbeddingProvider, MODEL_CONFIGS } from './transformers.js'; +import { getOllamaModelDimensions } from './ollama.js'; import { assertOptionalDependency } from '../utils/optional-dependency.js'; export function assertEmbeddingRuntimeAvailable(config: Partial = {}): void { const provider = config.provider ?? DEFAULT_EMBEDDING_CONFIG.provider; - if (provider !== 'openai' && provider !== 'custom') { + if (provider !== 'openai' && provider !== 'custom' && provider !== 'ollama') { assertOptionalDependency('@huggingface/transformers', '^4.2.0'); } } @@ -30,6 +33,7 @@ export function getConfiguredDimensions(config: Partial = {}): config.provider ?? parseEmbeddingProviderName(process.env.EMBEDDING_PROVIDER) ?? 'transformers'; const model = config.model ?? process.env.EMBEDDING_MODEL ?? DEFAULT_MODEL; if (provider === 'openai') return model.includes('large') ? 3072 : 1536; // text-embedding-3-large: 3072, all others: 1536 + if (provider === 'ollama') return getOllamaModelDimensions(model); // Look up from the same MODEL_CONFIGS the provider uses — avoids stale hardcoded guesses return MODEL_CONFIGS[model]?.dimensions ?? 384; } @@ -62,11 +66,19 @@ export async function getEmbeddingProvider( } if (mergedConfig.provider === 'custom') { - throw new Error("Custom provider not implemented. Use 'openai' or 'transformers'."); + throw new Error("Custom provider not implemented. Use 'openai', 'ollama', or 'transformers'."); } if (mergedConfig.provider === 'ollama') { - console.warn('Ollama provider not yet implemented, falling back to Transformers.js'); + const { OllamaEmbeddingProvider } = await import('./ollama.js'); + const provider = new OllamaEmbeddingProvider( + mergedConfig.model || DEFAULT_OLLAMA_MODEL, + mergedConfig.apiEndpoint ?? resolveOllamaHost() + ); + await provider.initialize(); + cachedProvider = provider; + cachedProviderType = providerKey; + return provider; } const provider = new TransformersEmbeddingProvider(mergedConfig.model); diff --git a/src/embeddings/ollama.ts b/src/embeddings/ollama.ts new file mode 100644 index 0000000..1c5d4c3 --- /dev/null +++ b/src/embeddings/ollama.ts @@ -0,0 +1,155 @@ +import { EmbeddingProvider } from './types.js'; + +/** Common Ollama embedding models and their vector sizes (for LanceDB dimension checks). */ +export const OLLAMA_MODEL_DIMENSIONS: Record = { + 'nomic-embed-text': 768, + 'mxbai-embed-large': 1024, + 'all-minilm': 384, + 'snowflake-arctic-embed:137m': 768, + 'snowflake-arctic-embed:335m': 1024 +}; + +interface OllamaEmbedResponse { + embeddings: number[][]; +} + +interface OllamaShowResponse { + modelfile?: string; + parameters?: string; +} + +function normalizeOllamaHost(host: string): string { + return host.replace(/\/+$/, ''); +} + +export function getOllamaModelDimensions(modelName: string): number { + const direct = OLLAMA_MODEL_DIMENSIONS[modelName]; + if (direct !== undefined) { + return direct; + } + const baseName = modelName.split(':')[0] ?? modelName; + const fromBase = OLLAMA_MODEL_DIMENSIONS[baseName]; + if (fromBase !== undefined) { + return fromBase; + } + const fromEnv = process.env.OLLAMA_EMBEDDING_DIMENSIONS; + if (fromEnv) { + const parsed = parseInt(fromEnv, 10); + if (!Number.isNaN(parsed) && parsed > 0) { + return parsed; + } + } + return 768; +} + +/** + * Ollama embedding provider — HTTP client for a local Ollama server (`/api/embed`). + */ +export class OllamaEmbeddingProvider implements EmbeddingProvider { + readonly name = 'ollama'; + private resolvedDimensions: number; + + constructor( + readonly modelName: string, + private host: string = 'http://localhost:11434' + ) { + this.host = normalizeOllamaHost(host); + this.resolvedDimensions = getOllamaModelDimensions(modelName); + } + + get dimensions(): number { + return this.resolvedDimensions; + } + + async initialize(): Promise { + let response: Response; + try { + response = await fetch(`${this.host}/api/show`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: this.modelName }) + }); + } catch (error) { + throw new Error( + `Cannot reach Ollama at ${this.host}. Start the server (ollama serve) or set OLLAMA_HOST. ${String(error)}` + ); + } + + if (response.status === 404) { + throw new Error( + `Ollama model "${this.modelName}" is not available. Pull it first: ollama pull ${this.modelName}` + ); + } + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Ollama API Error ${response.status}: ${error}`); + } + + const show = (await response.json()) as OllamaShowResponse; + const fromModelfile = parseEmbeddingLengthFromShow(show); + if (fromModelfile !== undefined) { + this.resolvedDimensions = fromModelfile; + } + } + + isReady(): boolean { + return true; + } + + async embed(text: string): Promise { + const batch = await this.embedBatch([text]); + return batch[0]; + } + + async embedBatch(texts: string[]): Promise { + if (!texts.length) return []; + + try { + const response = await fetch(`${this.host}/api/embed`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: this.modelName, + input: texts.length === 1 ? texts[0] : texts + }) + }); + + if (!response.ok) { + const error = await response.text(); + if (response.status === 404 && error.includes('not found')) { + throw new Error( + `Ollama model "${this.modelName}" is not available. Pull it first: ollama pull ${this.modelName}` + ); + } + throw new Error(`Ollama API Error ${response.status}: ${error}`); + } + + const data = (await response.json()) as OllamaEmbedResponse; + if (!Array.isArray(data.embeddings) || data.embeddings.length !== texts.length) { + throw new Error( + `Ollama returned ${data.embeddings?.length ?? 0} embeddings for ${texts.length} inputs` + ); + } + + if (data.embeddings[0]?.length && data.embeddings[0].length !== this.resolvedDimensions) { + this.resolvedDimensions = data.embeddings[0].length; + } + + return data.embeddings; + } catch (error) { + console.error('Ollama Embedding Failed:', error); + throw error; + } + } +} + +function parseEmbeddingLengthFromShow(show: OllamaShowResponse): number | undefined { + const haystack = `${show.modelfile ?? ''}\n${show.parameters ?? ''}`; + const match = haystack.match(/embedding[_\s-]?length[\s=:]+(\d+)/i); + if (!match) { + return undefined; + } + const parsed = parseInt(match[1] ?? '', 10); + return Number.isNaN(parsed) || parsed <= 0 ? undefined : parsed; +} diff --git a/src/embeddings/types.ts b/src/embeddings/types.ts index 66e5a7a..a78433a 100644 --- a/src/embeddings/types.ts +++ b/src/embeddings/types.ts @@ -27,15 +27,72 @@ export function parseEmbeddingProviderName( return undefined; } +export const DEFAULT_OLLAMA_MODEL = 'nomic-embed-text'; +export const DEFAULT_TRANSFORMERS_MODEL = 'Xenova/bge-small-en-v1.5'; +export const DEFAULT_OPENAI_MODEL = 'text-embedding-3-small'; + // Default: bge-small (fast, ~2min indexing, consumer-hardware safe) // Opt-in: set EMBEDDING_MODEL=onnx-community/granite-embedding-small-english-r2-ONNX for // 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'; +export const DEFAULT_MODEL = process.env.EMBEDDING_MODEL || DEFAULT_TRANSFORMERS_MODEL; + +const DEFAULT_OPENAI_API_ENDPOINT = 'https://api.openai.com/v1'; +const DEFAULT_OLLAMA_HOST = 'http://localhost:11434'; + +function stripTrailingSlashes(value: string): string { + return value.replace(/\/+$/, ''); +} + +function resolveOpenAiApiEndpoint(): string { + const fromEnv = process.env.OPENAI_BASE_URL?.trim(); + if (!fromEnv) { + return DEFAULT_OPENAI_API_ENDPOINT; + } + return stripTrailingSlashes(fromEnv); +} + +export function resolveOllamaHost(): string { + const fromEnv = process.env.OLLAMA_HOST?.trim(); + if (!fromEnv) { + return DEFAULT_OLLAMA_HOST; + } + return stripTrailingSlashes(fromEnv); +} + +export function resolveEmbeddingModel( + provider: EmbeddingConfig['provider'], + envModel: string | undefined = process.env.EMBEDDING_MODEL +): string { + if (envModel) { + return envModel; + } + if (provider === 'openai') { + return DEFAULT_OPENAI_MODEL; + } + if (provider === 'ollama') { + return DEFAULT_OLLAMA_MODEL; + } + return DEFAULT_TRANSFORMERS_MODEL; +} + +function resolveDefaultApiEndpoint(provider: EmbeddingConfig['provider']): string | undefined { + if (provider === 'openai') { + return resolveOpenAiApiEndpoint(); + } + if (provider === 'ollama') { + return resolveOllamaHost(); + } + return undefined; +} + +const defaultProvider = + parseEmbeddingProviderName(process.env.EMBEDDING_PROVIDER) ?? 'transformers'; export const DEFAULT_EMBEDDING_CONFIG: EmbeddingConfig = { - provider: parseEmbeddingProviderName(process.env.EMBEDDING_PROVIDER) ?? 'transformers', - model: DEFAULT_MODEL, + provider: defaultProvider, + model: resolveEmbeddingModel(defaultProvider), batchSize: 32, maxRetries: 3, - apiKey: process.env.OPENAI_API_KEY + apiKey: process.env.OPENAI_API_KEY, + apiEndpoint: resolveDefaultApiEndpoint(defaultProvider) }; diff --git a/tests/embedding-config-env.test.ts b/tests/embedding-config-env.test.ts new file mode 100644 index 0000000..650e304 --- /dev/null +++ b/tests/embedding-config-env.test.ts @@ -0,0 +1,39 @@ +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('EMBEDDING_PROVIDER', 'openai'); + 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.stubEnv('EMBEDDING_PROVIDER', 'openai'); + vi.unstubAllEnvs(); + vi.stubEnv('EMBEDDING_PROVIDER', 'openai'); + const { DEFAULT_EMBEDDING_CONFIG } = await import('../src/embeddings/types.js'); + expect(DEFAULT_EMBEDDING_CONFIG.apiEndpoint).toBe('https://api.openai.com/v1'); + }); + + it('uses OLLAMA_HOST when provider is ollama', async () => { + vi.stubEnv('EMBEDDING_PROVIDER', 'ollama'); + vi.stubEnv('OLLAMA_HOST', 'http://127.0.0.1:11434/'); + const { DEFAULT_EMBEDDING_CONFIG } = await import('../src/embeddings/types.js'); + expect(DEFAULT_EMBEDDING_CONFIG.provider).toBe('ollama'); + expect(DEFAULT_EMBEDDING_CONFIG.apiEndpoint).toBe('http://127.0.0.1:11434'); + expect(DEFAULT_EMBEDDING_CONFIG.model).toBe('nomic-embed-text'); + }); + + it('resolveEmbeddingModel picks provider-specific defaults', async () => { + const { resolveEmbeddingModel } = await import('../src/embeddings/types.js'); + expect(resolveEmbeddingModel('ollama', undefined)).toBe('nomic-embed-text'); + expect(resolveEmbeddingModel('openai', undefined)).toBe('text-embedding-3-small'); + expect(resolveEmbeddingModel('transformers', undefined)).toBe('Xenova/bge-small-en-v1.5'); + }); +}); diff --git a/tests/embedding-mismatch.test.ts b/tests/embedding-mismatch.test.ts index 67e78b3..6b38ade 100644 --- a/tests/embedding-mismatch.test.ts +++ b/tests/embedding-mismatch.test.ts @@ -95,4 +95,8 @@ describe('getConfiguredDimensions', () => { it('returns 384 as fallback for unknown transformers model', () => { expect(getConfiguredDimensions({ provider: 'transformers', model: 'some/unknown-model' })).toBe(384); }); + + it('returns 768 for nomic-embed-text via ollama provider', () => { + expect(getConfiguredDimensions({ provider: 'ollama', model: 'nomic-embed-text' })).toBe(768); + }); }); diff --git a/tests/ollama-embeddings.test.ts b/tests/ollama-embeddings.test.ts new file mode 100644 index 0000000..c73c889 --- /dev/null +++ b/tests/ollama-embeddings.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { OllamaEmbeddingProvider } from '../src/embeddings/ollama.js'; + +function makeFetchResponse(body: unknown, ok = true, status = 200) { + return Promise.resolve({ + ok, + status, + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)) + } as Response); +} + +describe('OllamaEmbeddingProvider', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + + it('uses known dimensions for nomic-embed-text', () => { + const provider = new OllamaEmbeddingProvider('nomic-embed-text'); + expect(provider.dimensions).toBe(768); + }); + + it('initialize() throws when the model is missing on the server', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockReturnValue(makeFetchResponse({ error: 'not found' }, false, 404)) + ); + + const provider = new OllamaEmbeddingProvider('nomic-embed-text', 'http://127.0.0.1:11434'); + await expect(provider.initialize()).rejects.toThrow('not available'); + }); + + it('initialize() resolves when /api/show succeeds', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockReturnValue(makeFetchResponse({ modelfile: 'FROM nomic-embed-text' })) + ); + + const provider = new OllamaEmbeddingProvider('nomic-embed-text'); + await expect(provider.initialize()).resolves.toBeUndefined(); + }); + + it('embedBatch() posts to /api/embed with model and input', async () => { + const mockFetch = vi.fn().mockReturnValue( + makeFetchResponse({ + embeddings: [[0.1, 0.2, 0.3]] + }) + ); + vi.stubGlobal('fetch', mockFetch); + + const provider = new OllamaEmbeddingProvider('nomic-embed-text', 'http://localhost:11434'); + const result = await provider.embedBatch(['hello world']); + + expect(result).toEqual([[0.1, 0.2, 0.3]]); + expect(mockFetch).toHaveBeenCalledOnce(); + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('http://localhost:11434/api/embed'); + const body = JSON.parse(init.body as string); + expect(body.model).toBe('nomic-embed-text'); + expect(body.input).toBe('hello world'); + }); + + it('embedBatch() sends array input for multiple texts', async () => { + const mockFetch = vi.fn().mockReturnValue( + makeFetchResponse({ + embeddings: [ + [0.1, 0.2], + [0.3, 0.4] + ] + }) + ); + vi.stubGlobal('fetch', mockFetch); + + const provider = new OllamaEmbeddingProvider('nomic-embed-text'); + await provider.embedBatch(['a', 'b']); + + const body = JSON.parse((mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string); + expect(body.input).toEqual(['a', 'b']); + }); + + it('embedBatch() returns [] for empty input', async () => { + const provider = new OllamaEmbeddingProvider('nomic-embed-text'); + await expect(provider.embedBatch([])).resolves.toEqual([]); + }); + + it('embedBatch() throws on non-ok API response', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockReturnValue(makeFetchResponse({ error: 'server error' }, false, 500)) + ); + + const provider = new OllamaEmbeddingProvider('nomic-embed-text'); + await expect(provider.embedBatch(['test'])).rejects.toThrow('500'); + }); + + it('embed() delegates to embedBatch', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockReturnValue(makeFetchResponse({ embeddings: [[0.5, 0.6]] })) + ); + + const provider = new OllamaEmbeddingProvider('nomic-embed-text'); + await expect(provider.embed('hello')).resolves.toEqual([0.5, 0.6]); + }); +}); diff --git a/tests/openai-embeddings.test.ts b/tests/openai-embeddings.test.ts index 8763efd..4e722a2 100644 --- a/tests/openai-embeddings.test.ts +++ b/tests/openai-embeddings.test.ts @@ -46,6 +46,25 @@ describe('OpenAIEmbeddingProvider', () => { expect(result).toEqual([]); }); + it('embedBatch() uses 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({