Skip to content
Open
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
11 changes: 11 additions & 0 deletions mindsdb/interfaces/knowledge_base/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -1094,6 +1094,17 @@ def add(
raise ValueError(
f"Unable to find database named {vector_db_name}, please make sure {vector_db_name} is defined"
)
# The storage backend for a knowledge base must be a vector database. If the user
# points `storage` at a non-vector integration (e.g. `files`, a SQL database, etc.)
# we previously crashed deep inside `create_table` with an opaque AttributeError.
# Detect that case here and raise a clear, actionable message. Fixes gh-11910.
if not isinstance(vector_store_handler, VectorStoreHandler):
handler_type = type(vector_store_handler).__name__
raise ValueError(
f"Storage '{vector_db_name}' is not a vector database "
f"(handler type: {handler_type}). Knowledge base storage must be a "
f"vector database integration such as pgvector, chromadb, or faiss."
)
# create table in vectordb before creating KB
if "default_vector_storage" in params:
# if vector db is a default - drop previous table, if exists
Expand Down
26 changes: 26 additions & 0 deletions mindsdb/interfaces/knowledge_base/llm_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import time
import asyncio
from typing import List
from urllib.parse import urlparse

from openai import OpenAI, AzureOpenAI

Expand Down Expand Up @@ -98,6 +99,13 @@ def __init__(self, params: dict = None, session=None):
elif self.provider == "ollama":
if params.get("api_key") is None:
params["api_key"] = "n/a"
# Ollama's OpenAI-compatible API is served under the `/v1` path. The OpenAI SDK
# appends route paths (e.g. `/embeddings`) directly to `base_url`, so a value
# like `http://localhost:11434` resolves to `http://localhost:11434/embeddings`
# which Ollama does not serve and returns 404. Provide a sensible default and
# transparently append `/v1` when the user-supplied URL is missing it.
# Fixes gh-11910.
params["base_url"] = self._normalize_ollama_base_url(params.get("base_url"))
self.client = OpenAI(**params)
elif self.provider == "bedrock":
if "aws_region" in params:
Expand All @@ -110,6 +118,24 @@ def __init__(self, params: dict = None, session=None):
else:
raise NotImplementedError(f'Provider "{self.provider}" is not supported')

@staticmethod
def _normalize_ollama_base_url(base_url: str | None) -> str:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness: The str | None union type syntax requires Python 3.10+; the rest of the file imports List from typing (Python 3.8/3.9 style), so this annotation will raise a TypeError at class-definition time on any runtime below 3.10. Use Optional[str] from typing instead (or add from __future__ import annotations to defer annotation evaluation).

🤖 AI Agent Prompt for Cursor/Windsurf

📋 Copy this prompt to your AI coding assistant (Cursor, Windsurf, etc.) to get help fixing this issue

In mindsdb/interfaces/knowledge_base/llm_client.py at line 122, the method signature `def _normalize_ollama_base_url(base_url: str | None) -> str:` uses the Python 3.10+ union type syntax (`str | None`). The rest of the file uses `typing.List` which is the Python 3.8/3.9 compatible style. Replace `str | None` with `Optional[str]` (importing `Optional` from `typing`) or simply remove the annotation to maintain backwards compatibility with Python 3.9 and earlier.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PEP 604 concern is a false positive: setup.py pins python_requires=">=3.10,<3.14", where str | None is native. The same syntax is already used throughout the repo, including the files this PR touches — e.g. controller.py:52 (id_column: str | None = None) and default_storage_resolver.py:7 (def _normalize_engine_name(engine: str | None) -> str | None). The annotation is consistent with existing convention, not a regression.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@chon3806 Got it, I'll keep that in mind for future reviews. Since the repo targets Python 3.10+ and already uses PEP 604 syntax consistently, I won't flag str | None style annotations as issues.

✍️ Learning notes added:

Learnt from: chon3806
Repo Name: mindsdb/mindsdb
PR Number: 12412
Filename: mindsdb/interfaces/knowledge_base/llm_client.py
Timestamp: 2026-04-25 18:07:53
Notes: Do not flag `str | None` union type syntax as a compatibility issue when the project's setup.py explicitly requires Python 3.10+, and the syntax is already consistently used throughout the codebase.

"""Return an Ollama base_url that points at the OpenAI-compatible `/v1` endpoint.

- When ``base_url`` is missing, default to ``http://localhost:11434/v1``.
- When the user-supplied URL has no ``v1`` path segment, append one.
- Otherwise return the URL unchanged so explicit user configuration wins.
"""
default_base_url = "http://localhost:11434/v1"
if not base_url:
return default_base_url

normalized = base_url.rstrip("/")
path_segments = [seg for seg in urlparse(normalized).path.split("/") if seg]
if "v1" in path_segments:
return normalized
return f"{normalized}/v1"

@run_in_batches(1000)
@retry_with_exponential_backoff
def embeddings(self, messages: List[str]):
Expand Down
56 changes: 56 additions & 0 deletions tests/unit/interfaces/knowledge_base/test_ollama_base_url.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Tests for ``LLMClient._normalize_ollama_base_url`` (gh-11910).

When a knowledge base is configured with an Ollama embedding model, the
OpenAI Python SDK appends route paths (``/embeddings``, ``/chat/completions``)
directly onto whatever ``base_url`` the client was constructed with. Ollama
serves its OpenAI-compatible API only under the ``/v1`` prefix, so a value
like ``http://localhost:11434`` resolves to ``http://localhost:11434/embeddings``
which Ollama does not expose and answers with ``404 Not Found``.

These tests pin the normalization rules so future refactors cannot silently
re-introduce the original bug.
"""

import pytest

from mindsdb.interfaces.knowledge_base.llm_client import LLMClient


OLLAMA_DEFAULT = "http://localhost:11434/v1"


class TestNormalizeOllamaBaseUrl:
@pytest.mark.parametrize("missing", [None, ""])
def test_missing_base_url_falls_back_to_local_default(self, missing):
assert LLMClient._normalize_ollama_base_url(missing) == OLLAMA_DEFAULT

def test_user_url_without_v1_gets_v1_appended(self):
# The exact case reported in gh-11910 — first user attempt.
assert LLMClient._normalize_ollama_base_url("http://localhost:11434") == "http://localhost:11434/v1"

def test_trailing_slash_is_collapsed_before_appending(self):
assert LLMClient._normalize_ollama_base_url("http://localhost:11434/") == "http://localhost:11434/v1"

def test_explicit_v1_is_left_unchanged(self):
assert LLMClient._normalize_ollama_base_url("http://localhost:11434/v1") == "http://localhost:11434/v1"

def test_explicit_v1_with_trailing_slash_is_normalized(self):
# rstrip("/") collapses trailing slashes; presence of /v1 still detected.
assert LLMClient._normalize_ollama_base_url("http://localhost:11434/v1/") == "http://localhost:11434/v1"

def test_remote_host_without_v1_gets_v1_appended(self):
assert (
LLMClient._normalize_ollama_base_url("https://ollama.example.com:8080")
== "https://ollama.example.com:8080/v1"
)

def test_url_with_v1_in_subpath_is_preserved(self):
# Some users front Ollama with a reverse proxy under a sub-path.
assert (
LLMClient._normalize_ollama_base_url("http://gateway.local/ollama/v1") == "http://gateway.local/ollama/v1"
)

def test_v10_is_not_mistaken_for_v1(self):
# Path-segment match must be exact: "v10" must not satisfy the "v1" check,
# otherwise we would silently drop the append and break custom deployments.
assert LLMClient._normalize_ollama_base_url("http://host/v10") == "http://host/v10/v1"
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"""Tests for KB storage handler validation (gh-11910).

Before this fix, ``KnowledgeBaseController.add`` happily accepted any
integration as ``storage`` and only blew up later inside
``vector_store_handler.create_table(...)`` with an opaque
``AttributeError: 'FileHandler' object has no attribute 'create_table'``.
The user has no way to tell from that message that they need to point
``storage`` at a vector database.

This test pins the friendly, up-front ``ValueError`` so the bad message
cannot regress.
"""

from types import SimpleNamespace
from unittest.mock import MagicMock, patch

import pytest

from mindsdb_sql_parser.ast import Identifier

from mindsdb.integrations.libs.vectordatabase_handler import VectorStoreHandler
from mindsdb.interfaces.knowledge_base.controller import KnowledgeBaseController


class _NotAVectorStore:
"""Stand-in for an integration handler that is not a vector database
(e.g. a FileHandler or a SQL handler)."""


class _FakeVectorStore(VectorStoreHandler):
"""Concrete subclass so isinstance(_, VectorStoreHandler) is True."""

def __init__(self): # pragma: no cover - trivial
# Intentionally bypass BaseHandler.__init__ — we only need the type.
pass


def _build_controller_with_storage(handler):
"""Wire up just enough of the controller to reach the storage validation."""
project = SimpleNamespace(id=1, name="mindsdb")
database_controller = MagicMock()
database_controller.get_project.return_value = project

data_node = SimpleNamespace(integration_handler=handler)
datahub = MagicMock()
datahub.get.return_value = data_node

integration_controller = MagicMock()
integration_controller.get.return_value = {"id": 42}

session = SimpleNamespace(
database_controller=database_controller,
datahub=datahub,
integration_controller=integration_controller,
model_controller=MagicMock(),
)
return KnowledgeBaseController(session)


def _embedding_params():
return {
"embedding_model": {
"provider": "openai",
"model_name": "text-embedding-3-small",
"api_key": "sk-test",
}
}


def test_non_vector_storage_raises_clear_error_before_create_table():
"""A non-vector handler must be rejected with an actionable message."""
controller = _build_controller_with_storage(_NotAVectorStore())

with (
patch.object(
KnowledgeBaseController,
"_check_embedding_model",
return_value={"dimension": 1536},
),
patch.object(KnowledgeBaseController, "get", return_value=None),
):
with pytest.raises(ValueError) as exc_info:
controller.add(
name="kb_bad_storage",
project_name="mindsdb",
storage=Identifier(parts=["files", "test_knowledge1"]),
params=_embedding_params(),
)

message = str(exc_info.value)
assert "files" in message
assert "vector database" in message
assert "pgvector" in message # message must point user at valid alternatives
# The original AttributeError leak must not be what the user sees.
assert "create_table" not in message
assert "_NotAVectorStore" in message


def test_vector_storage_passes_validation():
"""A real VectorStoreHandler subclass is accepted (proves the guard
is not over-eager)."""
handler = _FakeVectorStore()
handler.create_table = MagicMock()
handler.drop_table = MagicMock()
handler.add_full_text_index = MagicMock()
controller = _build_controller_with_storage(handler)

with (
patch.object(
KnowledgeBaseController,
"_check_embedding_model",
return_value={"dimension": 1536},
),
patch.object(KnowledgeBaseController, "_check_vector_table", return_value=None),
patch.object(KnowledgeBaseController, "get", return_value=None),
patch("mindsdb.interfaces.knowledge_base.controller.db.session"),
):
# We don't care about the return value here; we only care that the
# storage-type guard does not reject a real vector handler. Any
# downstream failure should NOT be the ValueError we are protecting
# against in the negative test above.
try:
controller.add(
name="kb_good_storage",
project_name="mindsdb",
storage=Identifier(parts=["pgvector_db", "kb_table"]),
params=_embedding_params(),
)
except ValueError as e:
assert "is not a vector database" not in str(e)

handler.create_table.assert_called_once_with("kb_table")
Loading