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
4 changes: 2 additions & 2 deletions src/backend/base/langflow/api/v1/memories.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
from langflow.services.jobs import DuplicateJobError
from langflow.services.memory_base.kb_path_helpers import BackendProvisioningError
from langflow.services.memory_base.provider_scope import MemoryBaseFlowNotFoundError
from langflow.services.memory_base.service import PreprocessingValidationError
from langflow.services.memory_base.service import EmbeddingProviderValidationError, PreprocessingValidationError

router = APIRouter(tags=["Memories"], prefix="/memories", include_in_schema=False)

Expand Down Expand Up @@ -160,7 +160,7 @@ async def create_memory_base(
except ModelProviderPolicyError as exc:
# Keep a hidden provider indistinguishable from one that does not exist.
raise HTTPException(status_code=404, detail="Model provider not found") from exc
except PreprocessingValidationError as exc:
except (PreprocessingValidationError, EmbeddingProviderValidationError) as exc:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
raise HTTPException(status_code=422, detail=str(exc)) from exc
except BackendProvisioningError as exc:
# Bad remote vector-store config (unreachable / wrong credentials) —
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,15 @@ class MemoryBaseCreate(MemoryBaseBase):
backend_type: str | None = None
backend_config: dict = Field(default_factory=dict)

# Provider that serves ``embedding_model``, as selected by the caller.
# Declared here rather than on ``MemoryBaseBase`` for the same reason as the
# backend fields above: no column is added to the ``memory_base`` table
# because the value is persisted in the backing ``knowledge_base`` row's
# ``model_selection``, which every ingestion/retrieval path resolves against.
# ``None`` falls back to name-based inference, which cannot see
# live-discovered models (e.g. an OpenAI-Compatible endpoint's catalog).
embedding_provider: str | None = None

@model_validator(mode="after")
def preprocessing_defaults(self) -> "MemoryBaseCreate":
if self.preprocessing and not self.preproc_model:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@
def infer_embedding_provider(embedding_model: str) -> str:
"""Derive embedding provider name from a model string.

Fallback only: used when the caller did not supply the provider it
selected. Inference is a guess — it has no user context, so it cannot see
live-discovered models (an OpenAI-Compatible endpoint's catalog is
per-user) and mislabels them via the ``"OpenAI"`` default. Callers that
know the selected provider must pass it instead.

Looks up the model in the unified models catalog first so the answer
matches what the UI dropdown shows; falls back to pattern-based
inference for legacy/edge cases.
Expand Down
86 changes: 77 additions & 9 deletions src/backend/base/langflow/services/memory_base/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,17 @@
from lfx.base.knowledge_bases.backends import is_local_chroma
from lfx.base.knowledge_bases.backends.postgres import resolve_default_kb_backend
from lfx.base.knowledge_bases.validation import validate_collection_name
from lfx.base.models.provider_registry import is_api_key_optional
from lfx.base.models.provider_registry import is_api_key_optional, provider_name_for_id, resolve_provider_id
from lfx.base.models.unified_models import get_api_key_for_provider
from lfx.base.models.unified_models.class_registry import EMBEDDING_PROVIDER_CLASS_MAPPING
from lfx.services.model_provider_policy import (
ModelProviderPolicyPurpose,
aresolve_model_provider_policy,
require_model_provider,
)
from sqlmodel import col, select

from langflow.api.utils.kb_helpers import local_chroma_rejection_reason
from langflow.api.utils.kb_helpers import local_chroma_rejection_reason, resolve_embedding_selection
from langflow.services.base import Service
from langflow.services.database.models.memory_base.model import (
MemoryBase,
Expand Down Expand Up @@ -82,6 +83,10 @@ class PreprocessingValidationError(ValueError):
"""Raised when preprocessing is enabled but the provider API key is absent."""


class EmbeddingProviderValidationError(ValueError):
"""Raised when the caller-selected embedding provider cannot serve embeddings."""


def _require_preprocessing_model_provider(user_id: uuid.UUID, preproc_model: str | None) -> str | None:
"""Require CONFIGURE access for a supplied preprocessing model identity."""
provider = _infer_preprocessing_model_provider(preproc_model)
Expand All @@ -105,18 +110,67 @@ def _infer_preprocessing_model_provider(preproc_model: str | None) -> str | None
raise PreprocessingValidationError(str(exc)) from exc


# ``get_embedding_provider`` reports this sentinel for a knowledge_base row whose
# ``model_selection`` carries no provider; it must never be authorized or persisted.
_UNKNOWN_PROVIDER = "Unknown"


def _select_embedding_provider(embedding_provider: str | None, embedding_model: str) -> str:
"""Return the canonical embedding provider for a Memory Base.

The caller's explicit selection wins. It is canonicalized through the provider
registry so the persisted value is the exact key every downstream embedding
lookup uses (``EMBEDDING_PROVIDER_CLASS_MAPPING`` is matched verbatim, while the
policy layer matches case- and alias-insensitively): ``"openai"`` becomes
``"OpenAI"`` and ``"IBM watsonx.ai"`` becomes ``"IBM WatsonX"``. Names the
registry does not know are kept as supplied so the policy layer can reject
them. Name-based inference is the fallback only when nothing usable was given.
"""
supplied = (embedding_provider or "").strip()
if not supplied or supplied == _UNKNOWN_PROVIDER:
return infer_embedding_provider(embedding_model)
return provider_name_for_id(resolve_provider_id(supplied)) or supplied


def _require_embedding_class(provider: str) -> None:
"""Reject a caller-selected provider that cannot serve embeddings.

Runs after the policy preflight on create only. The OSS policy allows every
provider name, so without this check a typo or a chat-only provider would be
persisted and fail at the first ingestion with a misleading credential error.
Stored providers on existing Memory Bases are not re-checked so an uninstalled
bundle never blocks deactivating or renaming a Memory Base.

Raises:
EmbeddingProviderValidationError: ``provider`` has no registered embedding class.
"""
if provider not in EMBEDDING_PROVIDER_CLASS_MAPPING:
msg = f"Embedding provider '{provider}' is not available for embeddings."
raise EmbeddingProviderValidationError(msg)


async def _preflight_memory_provider_configuration(
*,
flow,
actor_user_id: uuid.UUID,
actor_is_superuser: bool,
embedding_model: str,
embedding_provider: str | None,
preproc_model: str | None,
) -> tuple[str | None, str]:
"""Authorize selected configuration providers before any owner credential read."""
"""Authorize selected configuration providers before any owner credential read.

``embedding_provider`` is the provider the caller actually selected and is
authoritative when supplied. Name-based inference is only the fallback: it
cannot see live-discovered models (an OpenAI-Compatible endpoint's catalog is
per-user), so guessing from the model name labels those models as OpenAI and
every later credential lookup asks for the wrong key.
"""
preprocessing_provider = _infer_preprocessing_model_provider(preproc_model)
embedding_provider = infer_embedding_provider(embedding_model)
providers = list(dict.fromkeys(provider for provider in (preprocessing_provider, embedding_provider) if provider))
selected_embedding_provider = _select_embedding_provider(embedding_provider, embedding_model)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@viktoravelino
Can we create a tracker to unify the model discovery pattern for provider vs models? This is too large a change that I am suggesting for this PR but we should track it.
Ideally at some point we should be able to call:

_infer_model_provider(*args, **kwargs)

and have this guarded behaviour across all provider -> model mappings. Do you agree?

@viktoravelino viktoravelino Sep 4, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@dkaushik94
Agreed, and this PR is a good example of why: to fix one path I ended up walking five others that answer the same question differently.

Today "which provider serves this model" is answered in six places:

  • infer_embedding_provider (memory_base/embedding_helpers.py): static catalog with no user id → name patterns → silent "OpenAI" default
  • infer_llm_provider (same file): catalog only, raises on unknown
  • _infer_preprocessing_model_provider (memory_base/service.py): wraps the one above
  • _select_embedding_provider + _require_embedding_class (memory_base/service.py, this PR): explicit provider wins, canonicalized via the registry, checked for an embedding class
  • _require_create_embedding_provider + _provider_identity (api/v1/knowledge_bases.py): flat provider vs model_selection with its own identity normalization
  • get_provider_for_model_name (lfx provider_queries.py), which two of the above call

They disagree on whether the lookup is user-aware, whether unknown means "default to OpenAI" or "raise", whether the result is canonicalized, and whether capability for the model type is checked.

What I'd put in the tracker:

  1. One helper in lfx next to the provider registry, since both the KB route and the memory service need it. I'd go for a typed signature rather than *args, **kwargs, something like resolve_model_provider(model_name, *, provider=None, model_type, user_id) with a single rule: explicit provider wins → user-aware catalog → fail with a clear error, never a silent OpenAI default.
  2. Migrate the six call sites above to it.
  3. Retire name-only persistence. The knowledge_base row already stores {name, provider}; memory_base still stores only embedding_model. Once every writer carries the pair, inference is only a backfill concern and the fallback branch can be deleted.

Happy to open the ticket if you want to link it here.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@viktoravelino can you create a tracker for this? The PR looks fine otherwise.

providers = list(
dict.fromkeys(provider for provider in (preprocessing_provider, selected_embedding_provider) if provider)
)
with scoped_model_provider_policy_for_flow(
flow,
user_id=actor_user_id,
Expand All @@ -129,7 +183,7 @@ async def _preflight_memory_provider_configuration(
)
for provider in providers:
provider_policy.require(provider)
return preprocessing_provider, embedding_provider
return preprocessing_provider, selected_embedding_provider


def _validate_preprocessing_api_key(user_id: uuid.UUID, preproc_model: str | None) -> None:
Expand Down Expand Up @@ -229,8 +283,12 @@ async def create(
actor_user_id=user_id,
actor_is_superuser=is_superuser,
embedding_model=payload.embedding_model,
embedding_provider=payload.embedding_provider,
preproc_model=payload.preproc_model,
)
# Policy first so a hidden provider stays indistinguishable from a missing one.
if (payload.embedding_provider or "").strip():
_require_embedding_class(embedding_provider)
if payload.preprocessing:
_validate_preprocessing_provider_api_key(
user_id,
Expand Down Expand Up @@ -307,9 +365,9 @@ async def create(
raise ValueError(msg)

mb = MemoryBase(
# ``backend_type``/``backend_config`` live on the knowledge_base
# row created above, not on this table.
**payload.model_dump(exclude={"user_id", "backend_type", "backend_config"}),
# ``backend_type``/``backend_config``/``embedding_provider`` live
# on the knowledge_base row created above, not on this table.
**payload.model_dump(exclude={"user_id", "backend_type", "backend_config", "embedding_provider"}),
user_id=user_id,
kb_name=kb_name,
)
Expand Down Expand Up @@ -414,11 +472,21 @@ async def update(
return None

flow = await resolve_owned_memory_flow(db, flow_id=mb.flow_id, user_id=owner_user_id)
# The embedding provider chosen at create time is persisted on the
# backing knowledge_base row (the memory_base table stores only the
# model name). Re-inferring it from that name would relabel a
# live-discovered model — e.g. one served by an OpenAI-Compatible
# endpoint — as OpenAI and authorize the wrong provider.
stored_embedding_provider, _stored_embedding_model = await resolve_embedding_selection(
user_id=owner_user_id,
kb_name=mb.kb_name,
)
preprocessing_provider, _embedding_provider = await _preflight_memory_provider_configuration(
flow=flow,
actor_user_id=actor_user_id,
actor_is_superuser=actor_is_superuser,
embedding_model=mb.embedding_model,
embedding_provider=stored_embedding_provider,
preproc_model=mb.preproc_model if mb.preprocessing else None,
)

Expand Down
Loading
Loading