Skip to content

Commit e86052d

Browse files
committed
feat(models): enforce provider visibility policy
1 parent 8b815b6 commit e86052d

49 files changed

Lines changed: 2496 additions & 139 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.secrets.baseline

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2253,7 +2253,7 @@
22532253
"filename": "src/backend/tests/unit/services/variable/test_service.py",
22542254
"hashed_secret": "30abc0a833efea23496b4d226fffa2f90c0855c0",
22552255
"is_verified": false,
2256-
"line_number": 43,
2256+
"line_number": 48,
22572257
"is_secret": false
22582258
}
22592259
],
@@ -2451,7 +2451,7 @@
24512451
"filename": "src/backend/tests/unit/test_unified_models.py",
24522452
"hashed_secret": "e9a5f12a8ecbb3eb46eca5096b5c52aa5e7c9fdd",
24532453
"is_verified": false,
2454-
"line_number": 527,
2454+
"line_number": 549,
24552455
"is_secret": false
24562456
}
24572457
],
@@ -6637,7 +6637,7 @@
66376637
"filename": "src/lfx/src/lfx/base/models/unified_models/model_catalog.py",
66386638
"hashed_secret": "665b1e3851eefefa3fb878654292f16597d25155",
66396639
"is_verified": false,
6640-
"line_number": 469,
6640+
"line_number": 498,
66416641
"is_secret": false
66426642
}
66436643
],
@@ -7242,5 +7242,5 @@
72427242
}
72437243
]
72447244
},
7245-
"generated_at": "2026-07-16T15:57:06Z"
7245+
"generated_at": "2026-07-16T21:26:55Z"
72467246
}

docs/docs/Lfx/extensions-manifest.mdx

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,15 @@ Use the `$schema` reference in your manifest so editors can autocomplete and val
3737
| `name` | string | yes | Human-readable display name shown in the Langflow palette. 1-200 chars. |
3838
| `description` | string \| null | no | Optional one-paragraph summary, max 2000 chars. |
3939
| `lfx` | object | yes | [Compatibility declaration](#lfx-compatibility-declaration) against the BUNDLE_API contract. |
40-
| `bundles` | array | yes | [Bundle list](#bundles). v0 accepts **exactly one** bundle. |
40+
| `bundles` | array | no | [Bundle list](#bundles). v0 accepts at most one bundle; omit it for a provider-only extension. |
41+
| `providers` | array | no | [Model providers](#providers) contributed to the unified model-provider registry. |
4142
| `capabilities` | object | no | [Optional capability flags](#capabilities). Defaults to all-false. |
4243
| `$schema` | string | no | Optional pointer to this JSON Schema; editors use it for autocomplete. |
4344

4445
`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.
4546

47+
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.
48+
4649
## `lfx`: compatibility declaration
4750

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

71-
v0 enforces `minItems: 1, maxItems: 1`; multi-bundle extensions are rejected with `multi-bundle-deferred-in-this-milestone` and ship in a later epic.
74+
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.
75+
76+
## `providers`
77+
78+
A provider-only extension can add a provider without adding a component bundle:
79+
80+
```json
81+
{
82+
"$schema": "https://schemas.langflow.org/extension/v1.json",
83+
"id": "lfx-acme-models",
84+
"version": "0.1.0",
85+
"name": "Acme Models",
86+
"lfx": { "compat": ["1"] },
87+
"providers": [
88+
{
89+
"name": "Acme",
90+
"provider_id": "acme.models",
91+
"display_name": "Acme Models",
92+
"aliases": ["Acme Legacy"],
93+
"metadata": {
94+
"icon": "Bot",
95+
"variables": [],
96+
"mapping": {
97+
"model_class": "ChatAcme",
98+
"model_param": "model"
99+
}
100+
},
101+
"api_key_required": false,
102+
"model_class": {
103+
"module": "langchain_acme",
104+
"attr": "ChatAcme",
105+
"install_hint": "langchain-acme"
106+
},
107+
"catalog_loader": "lfx_acme.catalog:load_models"
108+
}
109+
]
110+
}
111+
```
112+
113+
| Field | Type | Required | Description |
114+
| --- | --- | --- | --- |
115+
| `name` | string | yes | Canonical provider name used by existing saved flows and provider selectors. |
116+
| `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`. |
117+
| `display_name` | string | no | User-facing label. It can change without changing `provider_id` or saved-flow identity. |
118+
| `aliases` | array of strings | no | Unique legacy names accepted when resolving the provider's stable identity. |
119+
| `metadata` | object | yes | Provider icon, variables, API documentation URL, and mapping. `mapping.model_class` must be non-empty. |
120+
| `model_class` | object | no | Lazy LLM class import with `module`, `attr`, and optional `install_hint`. Omit it when reusing an already-registered class. |
121+
| `embedding` | object | no | Optional embedding class import and parameter mapping. Includes `class_name`, `module`, `attr`, `param_mapping_key`, `param_mapping`, and optional `install_hint`. |
122+
| `api_key_required` | bool | no | Whether unified runtime helpers reject missing API-key credentials. Defaults to `true`. |
123+
| `live` | bool | no | Enables always-on live model discovery. Mutually exclusive with `conditional_live`. |
124+
| `conditional_live` | bool | no | Enables live discovery only when the provider has a custom endpoint configured. Mutually exclusive with `live`. |
125+
| `live_discovery` | string | no | Lazy `module:callable` that receives `(user_id, model_type)` and returns live model rows. |
126+
| `validator` | string | no | Lazy `module:callable` that validates `(provider, variables, model)` and raises on invalid credentials. |
127+
| `catalog_loader` | string | no | Lazy `module:callable` returning the provider's static model metadata rows. |
128+
129+
`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.
130+
131+
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.
132+
133+
Built-in providers take precedence over extension declarations. A colliding provider is skipped without preventing other providers or components in the extension from loading.
72134

73135
## `capabilities`
74136

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

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

src/backend/base/langflow/agentic/api/router.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@
1010

1111
from fastapi import APIRouter, Depends, HTTPException, Request
1212
from fastapi.responses import StreamingResponse
13+
from lfx.base.models.provider_registry import is_api_key_optional
1314
from lfx.base.models.unified_models import (
1415
get_all_variables_for_provider,
15-
get_model_provider_variable_mapping,
1616
get_provider_required_variable_keys,
17+
get_provider_secret_variable_key,
1718
get_unified_models_detailed,
1819
)
1920
from lfx.log.logger import logger
@@ -47,7 +48,7 @@ class _AssistantContext:
4748

4849
provider: str
4950
model_name: str
50-
api_key_name: str
51+
api_key_name: str | None
5152
session_id: str
5253
global_vars: dict[str, str]
5354
max_retries: int
@@ -63,7 +64,6 @@ async def _resolve_assistant_context(
6364
Raises:
6465
HTTPException: If provider is not configured or API key is missing.
6566
"""
66-
provider_variable_map = get_model_provider_variable_mapping()
6767
enabled_providers, _ = await get_enabled_providers_for_user(user_id, session)
6868

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

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

9494
model_name = request.model_name or get_default_model(provider, user_id=user_id) or ""

src/backend/base/langflow/agentic/services/provider_service.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,15 @@
66

77
from lfx.base.models.model_metadata import CONDITIONAL_LIVE_MODEL_PROVIDERS, LIVE_MODEL_PROVIDERS
88
from lfx.base.models.model_utils import get_live_models_for_provider
9+
from lfx.base.models.provider_registry import is_api_key_optional
910
from lfx.base.models.unified_models import (
1011
get_model_provider_variable_mapping,
12+
get_model_providers,
1113
get_provider_required_variable_keys,
1214
get_unified_models_detailed,
1315
)
1416
from lfx.log.logger import logger
17+
from lfx.services.model_provider_policy import ModelProviderPolicyPurpose, resolve_model_provider_policy
1518
from lfx.utils.secrets import secret_value_to_str
1619
from sqlalchemy.ext.asyncio import AsyncSession
1720

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

6366
provider_variable_map = get_model_provider_variable_mapping()
67+
registered_providers = get_model_providers()
68+
provider_candidates = [
69+
*provider_variable_map,
70+
*(
71+
provider
72+
for provider in registered_providers
73+
if provider not in provider_variable_map and is_api_key_optional(provider)
74+
),
75+
]
76+
provider_policy = resolve_model_provider_policy(
77+
user_id=user_id,
78+
providers=[*registered_providers, *provider_candidates],
79+
purpose=ModelProviderPolicyPurpose.USE,
80+
)
6481

6582
enabled_providers = []
6683
provider_status = {}
6784

68-
for provider in provider_variable_map:
85+
for provider in provider_candidates:
86+
if not provider_policy.allows(provider):
87+
continue
6988
# Check if ALL required variables for this provider are present
7089
# in either database variables or environment variables
7190
required_keys = get_provider_required_variable_keys(provider)
72-
is_enabled = all(key in all_variable_names or os.getenv(key) for key in required_keys)
91+
provider_has_variables = provider in provider_variable_map
92+
is_enabled = (
93+
is_api_key_optional(provider)
94+
if not provider_has_variables
95+
else all(key in all_variable_names or os.getenv(key) for key in required_keys)
96+
)
7397

7498
provider_status[provider] = is_enabled
7599
if is_enabled:

src/backend/base/langflow/api/v1/knowledge_bases.py

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,14 @@
2222
get_source_class,
2323
registered_sources,
2424
)
25+
from lfx.base.models.provider_registry import provider_id_for
2526
from lfx.base.vectorstores.chroma_security import chroma_client_create_collection_kwargs
2627
from lfx.log import logger
28+
from lfx.services.model_provider_policy import (
29+
ModelProviderPolicyError,
30+
ModelProviderPolicyPurpose,
31+
require_model_provider,
32+
)
2733
from pydantic import BaseModel, Field
2834

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

8086

87+
def _provider_identity(provider: str) -> str:
88+
"""Return a stable identity for comparison without exposing registry state."""
89+
normalized = provider.strip()
90+
return provider_id_for(normalized) or normalized.casefold()
91+
92+
93+
def _require_create_embedding_provider(
94+
request: CreateKnowledgeBaseRequest,
95+
current_user: CurrentActiveUser,
96+
) -> str:
97+
"""Validate both provider representations and enforce CONFIGURE policy."""
98+
flat_provider = request.embedding_provider.strip()
99+
selected_provider = None
100+
if request.model_selection is not None:
101+
selected_provider = knowledge_base_service.get_embedding_provider(request.model_selection)
102+
if not flat_provider or (
103+
selected_provider is not None
104+
and (
105+
selected_provider == "Unknown" or _provider_identity(flat_provider) != _provider_identity(selected_provider)
106+
)
107+
):
108+
raise HTTPException(status_code=404, detail="Model provider not found")
109+
110+
try:
111+
require_model_provider(
112+
user_id=current_user.id,
113+
provider=flat_provider,
114+
purpose=ModelProviderPolicyPurpose.CONFIGURE,
115+
)
116+
except ModelProviderPolicyError as exc:
117+
# Treat unknown, blocked, and conflicting provider identities alike.
118+
raise HTTPException(status_code=404, detail="Model provider not found") from exc
119+
return flat_provider
120+
121+
81122
@dataclass(frozen=True)
82123
class _KbGuardResult:
83124
"""Outcome of ``_guard_kb_action`` used by routes to know the effective owner.
@@ -709,6 +750,7 @@ async def create_knowledge_base(
709750
) -> KnowledgeBaseInfo:
710751
"""Create a new knowledge base with embedding configuration."""
711752
try:
753+
embedding_provider = _require_create_embedding_provider(request, current_user)
712754
kb_root_path = KBStorageHelper.get_root_path()
713755
kb_user = current_user.username
714756
kb_name = request.name.strip().replace(" ", "_")
@@ -788,7 +830,7 @@ async def create_knowledge_base(
788830
backend_config_value = request.backend_config or {}
789831
embedding_metadata = {
790832
"id": str(kb_id),
791-
"embedding_provider": request.embedding_provider,
833+
"embedding_provider": embedding_provider,
792834
"embedding_model": request.embedding_model,
793835
"model_selection": request.model_selection,
794836
"created_at": datetime.now(timezone.utc).isoformat(),
@@ -827,7 +869,7 @@ async def create_knowledge_base(
827869
# when the request didn't carry one of its own.
828870
persisted_selection = request.model_selection or {
829871
"name": request.embedding_model,
830-
"provider": request.embedding_provider,
872+
"provider": embedding_provider,
831873
}
832874
await knowledge_base_service.create_record(
833875
user_id=current_user.id,
@@ -857,7 +899,7 @@ async def create_knowledge_base(
857899
id=str(kb_id),
858900
dir_name=kb_name,
859901
name=kb_name.replace("_", " "),
860-
embedding_provider=request.embedding_provider,
902+
embedding_provider=embedding_provider,
861903
embedding_model=request.embedding_model,
862904
size=0,
863905
words=0,

0 commit comments

Comments
 (0)