Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
8 changes: 4 additions & 4 deletions .secrets.baseline
Original file line number Diff line number Diff line change
Expand Up @@ -2253,7 +2253,7 @@
"filename": "src/backend/tests/unit/services/variable/test_service.py",
"hashed_secret": "30abc0a833efea23496b4d226fffa2f90c0855c0",
"is_verified": false,
"line_number": 43,
"line_number": 48,
"is_secret": false
}
],
Expand Down Expand Up @@ -2451,7 +2451,7 @@
"filename": "src/backend/tests/unit/test_unified_models.py",
"hashed_secret": "e9a5f12a8ecbb3eb46eca5096b5c52aa5e7c9fdd",
"is_verified": false,
"line_number": 527,
"line_number": 549,
"is_secret": false
}
],
Expand Down Expand Up @@ -6637,7 +6637,7 @@
"filename": "src/lfx/src/lfx/base/models/unified_models/model_catalog.py",
"hashed_secret": "665b1e3851eefefa3fb878654292f16597d25155",
"is_verified": false,
"line_number": 469,
"line_number": 498,
"is_secret": false
}
],
Expand Down Expand Up @@ -7242,5 +7242,5 @@
}
]
},
"generated_at": "2026-07-16T15:57:06Z"
"generated_at": "2026-07-16T21:26:55Z"
}
12 changes: 12 additions & 0 deletions BUNDLE_API.md
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,18 @@ the deserialize half is covered by
provider-only extension with `DiscoveredExtension.bundle_name = None`, and
`registry.Extension.bundle_name` is likewise now `str | None` (the
`lfx extension list` BUNDLE column shows `—` for such extensions).
- **Provider identity and static catalogs (additive).** A `providers[]` entry
may now declare a stable lowercase `provider_id`, an independent
`display_name`, legacy `aliases`, and a dotted-path `catalog_loader`. The
loader must return a flat list of model metadata rows; Langflow validates
model identities and stamps provider ownership before merging those rows
into the unified catalog. Manifests that omit `provider_id` retain their
existing behavior through a deterministic ID derived from `name`.
`ProviderDescriptor` is the preferred public registry type and
`ProviderSpec` remains an alias for source compatibility. The registry also
exposes an immutable generation-tagged snapshot plus an eager catalog
validation hook for deployment readiness. Existing provider manifests are
unaffected and `BUNDLE_API_VERSION` remains `1`.
- **New typed error codes (additive): `provider-invalid`, `provider-skipped`.**
A malformed provider spec surfaces `provider-invalid`; a provider whose name
collides with a built-in or already-loaded provider surfaces
Expand Down
68 changes: 66 additions & 2 deletions docs/docs/Lfx/extensions-manifest.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,15 @@ Use the `$schema` reference in your manifest so editors can autocomplete and val
| `name` | string | yes | Human-readable display name shown in the Langflow palette. 1-200 chars. |
| `description` | string \| null | no | Optional one-paragraph summary, max 2000 chars. |
| `lfx` | object | yes | [Compatibility declaration](#lfx-compatibility-declaration) against the BUNDLE_API contract. |
| `bundles` | array | yes | [Bundle list](#bundles). v0 accepts **exactly one** bundle. |
| `bundles` | array | no | [Bundle list](#bundles). v0 accepts at most one bundle; omit it for a provider-only extension. |
| `providers` | array | no | [Model providers](#providers) contributed to the unified model-provider registry. |
| `capabilities` | object | no | [Optional capability flags](#capabilities). Defaults to all-false. |
| `$schema` | string | no | Optional pointer to this JSON Schema; editors use it for autocomplete. |

`additionalProperties: false` — any field not listed here is rejected with a typed error. Reserved names (`services`, `routes`, `hooks`, `starterProjects`, `userConfig`) are documented under [Deferred fields](#deferred-fields) and surface a more specific error code.

An extension must declare at least one entry in either `bundles` or `providers`. It can declare both when one package contributes components and model providers.

## `lfx`: compatibility declaration

```json
Expand All @@ -68,7 +71,66 @@ The runtime compares `str(BUNDLE_API_VERSION)` against this list. A mismatch fai
| `name` | string | Bundle name; addressable as `ext:<bundle>:<Class>@<slot>`. Lowercase snake_case, starts with a letter, 2-64 chars. |
| `path` | string | Path to the bundle directory, relative to the manifest. Must not start with `/` or contain `..`. |

v0 enforces `minItems: 1, maxItems: 1`; multi-bundle extensions are rejected with `multi-bundle-deferred-in-this-milestone` and ship in a later epic.
v0 enforces `maxItems: 1`; multi-bundle extensions are rejected with `multi-bundle-deferred-in-this-milestone` and ship in a later epic. The list can be empty when the extension declares at least one model provider.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

## `providers`

A provider-only extension can add a provider without adding a component bundle:

```json
{
"$schema": "https://schemas.langflow.org/extension/v1.json",
"id": "lfx-acme-models",
"version": "0.1.0",
"name": "Acme Models",
"lfx": { "compat": ["1"] },
"providers": [
{
"name": "Acme",
"provider_id": "acme.models",
"display_name": "Acme Models",
"aliases": ["Acme Legacy"],
"metadata": {
"icon": "Bot",
"variables": [],
"mapping": {
"model_class": "ChatAcme",
"model_param": "model"
}
},
"api_key_required": false,
"model_class": {
"module": "langchain_acme",
"attr": "ChatAcme",
"install_hint": "langchain-acme"
},
"catalog_loader": "lfx_acme.catalog:load_models"
}
]
}
```

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | yes | Canonical provider name used by existing saved flows and provider selectors. |
| `provider_id` | string | no | Stable lowercase machine identity used by policy and extension compatibility. Must match `^[a-z0-9][a-z0-9._-]*$`. Legacy manifests can omit it and receive a deterministic ID derived from `name`. |
| `display_name` | string | no | User-facing label. It can change without changing `provider_id` or saved-flow identity. |
| `aliases` | array of strings | no | Unique legacy names accepted when resolving the provider's stable identity. |
| `metadata` | object | yes | Provider icon, variables, API documentation URL, and mapping. `mapping.model_class` must be non-empty. |
| `model_class` | object | no | Lazy LLM class import with `module`, `attr`, and optional `install_hint`. Omit it when reusing an already-registered class. |
| `embedding` | object | no | Optional embedding class import and parameter mapping. Includes `class_name`, `module`, `attr`, `param_mapping_key`, `param_mapping`, and optional `install_hint`. |
| `api_key_required` | bool | no | Whether unified runtime helpers reject missing API-key credentials. Defaults to `true`. |
| `live` | bool | no | Enables always-on live model discovery. Mutually exclusive with `conditional_live`. |
| `conditional_live` | bool | no | Enables live discovery only when the provider has a custom endpoint configured. Mutually exclusive with `live`. |
| `live_discovery` | string | no | Lazy `module:callable` that receives `(user_id, model_type)` and returns live model rows. |
| `validator` | string | no | Lazy `module:callable` that validates `(provider, variables, model)` and raises on invalid credentials. |
| `catalog_loader` | string | no | Lazy `module:callable` returning the provider's static model metadata rows. |

`catalog_loader` must return a flat `list[dict]`. Every row requires a non-empty `name`; `model_type` can be `llm` or `embeddings` and defaults to `llm`. Langflow overwrites any supplied `provider` value with the descriptor's canonical `name`, supplies the provider icon when omitted, and rejects duplicate `(model_type, name)` identities. Deployments that require an extension catalog can call `validate_registered_provider_catalogs()` or `get_registry_snapshot(validate_catalogs=True)` during readiness.

Providers that use ambient authentication or need no credentials should set `api_key_required` to `false`. They remain eligible for unified model options when `metadata.variables` is empty or contains only optional configuration fields.

Built-in providers take precedence over extension declarations. A colliding provider is skipped without preventing other providers or components in the extension from loading.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

## `capabilities`

Expand Down Expand Up @@ -127,6 +189,8 @@ The loader and validator both emit typed errors keyed by the manifest field that
| `multi-bundle-deferred-in-this-milestone` | `bundles` has more than one entry. |
| `path-escape` | A `bundles[].path` resolves outside the manifest root (typically a symlink). |
| `bundle-path-not-found` | `bundles[].path` does not exist or is not a directory. |
| `provider-invalid` | A provider descriptor or one of its lazy import paths is malformed. The invalid provider is skipped. |
| `provider-skipped` | A provider name collides with a built-in or previously loaded provider. The existing provider wins. |

Run `lfx extension validate <path>` to see every error as a structured object with `code`, `message`, `location`, `hint`, and `ref_url`.

Expand Down
10 changes: 5 additions & 5 deletions src/backend/base/langflow/agentic/api/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@

from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
from lfx.base.models.provider_registry import is_api_key_optional
from lfx.base.models.unified_models import (
get_all_variables_for_provider,
get_model_provider_variable_mapping,
get_provider_required_variable_keys,
get_provider_secret_variable_key,
get_unified_models_detailed,
)
from lfx.log.logger import logger
Expand Down Expand Up @@ -47,7 +48,7 @@ class _AssistantContext:

provider: str
model_name: str
api_key_name: str
api_key_name: str | None
session_id: str
global_vars: dict[str, str]
max_retries: int
Expand All @@ -63,7 +64,6 @@ async def _resolve_assistant_context(
Raises:
HTTPException: If provider is not configured or API key is missing.
"""
provider_variable_map = get_model_provider_variable_mapping()
enabled_providers, _ = await get_enabled_providers_for_user(user_id, session)

if not enabled_providers:
Expand All @@ -87,8 +87,8 @@ async def _resolve_assistant_context(
detail=f"Provider '{provider}' is not configured. Available providers: {enabled_providers}",
)

api_key_name = provider_variable_map.get(provider)
if not api_key_name:
api_key_name = get_provider_secret_variable_key(provider)
if not api_key_name and not is_api_key_optional(provider):
raise HTTPException(status_code=400, detail=f"Unknown provider: {provider}")

model_name = request.model_name or get_default_model(provider, user_id=user_id) or ""
Expand Down
28 changes: 26 additions & 2 deletions src/backend/base/langflow/agentic/services/provider_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@

from lfx.base.models.model_metadata import CONDITIONAL_LIVE_MODEL_PROVIDERS, LIVE_MODEL_PROVIDERS
from lfx.base.models.model_utils import get_live_models_for_provider
from lfx.base.models.provider_registry import is_api_key_optional
from lfx.base.models.unified_models import (
get_model_provider_variable_mapping,
get_model_providers,
get_provider_required_variable_keys,
get_unified_models_detailed,
)
from lfx.log.logger import logger
from lfx.services.model_provider_policy import ModelProviderPolicyPurpose, resolve_model_provider_policy
from lfx.utils.secrets import secret_value_to_str
from sqlalchemy.ext.asyncio import AsyncSession

Expand Down Expand Up @@ -61,15 +64,36 @@ async def get_enabled_providers_for_user(
all_variable_names = {var.name for var in all_variables}

provider_variable_map = get_model_provider_variable_mapping()
registered_providers = get_model_providers()
provider_candidates = [
*provider_variable_map,
*(
provider
for provider in registered_providers
if provider not in provider_variable_map and is_api_key_optional(provider)
),
]
provider_policy = resolve_model_provider_policy(
user_id=user_id,
providers=[*registered_providers, *provider_candidates],
purpose=ModelProviderPolicyPurpose.USE,
)

enabled_providers = []
provider_status = {}

for provider in provider_variable_map:
for provider in provider_candidates:
if not provider_policy.allows(provider):
continue
# Check if ALL required variables for this provider are present
# in either database variables or environment variables
required_keys = get_provider_required_variable_keys(provider)
is_enabled = all(key in all_variable_names or os.getenv(key) for key in required_keys)
provider_has_variables = provider in provider_variable_map
is_enabled = (
is_api_key_optional(provider)
if not provider_has_variables
else all(key in all_variable_names or os.getenv(key) for key in required_keys)
)

provider_status[provider] = is_enabled
if is_enabled:
Expand Down
48 changes: 45 additions & 3 deletions src/backend/base/langflow/api/v1/knowledge_bases.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,14 @@
get_source_class,
registered_sources,
)
from lfx.base.models.provider_registry import provider_id_for
from lfx.base.vectorstores.chroma_security import chroma_client_create_collection_kwargs
from lfx.log import logger
from lfx.services.model_provider_policy import (
ModelProviderPolicyError,
ModelProviderPolicyPurpose,
require_model_provider,
)
from pydantic import BaseModel, Field

from langflow.api.utils import CurrentActiveUser, ingestion_run_service, knowledge_base_service
Expand Down Expand Up @@ -78,6 +84,41 @@
router = APIRouter(tags=["Knowledge Bases"], prefix="/knowledge_bases", include_in_schema=False)


def _provider_identity(provider: str) -> str:
"""Return a stable identity for comparison without exposing registry state."""
normalized = provider.strip()
return provider_id_for(normalized) or normalized.casefold()


def _require_create_embedding_provider(
request: CreateKnowledgeBaseRequest,
current_user: CurrentActiveUser,
) -> str:
"""Validate both provider representations and enforce CONFIGURE policy."""
flat_provider = request.embedding_provider.strip()
selected_provider = None
if request.model_selection is not None:
selected_provider = knowledge_base_service.get_embedding_provider(request.model_selection)
if not flat_provider or (
selected_provider is not None
and (
selected_provider == "Unknown" or _provider_identity(flat_provider) != _provider_identity(selected_provider)
)
):
raise HTTPException(status_code=404, detail="Model provider not found")

try:
require_model_provider(
user_id=current_user.id,
provider=flat_provider,
purpose=ModelProviderPolicyPurpose.CONFIGURE,
)
except ModelProviderPolicyError as exc:
# Treat unknown, blocked, and conflicting provider identities alike.
raise HTTPException(status_code=404, detail="Model provider not found") from exc
return flat_provider


@dataclass(frozen=True)
class _KbGuardResult:
"""Outcome of ``_guard_kb_action`` used by routes to know the effective owner.
Expand Down Expand Up @@ -709,6 +750,7 @@ async def create_knowledge_base(
) -> KnowledgeBaseInfo:
"""Create a new knowledge base with embedding configuration."""
try:
embedding_provider = _require_create_embedding_provider(request, current_user)
kb_root_path = KBStorageHelper.get_root_path()
kb_user = current_user.username
kb_name = request.name.strip().replace(" ", "_")
Expand Down Expand Up @@ -788,7 +830,7 @@ async def create_knowledge_base(
backend_config_value = request.backend_config or {}
embedding_metadata = {
"id": str(kb_id),
"embedding_provider": request.embedding_provider,
"embedding_provider": embedding_provider,
"embedding_model": request.embedding_model,
"model_selection": request.model_selection,
"created_at": datetime.now(timezone.utc).isoformat(),
Expand Down Expand Up @@ -827,7 +869,7 @@ async def create_knowledge_base(
# when the request didn't carry one of its own.
persisted_selection = request.model_selection or {
"name": request.embedding_model,
"provider": request.embedding_provider,
"provider": embedding_provider,
}
await knowledge_base_service.create_record(
user_id=current_user.id,
Expand Down Expand Up @@ -857,7 +899,7 @@ async def create_knowledge_base(
id=str(kb_id),
dir_name=kb_name,
name=kb_name.replace("_", " "),
embedding_provider=request.embedding_provider,
embedding_provider=embedding_provider,
embedding_model=request.embedding_model,
size=0,
words=0,
Expand Down
Loading
Loading