From 1b1b84a508a94766ca96ec486fa88c107ead6f95 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Wed, 5 Aug 2026 22:40:46 -0300 Subject: [PATCH] feat(ollama): add Ollama Local embedding support (#9225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated in local merge-train (devbox-vm-06-dev002) @ combined-tip (FAST gates — only pre-existing audit.test.ts flake). --- .../features/9225-ollama-local-embeddings.md | 1 + open-sse/config/embeddingRegistry.ts | 15 ++ open-sse/handlers/embeddings.ts | 24 +- src/lib/embeddings/service.ts | 37 ++- .../unit/ollama-local-embedding-2824.test.ts | 212 ++++++++++++++++++ 5 files changed, 280 insertions(+), 9 deletions(-) create mode 100644 changelog.d/features/9225-ollama-local-embeddings.md create mode 100644 tests/unit/ollama-local-embedding-2824.test.ts diff --git a/changelog.d/features/9225-ollama-local-embeddings.md b/changelog.d/features/9225-ollama-local-embeddings.md new file mode 100644 index 0000000000..02b606281b --- /dev/null +++ b/changelog.d/features/9225-ollama-local-embeddings.md @@ -0,0 +1 @@ +- **feat(ollama):** add Ollama Local embedding support via /v1/embeddings. (thanks @HaoNgo232) diff --git a/open-sse/config/embeddingRegistry.ts b/open-sse/config/embeddingRegistry.ts index 4882b5373a..f8de0e18e9 100644 --- a/open-sse/config/embeddingRegistry.ts +++ b/open-sse/config/embeddingRegistry.ts @@ -372,6 +372,21 @@ export const EMBEDDING_PROVIDERS: Record = { models: [], }, + // Ollama Local — OpenAI-compatible embeddings endpoint. Ollama exposes its + // own model catalog, but these common embedding models are useful defaults + // for model selection and validation. + "ollama-local": { + id: "ollama-local", + baseUrl: "http://localhost:11434/v1/embeddings", + authType: "none", + authHeader: "none", + models: [ + { id: "embeddinggemma", name: "EmbeddingGemma" }, + { id: "nomic-embed-text", name: "Nomic Embed Text" }, + { id: "bge-m3", name: "BGE M3" }, + ], + }, + // Issue #6660: Mixedbread AI — OpenAI-compatible /v1/embeddings, free tier // available (API key via signup, no card required). Model ids are the // upstream-qualified "mixedbread-ai/" form, mirroring how `together`/ diff --git a/open-sse/handlers/embeddings.ts b/open-sse/handlers/embeddings.ts index b66fff98b2..7846ec3d58 100644 --- a/open-sse/handlers/embeddings.ts +++ b/open-sse/handlers/embeddings.ts @@ -28,6 +28,7 @@ import { getCallLogPipelineCaptureStreamChunks } from "@/lib/logEnv"; import { toJsonErrorPayload } from "@/shared/utils/upstreamError"; import { stripStaleEncodingHeaders } from "../utils/upstreamResponseHeaders.ts"; import { sanitizeErrorMessage } from "../utils/error.ts"; +import { stripTrailingSlashes } from "../utils/urlSanitize.ts"; import { fetchRemoteImage } from "@/shared/network/remoteImageFetch"; import { hasStructuredEmbeddingInput, @@ -84,7 +85,11 @@ export async function handleEmbedding({ connectionId = null, }: { body: Record; - credentials: { apiKey?: string | null; accessToken?: string | null } | null; + credentials: { + apiKey?: string | null; + accessToken?: string | null; + providerSpecificData?: Record | null; + } | null; log?: { info: (...args: unknown[]) => void; error: (...args: unknown[]) => void }; resolvedProvider?: EmbeddingProvider | null; resolvedModel?: string | null; @@ -230,6 +235,23 @@ export async function handleEmbedding({ } let upstreamUrl = providerConfig.baseUrl; + if (provider === "ollama-local") { + const configuredBaseUrl = credentials?.providerSpecificData?.baseUrl; + const rawBaseUrl = + typeof configuredBaseUrl === "string" && configuredBaseUrl.trim().length > 0 + ? configuredBaseUrl + : providerConfig.baseUrl; + // Use the shared O(n) helper instead of `/\/+$/` — that regex is + // vulnerable to polynomial backtracking on adversarial input + // (CodeQL js/polynomial-redos) since baseUrl is operator-configured + // per-connection data. See open-sse/utils/urlSanitize.ts. + const normalizedBaseUrl = stripTrailingSlashes(rawBaseUrl.trim()); + const ollamaHost = normalizedBaseUrl + .replace(/\/v1\/(?:chat\/completions|embeddings)$/i, "") + .replace(/\/api\/chat$/i, "") + .replace(/\/v1$/i, ""); + upstreamUrl = `${ollamaHost}/v1/embeddings`; + } let normalizeProviderResponse: ((data: Record) => Record) | null = null; diff --git a/src/lib/embeddings/service.ts b/src/lib/embeddings/service.ts index ba3d0bb43d..c3d707369e 100644 --- a/src/lib/embeddings/service.ts +++ b/src/lib/embeddings/service.ts @@ -11,7 +11,12 @@ import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; import * as log from "@/sse/utils/logger"; import { toJsonErrorPayload } from "@/shared/utils/upstreamError"; import { getProviderCredentials, clearRecoveredProviderState } from "@/sse/services/auth"; -import { getCachedProviderNodes, getComboByName, getCombos, getDatabaseSettings } from "@/lib/localDb"; +import { + getCachedProviderNodes, + getComboByName, + getCombos, + getDatabaseSettings, +} from "@/lib/localDb"; import { resolveProxyForConnection } from "@/lib/db/settings"; import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; import { handleComboChat } from "@omniroute/open-sse/services/combo.ts"; @@ -68,10 +73,7 @@ export async function createEmbeddingResponse( // different models are not comparable). The generic combo engine has no // notion of embedding families, so reject loudly here before dispatch. // See _tasks/features-v3.8.12/01-embeddings-combo-family-guard.plan.md. - const dimConflict = findEmbeddingComboDimensionConflict( - combo as any, - allCombos as any - ); + const dimConflict = findEmbeddingComboDimensionConflict(combo as any, allCombos as any); if (dimConflict.conflict) { return errorResponse( HTTP_STATUS.BAD_REQUEST, @@ -225,6 +227,19 @@ export async function createEmbeddingResponse( credentials.retryAfterHuman ); } + } else if (provider === "ollama-local") { + // Ollama is keyless, but a configured connection can still provide a + // custom local host. Hydrate that optional connection without imposing an + // authentication requirement, then keep the static localhost default when + // no connection exists. + const localCredentials = await getProviderCredentials(credentialsProviderId); + if ( + localCredentials && + !("allRateLimited" in localCredentials) && + !("allExpired" in localCredentials) + ) { + credentials = localCredentials; + } } // #474: when the request used a bare model name (no "/" — e.g. an alias that @@ -259,11 +274,17 @@ export async function createEmbeddingResponse( const runEmbedding = () => handleEmbedding({ body: - effectiveModel !== resolvedModel ? { ...body, model: `${provider}/${effectiveModel}` } : body, + effectiveModel !== resolvedModel + ? { ...body, model: `${provider}/${effectiveModel}` } + : body, // getProviderCredentials returns a richer connection object; handleEmbedding - // only reads apiKey/accessToken, both present at runtime. Bridge the wider + // reads auth plus the optional local baseUrl override. Bridge the wider // selection type to the handler's narrow credential shape. - credentials: credentials as { apiKey?: string; accessToken?: string } | null, + credentials: credentials as { + apiKey?: string; + accessToken?: string; + providerSpecificData?: Record | null; + } | null, log, resolvedProvider: providerConfig, resolvedModel: effectiveModel, diff --git a/tests/unit/ollama-local-embedding-2824.test.ts b/tests/unit/ollama-local-embedding-2824.test.ts new file mode 100644 index 0000000000..2cb4c18e2f --- /dev/null +++ b/tests/unit/ollama-local-embedding-2824.test.ts @@ -0,0 +1,212 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const TEST_DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-ollama-embedding-2824-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const { getEmbeddingProvider, parseEmbeddingModel } = + await import("../../open-sse/config/embeddingRegistry.ts"); +const { handleEmbedding } = await import("../../open-sse/handlers/embeddings.ts"); +const core = await import("../../src/lib/db/core.ts"); +const { createProviderConnection } = await import("../../src/lib/db/providers.ts"); +const { createEmbeddingResponse } = await import("../../src/lib/embeddings/service.ts"); + +test.after(() => { + core.resetDbInstance(); + rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("ollama-local exposes a static no-auth embedding registry entry", () => { + const provider = getEmbeddingProvider("ollama-local"); + + assert.ok(provider); + assert.equal(provider.baseUrl, "http://localhost:11434/v1/embeddings"); + assert.equal(provider.authType, "none"); + assert.equal(provider.authHeader, "none"); + assert.deepEqual( + provider.models.map((model) => model.id), + ["embeddinggemma", "nomic-embed-text", "bge-m3"] + ); +}); + +test("ollama-local model names parse with the provider prefix", () => { + assert.deepEqual(parseEmbeddingModel("ollama-local/nomic-embed-text"), { + provider: "ollama-local", + model: "nomic-embed-text", + }); +}); + +test("ollama-local routes the default host without credentials", async () => { + const originalFetch = globalThis.fetch; + let captured: { url: string; body: Record } | null = null; + globalThis.fetch = async (url, options = {}) => { + captured = { + url: String(url), + body: JSON.parse(String(options.body || "{}")) as Record, + }; + return new Response( + JSON.stringify({ + data: [{ object: "embedding", embedding: [0.1, 0.2], index: 0 }], + usage: { prompt_tokens: 2, total_tokens: 2 }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; + + try { + const result = await handleEmbedding({ + body: { + model: "ollama-local/nomic-embed-text", + input: "hello world", + encoding_format: "float", + }, + credentials: null, + log: null, + }); + + assert.equal(result.success, true); + } finally { + globalThis.fetch = originalFetch; + } + + assert.ok(captured); + assert.equal(captured.url, "http://localhost:11434/v1/embeddings"); + assert.deepEqual(captured.body, { + model: "nomic-embed-text", + input: "hello world", + encoding_format: "float", + }); +}); + +test("ollama-local preserves a custom resolved host and strips trailing slashes", async () => { + const originalFetch = globalThis.fetch; + let capturedUrl: string | null = null; + globalThis.fetch = async (url) => { + capturedUrl = String(url); + return new Response( + JSON.stringify({ + data: [{ object: "embedding", embedding: [0.3, 0.4], index: 0 }], + usage: { prompt_tokens: 2, total_tokens: 2 }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; + + try { + const result = await handleEmbedding({ + body: { model: "ollama-local/bge-m3", input: "hello" }, + resolvedProvider: { + id: "ollama-local", + baseUrl: "http://localhost:11434/v1/embeddings", + authType: "none", + authHeader: "none", + models: [], + }, + resolvedModel: "bge-m3", + credentials: { + providerSpecificData: { baseUrl: "http://192.168.1.100:11434///" }, + }, + log: null, + }); + + assert.equal(result.success, true); + } finally { + globalThis.fetch = originalFetch; + } + + assert.equal(capturedUrl, "http://192.168.1.100:11434/v1/embeddings"); +}); + +test("ollama-local strips a pathological run of trailing slashes without ReDoS (CodeQL js/polynomial-redos #9225)", async () => { + // Regression for a polynomial-time regex (`/\/+$/`) that shipped in the + // initial ollama-local port: a long run of trailing slashes followed by a + // non-slash tail forces O(n^2) backtracking in a naive backtracking regex + // engine. handleEmbedding now normalizes via the shared, guaranteed-O(n) + // `stripTrailingSlashes` helper (open-sse/utils/urlSanitize.ts) instead. + const originalFetch = globalThis.fetch; + let capturedUrl: string | null = null; + globalThis.fetch = async (url) => { + capturedUrl = String(url); + return new Response( + JSON.stringify({ + data: [{ object: "embedding", embedding: [0.1, 0.2], index: 0 }], + usage: { prompt_tokens: 2, total_tokens: 2 }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; + + const adversarialBaseUrl = `http://192.168.1.100:11434${"/".repeat(50_000)}`; + + try { + const start = performance.now(); + const result = await handleEmbedding({ + body: { model: "ollama-local/bge-m3", input: "hello" }, + resolvedProvider: { + id: "ollama-local", + baseUrl: "http://localhost:11434/v1/embeddings", + authType: "none", + authHeader: "none", + models: [], + }, + resolvedModel: "bge-m3", + credentials: { + providerSpecificData: { baseUrl: adversarialBaseUrl }, + }, + log: null, + }); + const elapsed = performance.now() - start; + + assert.equal(result.success, true); + // Generous bound — a vulnerable O(n^2) regex over 50k trailing slashes + // takes seconds to minutes; a correct O(n) trim finishes in low ms. + assert.ok(elapsed < 500, `took ${elapsed}ms — expected < 500ms (ReDoS regression)`); + } finally { + globalThis.fetch = originalFetch; + } + + assert.equal(capturedUrl, "http://192.168.1.100:11434/v1/embeddings"); +}); + +test("ollama-local service hydrates the configured connection host without requiring a key", async () => { + await createProviderConnection({ + provider: "ollama-local", + authType: "none", + name: "LAN Ollama", + isActive: true, + providerSpecificData: { baseUrl: "http://10.10.0.181:11434/v1///" }, + }); + + const originalFetch = globalThis.fetch; + let captured: { url: string; headers: Record } | null = null; + globalThis.fetch = async (url, options = {}) => { + captured = { + url: String(url), + headers: (options.headers as Record) || {}, + }; + return new Response( + JSON.stringify({ + data: [{ object: "embedding", embedding: [0.5, 0.6], index: 0 }], + usage: { prompt_tokens: 2, total_tokens: 2 }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; + + try { + const response = await createEmbeddingResponse({ + model: "ollama-local/embeddinggemma", + input: "hello", + }); + assert.equal(response.status, 200); + } finally { + globalThis.fetch = originalFetch; + } + + assert.ok(captured); + assert.equal(captured.url, "http://10.10.0.181:11434/v1/embeddings"); + assert.equal(captured.headers.Authorization, undefined); +});