Skip to content

Commit b458aee

Browse files
committed
feat(models): add approved provider policy store
1 parent 021c16e commit b458aee

19 files changed

Lines changed: 968 additions & 2 deletions

File tree

src/backend/base/langflow/agentic/mcp/server.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,14 @@ async def _ensure_services() -> None:
6464
from langflow.services.utils import initialize_services
6565

6666
await initialize_services()
67+
from langflow.services.task.model_provider_policy_refresh import (
68+
model_provider_policy_refresh_worker,
69+
)
70+
71+
# Standalone stdio servers do not enter the FastAPI lifespan, but they
72+
# are long-lived model consumers and must converge after an admin
73+
# changes the install-wide provider ceiling.
74+
await model_provider_policy_refresh_worker.start()
6775
_services_initialized = True
6876

6977

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""Add the install-wide model-provider policy store.
2+
3+
Phase: EXPAND
4+
Revision ID: e8f1a2b3c4d5
5+
Revises: b7d5f9a3c2e4
6+
Create Date: 2026-07-29 00:00:00.000000
7+
8+
The singleton starts with an empty approved-provider list. Empty deliberately
9+
means unrestricted, preserving existing behavior until an administrator saves
10+
a narrower policy. Its monotonically increasing version lets every worker
11+
notice committed changes without relying on process-local invalidation.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
from typing import TYPE_CHECKING
17+
18+
import sqlalchemy as sa
19+
from alembic import op
20+
from langflow.utils import migration
21+
22+
if TYPE_CHECKING:
23+
from collections.abc import Sequence
24+
25+
revision: str = "e8f1a2b3c4d5" # pragma: allowlist secret
26+
down_revision: str | None = "b7d5f9a3c2e4" # pragma: allowlist secret
27+
branch_labels: str | Sequence[str] | None = None
28+
depends_on: str | Sequence[str] | None = None
29+
30+
TABLE_NAME = "model_provider_policy"
31+
SINGLETON_ID = 1
32+
REQUIRED_COLUMNS = frozenset({"id", "approved_provider_ids", "version"})
33+
34+
35+
def _policy_table() -> sa.TableClause:
36+
return sa.table(
37+
TABLE_NAME,
38+
sa.column("id", sa.Integer()),
39+
sa.column("approved_provider_ids", sa.JSON()),
40+
sa.column("version", sa.Integer()),
41+
)
42+
43+
44+
def _seed_singleton(conn: sa.Connection) -> None:
45+
table = _policy_table()
46+
exists = conn.execute(sa.select(table.c.id).where(table.c.id == SINGLETON_ID)).first()
47+
if exists is None:
48+
conn.execute(
49+
table.insert().values(
50+
id=SINGLETON_ID,
51+
approved_provider_ids=[],
52+
version=0,
53+
)
54+
)
55+
56+
57+
def upgrade() -> None:
58+
conn = op.get_bind()
59+
if migration.table_exists(TABLE_NAME, conn):
60+
missing_columns = {
61+
column for column in REQUIRED_COLUMNS if not migration.column_exists(TABLE_NAME, column, conn)
62+
}
63+
if missing_columns:
64+
missing = ", ".join(sorted(missing_columns))
65+
msg = f"Existing {TABLE_NAME!r} table is missing required columns: {missing}"
66+
raise RuntimeError(msg)
67+
_seed_singleton(conn)
68+
return
69+
70+
op.create_table(
71+
TABLE_NAME,
72+
sa.Column("id", sa.Integer(), nullable=False),
73+
sa.Column("approved_provider_ids", sa.JSON(), nullable=False),
74+
sa.Column("version", sa.Integer(), nullable=False),
75+
sa.CheckConstraint(f"id = {SINGLETON_ID}", name=op.f("ck_model_provider_policy_singleton")),
76+
sa.CheckConstraint("version >= 0", name=op.f("ck_model_provider_policy_version")),
77+
sa.PrimaryKeyConstraint("id", name=op.f("pk_model_provider_policy")),
78+
)
79+
_seed_singleton(conn)
80+
81+
82+
def downgrade() -> None:
83+
conn = op.get_bind()
84+
if not migration.table_exists(TABLE_NAME, conn):
85+
return
86+
op.drop_table(TABLE_NAME)

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
mcp_router,
2929
memories_router,
3030
model_options_router,
31+
model_provider_policy_router,
3132
models_router,
3233
monitor_router,
3334
openai_responses_router,
@@ -93,6 +94,7 @@ def include_deployment_router(target_router: APIRouter) -> None:
9394
router_v1.include_router(openai_responses_router)
9495
router_v1.include_router(models_router)
9596
router_v1.include_router(model_options_router)
97+
router_v1.include_router(model_provider_policy_router)
9698
router_v1.include_router(authz_shares_router)
9799
router_v1.include_router(authz_audit_router)
98100
router_v1.include_router(authz_roles_router)

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from langflow.api.v1.mcp_projects import router as mcp_projects_router
2121
from langflow.api.v1.memories import router as memories_router
2222
from langflow.api.v1.model_options import router as model_options_router
23+
from langflow.api.v1.model_provider_policy import router as model_provider_policy_router
2324
from langflow.api.v1.models import router as models_router
2425
from langflow.api.v1.monitor import router as monitor_router
2526
from langflow.api.v1.openai_responses import router as openai_responses_router
@@ -55,6 +56,7 @@
5556
"mcp_router",
5657
"memories_router",
5758
"model_options_router",
59+
"model_provider_policy_router",
5860
"models_router",
5961
"monitor_router",
6062
"openai_responses_router",
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"""Superuser administration for the install-wide approved-provider policy."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Annotated
6+
7+
from fastapi import APIRouter, Depends
8+
from lfx.base.models.provider_registry import get_registry_snapshot
9+
from pydantic import BaseModel, Field, StringConstraints, field_validator
10+
11+
from langflow.api.utils import DbSession, DbSessionReadOnly
12+
from langflow.services.auth.utils import get_current_active_superuser
13+
from langflow.services.database.models.user.model import User
14+
from langflow.services.model_provider_policy import (
15+
apply_model_provider_policy_state,
16+
get_model_provider_policy_state,
17+
replace_model_provider_policy_state,
18+
)
19+
20+
router = APIRouter(prefix="/model-provider-policy", tags=["Model Provider Policy"])
21+
22+
ProviderId = Annotated[str, StringConstraints(pattern=r"^[a-z0-9][a-z0-9._-]*$", max_length=255)]
23+
24+
25+
class RegisteredModelProviderRead(BaseModel):
26+
"""Stable provider identity exposed to the administrative picker."""
27+
28+
provider_id: str
29+
display_name: str
30+
provider: str
31+
32+
33+
class ModelProviderPolicyWrite(BaseModel):
34+
"""Complete replacement for the install-wide approved-provider set."""
35+
36+
approved_provider_ids: Annotated[list[ProviderId], Field(max_length=1000)]
37+
38+
@field_validator("approved_provider_ids")
39+
@classmethod
40+
def deduplicate_provider_ids(cls, provider_ids: list[str]) -> list[str]:
41+
return sorted(set(provider_ids))
42+
43+
44+
class ModelProviderPolicyRead(BaseModel):
45+
"""Current deployment ceiling plus every provider registered in this process."""
46+
47+
approved_provider_ids: list[str]
48+
registered_providers: list[RegisteredModelProviderRead]
49+
50+
51+
def _build_policy_response(approved_provider_ids: set[str] | frozenset[str]) -> ModelProviderPolicyRead:
52+
snapshot = get_registry_snapshot()
53+
registered_providers = [
54+
RegisteredModelProviderRead(
55+
provider_id=provider_id,
56+
display_name=descriptor.display_name or descriptor.name,
57+
provider=descriptor.name,
58+
)
59+
for provider_id, descriptor in snapshot.descriptors_by_id.items()
60+
]
61+
registered_providers.sort(key=lambda descriptor: (descriptor.display_name.casefold(), descriptor.provider_id))
62+
return ModelProviderPolicyRead(
63+
approved_provider_ids=sorted(approved_provider_ids),
64+
registered_providers=registered_providers,
65+
)
66+
67+
68+
@router.get("", response_model=ModelProviderPolicyRead)
69+
@router.get("/", response_model=ModelProviderPolicyRead)
70+
async def read_model_provider_policy(
71+
_admin: Annotated[User, Depends(get_current_active_superuser)],
72+
session: DbSessionReadOnly,
73+
) -> ModelProviderPolicyRead:
74+
"""Read the global provider policy. An empty approved list is unrestricted."""
75+
state = await get_model_provider_policy_state(session)
76+
return _build_policy_response(state.approved_provider_ids)
77+
78+
79+
@router.post("", response_model=ModelProviderPolicyRead)
80+
@router.post("/", response_model=ModelProviderPolicyRead)
81+
@router.put("", response_model=ModelProviderPolicyRead)
82+
@router.put("/", response_model=ModelProviderPolicyRead)
83+
async def replace_model_provider_policy(
84+
payload: ModelProviderPolicyWrite,
85+
_admin: Annotated[User, Depends(get_current_active_superuser)],
86+
session: DbSession,
87+
) -> ModelProviderPolicyRead:
88+
"""Atomically replace the global provider policy and invalidate snapshots."""
89+
state = await replace_model_provider_policy_state(session, payload.approved_provider_ids)
90+
91+
# Never publish uncommitted policy to the runtime. A failed commit leaves
92+
# the previous in-memory ceiling and cached decisions intact.
93+
apply_model_provider_policy_state(state)
94+
return _build_policy_response(state.approved_provider_ids)
95+
96+
97+
__all__ = [
98+
"ModelProviderPolicyRead",
99+
"ModelProviderPolicyWrite",
100+
"RegisteredModelProviderRead",
101+
"router",
102+
]

src/backend/base/langflow/main.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,16 @@ async def lifespan(_app: FastAPI):
247247
except Exception as exc: # noqa: BLE001 — never block startup on cleanup scheduling
248248
await logger.awarning(f"Failed to start authz audit-log cleanup worker: {exc}")
249249

250+
# Keep the default OSS provider ceiling coherent across backend
251+
# worker processes after an administrator commits a replacement.
252+
# This worker is part of policy enforcement, so a scheduling failure
253+
# must fail startup rather than leave a worker stale indefinitely.
254+
from langflow.services.task.model_provider_policy_refresh import (
255+
model_provider_policy_refresh_worker,
256+
)
257+
258+
await model_provider_policy_refresh_worker.start()
259+
250260
current_time = asyncio.get_event_loop().time()
251261
await logger.adebug("Setting up LLM caching")
252262
setup_llm_caching()
@@ -621,6 +631,14 @@ async def refresh_models_dev_periodically() -> None:
621631
await audit_log_cleanup_worker.stop()
622632
except Exception as e: # noqa: BLE001
623633
await logger.aerror(f"Failed to stop authz audit-log cleanup worker: {e}")
634+
try:
635+
from langflow.services.task.model_provider_policy_refresh import (
636+
model_provider_policy_refresh_worker,
637+
)
638+
639+
await model_provider_policy_refresh_worker.stop()
640+
except Exception as e: # noqa: BLE001
641+
await logger.aerror(f"Failed to stop model-provider policy refresh worker: {e}")
624642

625643
# Cancel background tasks
626644
tasks_to_cancel = []

src/backend/base/langflow/services/database/models/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from .mcp_server import MCPServer
2626
from .memory_base import MemoryBase, MemoryBaseSession, MemoryBaseWorkflowRun, MessageIngestionRecord
2727
from .message import MessageTable
28+
from .model_provider_policy import ModelProviderPolicy
2829
from .traces.model import SpanTable, TraceTable
2930
from .transactions import TransactionTable
3031
from .user import User
@@ -63,6 +64,7 @@
6364
"MemoryBaseWorkflowRun",
6465
"MessageIngestionRecord",
6566
"MessageTable",
67+
"ModelProviderPolicy",
6668
"SSOConfig",
6769
"SSOUserProfile",
6870
"SignalType",
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .model import MODEL_PROVIDER_POLICY_SINGLETON_ID, ModelProviderPolicy
2+
3+
__all__ = ["MODEL_PROVIDER_POLICY_SINGLETON_ID", "ModelProviderPolicy"]
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import sqlalchemy as sa
2+
from sqlmodel import Field, SQLModel
3+
4+
MODEL_PROVIDER_POLICY_SINGLETON_ID = 1
5+
6+
7+
class ModelProviderPolicy(SQLModel, table=True): # type: ignore[call-arg]
8+
"""Versioned singleton containing the install-wide provider ceiling."""
9+
10+
__tablename__ = "model_provider_policy"
11+
__table_args__ = (
12+
sa.CheckConstraint(
13+
f"id = {MODEL_PROVIDER_POLICY_SINGLETON_ID}",
14+
name="ck_model_provider_policy_singleton",
15+
),
16+
sa.CheckConstraint("version >= 0", name="ck_model_provider_policy_version"),
17+
)
18+
19+
id: int = Field(default=MODEL_PROVIDER_POLICY_SINGLETON_ID, primary_key=True)
20+
approved_provider_ids: list[str] = Field(default_factory=list, sa_column=sa.Column(sa.JSON, nullable=False))
21+
version: int = Field(default=0, nullable=False, ge=0)

0 commit comments

Comments
 (0)