diff --git a/.secrets.baseline b/.secrets.baseline index 9a34c46c9cb0..34a0ce4f06ad 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -1263,7 +1263,7 @@ "filename": "src/backend/tests/unit/agentic/services/test_assistant_service_run_intent.py", "hashed_secret": "e9a5f12a8ecbb3eb46eca5096b5c52aa5e7c9fdd", "is_verified": false, - "line_number": 369, + "line_number": 386, "is_secret": false } ], @@ -1337,7 +1337,7 @@ "filename": "src/backend/tests/unit/agentic/services/test_provider_service.py", "hashed_secret": "c92b9809dacd9240dc85e86da6388e9b1bfc8a7d", "is_verified": false, - "line_number": 118, + "line_number": 126, "is_secret": false } ], diff --git a/src/backend/base/langflow/agentic/flows/flow_builder_assistant.py b/src/backend/base/langflow/agentic/flows/flow_builder_assistant.py index 031196a31a55..374e84b64b81 100644 --- a/src/backend/base/langflow/agentic/flows/flow_builder_assistant.py +++ b/src/backend/base/langflow/agentic/flows/flow_builder_assistant.py @@ -323,13 +323,16 @@ running — otherwise the run fails with "No model selected". Pick the model in this STRICT priority order: -1. **The model the user EXPLICITLY named WINS — always.** If the user asked +1. **The model the user EXPLICITLY named WINS unless a runtime + `[Model provider policy ...]` notice says its provider is unavailable.** + If the user asked for a specific model ("use GPT-5.4", "use the OpenAI 5.4 model", "switch to claude-sonnet-4-5", "troque para gemini-2.5-pro"), use THAT model — never substitute a different version or the `preferred` model for it, even if the - requested model is not in the `[Available language models ...]` block and - even if a "preferred" model is offered. (e.g. user said "5.4" / "gpt-5.4" - and preferred is "gpt-5.5" → you MUST set the 5.4 model, NOT gpt-5.5.) + exact requested model is not listed and even if a "preferred" model is + offered. (e.g. user said "5.4" / "gpt-5.4" and preferred is "gpt-5.5" + → you MUST set the 5.4 model, NOT gpt-5.5.) If a policy notice omits or + rejects the provider, do not discover, configure, or run it. BUT set the **canonical model id** — the EXACT id as it appears in the provider catalog / `describe_component` / the `[Available language models]` block, NOT the user's loose wording. Provider model ids are CASE-SENSITIVE @@ -343,19 +346,18 @@ `preferred`; if there is none, pick ANY provider from "providers with credentials configured" (provider-agnostic — do NOT assume OpenAI; use whatever the user actually has keys for, e.g. Anthropic, Google, Groq). -3. Only if no such block is present at all may you fall back to - `provider="OpenAI", name="gpt-4o-mini"`. +3. Only if neither an Available-language-model block nor a restrictive + Model-provider-policy notice is present may you preserve the historical + fallback `provider="OpenAI", name="gpt-4o-mini"`. `configure_component(component_id="Agent-...", params='{"model": [{"provider": "", "name": ""}]}')`. Never run a flow whose Agent has no model. NEVER claim in your reply that you used a model different from the one you actually set on the canvas — report the EXACT model you configured. -Common providers and example model names: -- `OpenAI` — `gpt-4o`, `gpt-4o-mini`, `gpt-5`, `o1-mini` -- `Anthropic` — `claude-sonnet-4-5-20250929`, `claude-haiku-4-5` -- `Google Generative AI` — `gemini-2.5-flash`, `gemini-2.5-pro` -- `Groq`, `Azure OpenAI`, `Ollama`, `IBM WatsonX` +Provider and model names are deployment-specific. Treat the runtime +`[Available language models ...]` block as the authority; never infer that a +provider is available from these instructions or from an example below. Add a SEPARATE model component (OpenAIModel etc.) only when the user EXPLICITLY says "add an OpenAIModel component" / "create a model node" — never diff --git a/src/backend/base/langflow/agentic/services/agent_run_context.py b/src/backend/base/langflow/agentic/services/agent_run_context.py index deac85e7fd0e..332a1a20e1dc 100644 --- a/src/backend/base/langflow/agentic/services/agent_run_context.py +++ b/src/backend/base/langflow/agentic/services/agent_run_context.py @@ -18,20 +18,40 @@ class AgentRunModel(TypedDict): provider: str | None model_name: str | None api_key_var: str | None + allow_configuration: bool + + +class RequestedAgentModel(TypedDict): + provider: str + model_name: str + api_key_var: str | None _model_var: contextvars.ContextVar[AgentRunModel | None] = contextvars.ContextVar("agentic_run_model", default=None) -_requested_model_var: contextvars.ContextVar[AgentRunModel | None] = contextvars.ContextVar( +_requested_model_var: contextvars.ContextVar[RequestedAgentModel | None] = contextvars.ContextVar( "agentic_requested_model", default=None ) _iterations_var: contextvars.ContextVar[int | None] = contextvars.ContextVar("agentic_run_iterations", default=None) -def set_agent_run_model(provider: str | None, model_name: str | None, api_key_var: str | None) -> None: - """Bind the request's provider/model/api-key to the current context.""" - _model_var.set({"provider": provider, "model_name": model_name, "api_key_var": api_key_var}) +def set_agent_run_model( + provider: str | None, + model_name: str | None, + api_key_var: str | None, + *, + allow_configuration: bool = True, +) -> None: + """Bind the request model and whether it may be injected into the canvas.""" + _model_var.set( + { + "provider": provider, + "model_name": model_name, + "api_key_var": api_key_var, + "allow_configuration": allow_configuration, + } + ) def current_agent_run_model() -> AgentRunModel | None: @@ -83,7 +103,7 @@ def set_requested_agent_model(provider: str | None, model_name: str | None, api_ _requested_model_var.set(None) -def current_requested_agent_model() -> AgentRunModel | None: +def current_requested_agent_model() -> RequestedAgentModel | None: """Return the user's explicitly-requested model (or ``None`` when unset).""" return _requested_model_var.get() diff --git a/src/backend/base/langflow/agentic/services/assistant_service.py b/src/backend/base/langflow/agentic/services/assistant_service.py index 14d9eea6d514..46f6af6f04d8 100644 --- a/src/backend/base/langflow/agentic/services/assistant_service.py +++ b/src/backend/base/langflow/agentic/services/assistant_service.py @@ -12,6 +12,7 @@ from fastapi import HTTPException from lfx.base.models.model_remediation import cached_overrides, find_remediation, remember, restore_overrides +from lfx.base.models.provider_registry import get_registry_snapshot from lfx.graph.flow_builder.flow import flow_to_spec_summary from lfx.log.logger import logger from lfx.mcp.flow_builder_tools import ( @@ -23,6 +24,7 @@ set_propose_existing_edits, ) from lfx.mcp.tool_cache import reset_tool_cache +from lfx.services.model_provider_policy import ModelProviderPolicyPurpose, aresolve_model_provider_policy from langflow.agentic.helpers.code_extraction import extract_component_code, extract_flow_json from langflow.agentic.helpers.code_security import scan_code_security @@ -718,29 +720,79 @@ def _complete(data: dict) -> str: f"{current_input}" ) - # Tell the agent which language model(s) it can safely put on any Agent - # it builds — building an Agent without a model makes the run fail with - # "No model selected". The PREFERRED one is the model the assistant + # Tell the agent which language model(s) it can safely put on any Agent it + # builds — building an Agent without a model makes the run fail with + # "No model selected". + # Resolve one CONFIGURE snapshot before exposing names or binding the + # classifier's requested model; this keeps the assistant from reintroducing + # a provider hidden by governance through prompt context or deterministic + # run-model injection. + # The PREFERRED one is the model the assistant # itself runs with (key guaranteed). We also list every provider whose # API key is configured (provider-agnostic, detected from the env-built # global variables — NO OpenAI bias). Omitted (input byte-identical to # before) only when neither is available. from langflow.agentic.services.flow_preparation import available_model_providers + _available_provider_names = available_model_providers(global_variables) + _registered_provider_names = sorted( + descriptor.name for descriptor in get_registry_snapshot().descriptors_by_id.values() + ) + _policy_candidates = list( + dict.fromkeys( + [ + *_registered_provider_names, + *_available_provider_names, + *([provider] if provider else []), + *([intent_result.requested_provider] if intent_result.requested_provider else []), + ] + ) + ) + try: + _provider_policy = await aresolve_model_provider_policy( + user_id=user_id, + providers=_policy_candidates, + purpose=ModelProviderPolicyPurpose.CONFIGURE, + ) + except BaseException: + # The current canvas was already seeded above, but the main request + # try/finally has not started yet. A fail-closed policy error or + # cancellation must not leak that ContextVar state to the next request. + reset_working_flow() + raise + _requested_provider = intent_result.requested_provider + _requested_provider_allowed = not _requested_provider or _provider_policy.allows(_requested_provider) + _allowed_configuration_providers = _provider_policy.filter(_registered_provider_names) + _catalog_is_restricted = any(not _provider_policy.allows(name) for name in _policy_candidates) + _model_parts: list[str] = [] - if provider and model_name: + if provider and model_name and _provider_policy.allows(provider): _model_parts.append(f"preferred: provider={provider!r}, name={model_name!r}") - _avail = available_model_providers(global_variables) + _avail = _provider_policy.filter(_available_provider_names) if _avail: _model_parts.append("providers with credentials configured: " + ", ".join(_avail)) if _model_parts: current_input = ( f"[Available language models — these are a DEFAULT only. If the user explicitly named a " - f"model, set EXACTLY that model (verbatim) and IGNORE this block. ONLY when the user did " - f"NOT name a model, configure an Agent's `model` field with the one marked `preferred` " - f"(else any listed provider) so the flow can run: " + f"model and no Model provider policy notice rejects it, set EXACTLY that model and IGNORE " + f"this block. ONLY when the user did NOT name a model, configure an Agent's `model` field " + f"with the one marked `preferred` (else any listed provider) so the flow can run: " f"{'; '.join(_model_parts)}]\n\n{current_input}" ) + if _catalog_is_restricted or not _requested_provider_allowed: + allowed_notice = ( + "Configure only these providers: " + ", ".join(_allowed_configuration_providers) + ". " + if _allowed_configuration_providers + else "No model providers are available for configuration. " + ) + requested_notice = ( + "The explicitly requested provider is unavailable. " if not _requested_provider_allowed else "" + ) + current_input = ( + f"[Model provider policy: {requested_notice}{allowed_notice}" + "Do not discover, configure, or run any other provider.]\n\n" + f"{current_input}" + ) # Headless callers (MCP) have no review UI, so steer the agent away from a # "proposed/pending approval" narration the user can never act on (#13641). @@ -829,16 +881,22 @@ async def check_cancelled() -> bool: set_current_user_id(user_id) # The generate_component tool re-runs the component-gen LLM flow # mid-loop and needs the same provider/model the request used. - set_agent_run_model(provider, model_name, api_key_var) + set_agent_run_model( + provider, + model_name, + api_key_var, + allow_configuration=bool(provider and _provider_policy.allows(provider)), + ) set_agent_run_iterations(_iterations_from_globals(global_variables)) # If the user EXPLICITLY named a model (e.g. "use the OpenAI gpt-5.4 # model"), bind it so the run-time injector ENFORCES it on the Agent — # the canvas must show exactly what the user asked for, never the # assistant's own runtime model. Same-provider runs reuse the verified # api_key_var; a different provider falls back to its default var. - _req_provider = intent_result.requested_provider + _req_provider = intent_result.requested_provider if _requested_provider_allowed else None _req_api_key_var = api_key_var if (_req_provider and provider and _req_provider == provider) else None - set_requested_agent_model(_req_provider, intent_result.requested_model, _req_api_key_var) + _req_model = intent_result.requested_model if _req_provider else None + set_requested_agent_model(_req_provider, _req_model, _req_api_key_var) # max_retries=0 means 1 attempt (no retries), matching non-streaming semantics total_attempts = max_retries + 1 diff --git a/src/backend/base/langflow/agentic/services/provider_service.py b/src/backend/base/langflow/agentic/services/provider_service.py index de39858f04c7..2121b263f53c 100644 --- a/src/backend/base/langflow/agentic/services/provider_service.py +++ b/src/backend/base/langflow/agentic/services/provider_service.py @@ -6,10 +6,9 @@ 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.provider_registry import get_registry_snapshot, 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, ) @@ -45,6 +44,11 @@ """ +def _get_registered_provider_names() -> list[str]: + """Return provider names without executing extension catalog loaders.""" + return sorted(descriptor.name for descriptor in get_registry_snapshot().descriptors_by_id.values()) + + async def get_enabled_providers_for_user( user_id: UUID | str, session: AsyncSession, @@ -64,7 +68,7 @@ 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() + registered_providers = _get_registered_provider_names() provider_candidates = [ *provider_variable_map, *( @@ -76,7 +80,7 @@ async def get_enabled_providers_for_user( provider_policy = resolve_model_provider_policy( user_id=user_id, providers=[*registered_providers, *provider_candidates], - purpose=ModelProviderPolicyPurpose.USE, + purpose=ModelProviderPolicyPurpose.CONFIGURE, ) enabled_providers = [] diff --git a/src/backend/tests/unit/agentic/services/test_assistant_service_run_intent.py b/src/backend/tests/unit/agentic/services/test_assistant_service_run_intent.py index 8b2f9d8f7cf3..90edf97e55e5 100644 --- a/src/backend/tests/unit/agentic/services/test_assistant_service_run_intent.py +++ b/src/backend/tests/unit/agentic/services/test_assistant_service_run_intent.py @@ -18,6 +18,23 @@ from langflow.agentic.services.flow_types import IntentResult MODULE = "langflow.agentic.services.assistant_service" +_DENIED_ASSISTANT_CATALOG_CALLS: list[None] = [] + + +def _denied_assistant_catalog_loader(): + _DENIED_ASSISTANT_CATALOG_CALLS.append(None) + return [{"name": "blocked-model", "model_type": "llm"}] + + +class _AssistantPolicy: + def __init__(self, allowed: set[str] | None) -> None: + self.allowed = allowed + + def allows(self, provider: str) -> bool: + return self.allowed is None or provider in self.allowed + + def filter(self, providers: list[str]) -> list[str]: + return [provider for provider in providers if self.allows(provider)] def _intent(intent: str) -> IntentResult: @@ -391,6 +408,200 @@ def factory(**kw): assert "Available language models" not in seen[0] +@pytest.mark.asyncio +async def test_assistant_filters_and_does_not_bind_policy_blocked_requested_provider(): + from langflow.agentic.services.agent_run_context import current_agent_run_model, current_requested_agent_model + from lfx.services.model_provider_policy import ModelProviderPolicyPurpose + + seen: dict = {} + captured_policy: dict = {} + intent = IntentResult( + intent="build_flow", + translation="build an agent with Anthropic", + requested_model="claude-sonnet-4-5", + requested_provider="Anthropic", + ) + + async def _resolve_policy(**kwargs): + captured_policy.update(kwargs) + return _AssistantPolicy({"OpenAI"}) + + def factory(**kwargs): + seen["input"] = kwargs["input_value"] + seen["requested"] = current_requested_agent_model() + seen["runtime"] = current_agent_run_model() + return _gen([("end", {"result": "ok"})]) + + with ( + patch(f"{MODULE}.classify_intent", new_callable=AsyncMock, return_value=intent), + patch(f"{MODULE}.aresolve_model_provider_policy", side_effect=_resolve_policy), + patch(f"{MODULE}.execute_flow_file_streaming", side_effect=factory), + patch(f"{MODULE}.drain_flow_events", side_effect=[[{"action": "set_flow", "flow": {}}], [], []]), + patch("asyncio.sleep", new_callable=AsyncMock), + ): + await _collect( + execute_flow_with_validation_streaming( + flow_filename="TestFlow", + input_value="build an agent with Anthropic", + global_variables={ + "OPENAI_API_KEY": "openai-test", # pragma: allowlist secret + "ANTHROPIC_API_KEY": "anthropic-test", # pragma: allowlist secret + }, + user_id="user-1", + provider="Anthropic", + model_name="claude-sonnet-4-5", + max_retries=1, + ) + ) + + assert captured_policy["purpose"] is ModelProviderPolicyPurpose.CONFIGURE + assert "providers with credentials configured: OpenAI" in seen["input"] + assert "providers with credentials configured: OpenAI, Anthropic" not in seen["input"] + assert "explicitly requested provider is unavailable" in seen["input"] + assert seen["requested"] is None + assert seen["runtime"] == { + "provider": "Anthropic", + "model_name": "claude-sonnet-4-5", + "api_key_var": None, + "allow_configuration": False, + } + + +@pytest.mark.asyncio +async def test_assistant_allow_all_policy_preserves_requested_provider_binding(): + from langflow.agentic.services.agent_run_context import current_requested_agent_model + + seen: dict = {} + intent = IntentResult( + intent="build_flow", + translation="build an agent with Anthropic", + requested_model="claude-sonnet-4-5", + requested_provider="Anthropic", + ) + + async def _resolve_policy(**_kwargs): + return _AssistantPolicy(None) + + def factory(**kwargs): + seen["input"] = kwargs["input_value"] + seen["requested"] = current_requested_agent_model() + return _gen([("end", {"result": "ok"})]) + + with ( + patch(f"{MODULE}.classify_intent", new_callable=AsyncMock, return_value=intent), + patch(f"{MODULE}.aresolve_model_provider_policy", side_effect=_resolve_policy), + patch(f"{MODULE}.execute_flow_file_streaming", side_effect=factory), + patch(f"{MODULE}.drain_flow_events", side_effect=[[{"action": "set_flow", "flow": {}}], [], []]), + patch("asyncio.sleep", new_callable=AsyncMock), + ): + await _collect( + execute_flow_with_validation_streaming( + flow_filename="TestFlow", + input_value="build an agent with Anthropic", + global_variables={ + "OPENAI_API_KEY": "openai-test", # pragma: allowlist secret + "ANTHROPIC_API_KEY": "anthropic-test", # pragma: allowlist secret + }, + user_id="user-1", + provider="OpenAI", + model_name="gpt-4o-mini", + max_retries=1, + ) + ) + + assert "providers with credentials configured: OpenAI, Anthropic" in seen["input"] + assert "explicitly requested provider is unavailable" not in seen["input"] + assert "[Model provider policy:" not in seen["input"] + assert seen["requested"] == { + "provider": "Anthropic", + "model_name": "claude-sonnet-4-5", + "api_key_var": None, + } + + +@pytest.mark.asyncio +async def test_assistant_policy_resolution_does_not_execute_extension_catalog(): + from lfx.base.models.provider_registry import ProviderSpec, register_provider, unregister_provider + + provider = "Denied Assistant Extension" + register_provider( + ProviderSpec( + name=provider, + provider_id="denied-assistant-extension", + metadata={ + "icon": "Bot", + "variables": [], + "mapping": {"model_class": "ChatOpenAI", "model_param": "model"}, + }, + catalog_loader=f"{__name__}:_denied_assistant_catalog_loader", + ) + ) + _DENIED_ASSISTANT_CATALOG_CALLS.clear() + + async def _resolve_policy(**_kwargs): + return _AssistantPolicy({"OpenAI"}) + + try: + with ( + patch(f"{MODULE}.classify_intent", new_callable=AsyncMock, return_value=_intent("build_flow")), + patch(f"{MODULE}.aresolve_model_provider_policy", side_effect=_resolve_policy), + patch( + f"{MODULE}.execute_flow_file_streaming", + return_value=_gen([("end", {"result": "ok"})]), + ), + patch(f"{MODULE}.drain_flow_events", side_effect=[[{"action": "set_flow", "flow": {}}], [], []]), + patch("asyncio.sleep", new_callable=AsyncMock), + ): + await _collect( + execute_flow_with_validation_streaming( + flow_filename="TestFlow", + input_value="build an agent", + global_variables={"OPENAI_API_KEY": "openai-test"}, # pragma: allowlist secret + user_id="user-1", + provider="OpenAI", + model_name="gpt-4o-mini", + max_retries=1, + ) + ) + finally: + unregister_provider(provider) + + assert _DENIED_ASSISTANT_CATALOG_CALLS == [] + + +@pytest.mark.asyncio +async def test_assistant_resets_working_flow_when_policy_resolution_fails(): + from lfx.mcp.flow_builder_tools import get_working_flow, init_working_flow, reset_working_flow + + async def _seed_working_flow(*_args, **_kwargs): + init_working_flow({"name": "Seeded", "data": {"nodes": [], "edges": []}}, "flow-1") + return "seeded canvas" + + try: + with ( + patch(f"{MODULE}._get_current_flow_summary", side_effect=_seed_working_flow), + patch(f"{MODULE}.classify_intent", new_callable=AsyncMock, return_value=_intent("build_flow")), + patch( + f"{MODULE}.aresolve_model_provider_policy", + new_callable=AsyncMock, + side_effect=RuntimeError("denied"), + ), + pytest.raises(RuntimeError, match="denied"), + ): + await _collect( + execute_flow_with_validation_streaming( + flow_filename="TestFlow", + input_value="build an agent", + global_variables={}, + user_id="user-1", + ) + ) + + assert get_working_flow() is None + finally: + reset_working_flow() + + @pytest.mark.asyncio async def test_build_flow_truly_no_action_still_guarded_regression(): """Regression: a real build that did NOTHING (no flow updates, no run) must still be guarded.""" diff --git a/src/backend/tests/unit/agentic/services/test_provider_service.py b/src/backend/tests/unit/agentic/services/test_provider_service.py index fc2d14d30610..043bdd54c511 100644 --- a/src/backend/tests/unit/agentic/services/test_provider_service.py +++ b/src/backend/tests/unit/agentic/services/test_provider_service.py @@ -15,6 +15,14 @@ get_default_model, get_enabled_providers_for_user, ) +from lfx.services.model_provider_policy import ModelProviderPolicyPurpose + +_DENIED_PROVIDER_SERVICE_CATALOG_CALLS: list[None] = [] + + +def _denied_provider_service_catalog_loader(): + _DENIED_PROVIDER_SERVICE_CATALOG_CALLS.append(None) + return [{"name": "blocked-model", "model_type": "llm"}] class TestPreferredProviders: @@ -229,7 +237,7 @@ async def test_should_return_enabled_providers_with_credentials(self): return_value={"Anthropic": "ANTHROPIC_API_KEY", "OpenAI": "OPENAI_API_KEY"}, ), patch( - "langflow.agentic.services.provider_service.get_model_providers", + "langflow.agentic.services.provider_service._get_registered_provider_names", return_value=["Anthropic", "OpenAI"], ), patch("langflow.agentic.services.provider_service.os.getenv", return_value=None), @@ -265,7 +273,7 @@ async def test_should_return_all_providers_when_all_have_credentials(self): return_value={"Anthropic": "ANTHROPIC_API_KEY", "OpenAI": "OPENAI_API_KEY"}, ), patch( - "langflow.agentic.services.provider_service.get_model_providers", + "langflow.agentic.services.provider_service._get_registered_provider_names", return_value=["Anthropic", "OpenAI"], ), ): @@ -314,19 +322,23 @@ async def test_policy_hidden_provider_is_absent_from_enabled_and_status(self): return_value={"OpenAI": "OPENAI_API_KEY", "Anthropic": "ANTHROPIC_API_KEY"}, ), patch( - "langflow.agentic.services.provider_service.get_model_providers", + "langflow.agentic.services.provider_service._get_registered_provider_names", return_value=["OpenAI", "Anthropic"], ), patch( "langflow.agentic.services.provider_service.get_provider_required_variable_keys", side_effect=lambda provider: [f"{provider.upper()}_API_KEY"], ), - patch("langflow.agentic.services.provider_service.resolve_model_provider_policy", return_value=policy), + patch( + "langflow.agentic.services.provider_service.resolve_model_provider_policy", + return_value=policy, + ) as resolve_policy, ): enabled, provider_status = await get_enabled_providers_for_user("user-1", MagicMock()) assert enabled == ["OpenAI"] assert provider_status == {"OpenAI": True} + assert resolve_policy.call_args.kwargs["purpose"] is ModelProviderPolicyPurpose.CONFIGURE @pytest.mark.asyncio async def test_credentialless_extension_provider_is_enabled(self): @@ -341,7 +353,7 @@ async def test_credentialless_extension_provider_is_enabled(self): patch("langflow.agentic.services.provider_service.get_variable_service", return_value=mock_db_service), patch("langflow.agentic.services.provider_service.get_model_provider_variable_mapping", return_value={}), patch( - "langflow.agentic.services.provider_service.get_model_providers", + "langflow.agentic.services.provider_service._get_registered_provider_names", return_value=["AmbientAuthCo"], ), patch("langflow.agentic.services.provider_service.is_api_key_optional", return_value=True), @@ -356,6 +368,48 @@ async def test_credentialless_extension_provider_is_enabled(self): assert enabled == ["AmbientAuthCo"] assert provider_status == {"AmbientAuthCo": True} + @pytest.mark.asyncio + async def test_blocked_extension_catalog_loader_is_not_executed(self): + from langflow.services.variable.service import DatabaseVariableService + from lfx.base.models.provider_registry import ProviderSpec, register_provider, unregister_provider + + provider = "Denied Provider Service Extension" + register_provider( + ProviderSpec( + name=provider, + provider_id="denied-provider-service-extension", + metadata={ + "icon": "Bot", + "variables": [], + "mapping": {"model_class": "ChatOpenAI", "model_param": "model"}, + }, + api_key_required=False, + catalog_loader=f"{__name__}:_denied_provider_service_catalog_loader", + ) + ) + _DENIED_PROVIDER_SERVICE_CATALOG_CALLS.clear() + mock_db_service = MagicMock(spec=DatabaseVariableService) + mock_db_service.get_all = AsyncMock(return_value=[]) + policy = MagicMock() + policy.allows.side_effect = lambda candidate: candidate == "OpenAI" + + try: + with ( + patch("langflow.agentic.services.provider_service.get_variable_service", return_value=mock_db_service), + patch( + "langflow.agentic.services.provider_service.resolve_model_provider_policy", + return_value=policy, + ), + patch("langflow.agentic.services.provider_service.os.getenv", return_value=None), + ): + enabled, provider_status = await get_enabled_providers_for_user("user-1", MagicMock()) + finally: + unregister_provider(provider) + + assert provider not in enabled + assert provider not in provider_status + assert _DENIED_PROVIDER_SERVICE_CATALOG_CALLS == [] + class TestBugsAndEdgeCases: """Tests that challenge the code — exposing real bugs and untested edge cases.""" @@ -452,7 +506,7 @@ async def test_get_enabled_providers_with_no_required_keys(self): return_value={"NoKeysProvider": None, "Anthropic": "ANTHROPIC_API_KEY"}, ), patch( - "langflow.agentic.services.provider_service.get_model_providers", + "langflow.agentic.services.provider_service._get_registered_provider_names", return_value=["NoKeysProvider", "Anthropic"], ), patch( diff --git a/src/backend/tests/unit/agentic/services/test_run_flow_tool.py b/src/backend/tests/unit/agentic/services/test_run_flow_tool.py index b09e592f7fc8..679665210837 100644 --- a/src/backend/tests/unit/agentic/services/test_run_flow_tool.py +++ b/src/backend/tests/unit/agentic/services/test_run_flow_tool.py @@ -206,6 +206,24 @@ def test_runs_normally_when_no_verified_model_is_bound(self): m.assert_awaited_once() assert data.data["result"] == "ok" + def test_does_not_inject_runtime_model_when_configuration_is_disallowed(self): + from langflow.agentic.services.agent_run_context import set_agent_run_model + + set_agent_run_model( + "Anthropic", + "claude-sonnet-4-5", + "ANTHROPIC_API_KEY", + allow_configuration=False, + ) + init_working_flow(self._agent_flow(), "flow-1") + + with patch(RWF, new_callable=AsyncMock, return_value={"result": "ok"}) as m: + _run(RunFlow()) + + flow_data = m.await_args.kwargs["flow_data"] + model_value = flow_data["data"]["nodes"][0]["data"]["node"]["template"]["model"]["value"] + assert model_value == "" + class TestRunFlowEnforcesRequestedModel: """A model the USER explicitly named must win on the canvas. diff --git a/src/backend/tests/unit/api/v1/test_models_provider_policy.py b/src/backend/tests/unit/api/v1/test_models_provider_policy.py index e5969ce9684e..7b5f99b8c77e 100644 --- a/src/backend/tests/unit/api/v1/test_models_provider_policy.py +++ b/src/backend/tests/unit/api/v1/test_models_provider_policy.py @@ -5,6 +5,7 @@ from types import SimpleNamespace from typing import TYPE_CHECKING from unittest.mock import AsyncMock +from uuid import uuid4 import pytest from fastapi import status @@ -147,6 +148,7 @@ def _provider_validation(*_args, **_kwargs): provider_validation_called = True monkeypatch.setattr("langflow.api.v1.variable.validate_model_provider_key", _provider_validation) + monkeypatch.setattr("lfx.base.models.unified_models.validate_model_provider_key", _provider_validation) validate_response = await client.post( "api/v1/models/validate-provider", headers=logged_in_headers, @@ -220,6 +222,48 @@ def _provider_validation(*_args, **_kwargs): assert provider_validation_called is False +@pytest.mark.usefixtures("active_user") +async def test_credential_rename_cannot_bypass_provider_policy( + client: AsyncClient, + logged_in_headers, + monkeypatch, +): + validation_called = False + + def _provider_validation(*_args, **_kwargs): + nonlocal validation_called + validation_called = True + + created = await client.post( + "api/v1/variables/", + headers=logged_in_headers, + json={ + "name": f"POLICY_TEST_{uuid4().hex}", + "value": "generic", + "type": "Generic", + "default_fields": [], + }, + ) + assert created.status_code == status.HTTP_201_CREATED + + monkeypatch.setattr("langflow.api.v1.variable.validate_model_provider_key", _provider_validation) + variable_id = created.json()["id"] + response = await client.patch( + f"api/v1/variables/{variable_id}", + headers=logged_in_headers, + json={ + "id": variable_id, + "name": "ANTHROPIC_API_KEY", + "value": "test", # pragma: allowlist secret + "type": "Credential", + "default_fields": [], + }, + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert validation_called is False + + @pytest.mark.usefixtures("active_user") async def test_dynamic_model_sources_cannot_reintroduce_denied_provider( client: AsyncClient, logged_in_headers, monkeypatch diff --git a/src/lfx/src/lfx/mcp/flow_builder_tools/edit_tools.py b/src/lfx/src/lfx/mcp/flow_builder_tools/edit_tools.py index eb5c7de9747c..b7dd9db19fa5 100644 --- a/src/lfx/src/lfx/mcp/flow_builder_tools/edit_tools.py +++ b/src/lfx/src/lfx/mcp/flow_builder_tools/edit_tools.py @@ -16,6 +16,7 @@ from lfx.custom import Component from lfx.io import MessageTextInput, Output from lfx.schema import Data +from lfx.services.model_provider_policy import ModelProviderPolicyError from ._state import _emit, _find_node, _readable_preview, get_working_flow, should_apply_edits_live @@ -122,6 +123,18 @@ def propose_field_edit(self) -> Data: } ) + # This tool is an alternate configuration path: the UI may apply its + # emitted patch after approval, while headless callers apply it below. + # Gate the prospective value before either mutation can escape. + from .mutate_tools import _model_providers_in_params, _require_allowed_model_providers + + try: + _require_allowed_model_providers( + _model_providers_in_params(flow, self.component_id, {self.field_name: self.new_value}) + ) + except ModelProviderPolicyError as exc: + return Data(data={"error": str(exc)}) + # 3. Read old value old_value = template[self.field_name].get("value") diff --git a/src/lfx/src/lfx/mcp/flow_builder_tools/mutate_tools.py b/src/lfx/src/lfx/mcp/flow_builder_tools/mutate_tools.py index 73ad155e9766..ba0a28c64660 100644 --- a/src/lfx/src/lfx/mcp/flow_builder_tools/mutate_tools.py +++ b/src/lfx/src/lfx/mcp/flow_builder_tools/mutate_tools.py @@ -8,7 +8,10 @@ from __future__ import annotations import json +import re +from types import SimpleNamespace +from lfx.base.models.provider_registry import get_registry_snapshot, model_component_provider_id from lfx.custom import Component from lfx.graph.flow_builder.component import _coerce_model_value as fb_coerce_model_value from lfx.graph.flow_builder.component import add_component as fb_add_component @@ -61,11 +64,16 @@ def add_component(self) -> Data: registry = _load_registry_user_aware() flow = _ensure_working_flow() try: + provider_id = _registry_component_provider_id(registry, self.component_type) + _require_allowed_model_providers({provider_id} if provider_id else set()) result = fb_add_component(flow, self.component_type, registry) layout_flow(flow) _emit("add_component", node=flow["data"]["nodes"][-1]) text = f"Added {self.component_type} ({result['id']})" return Data(data={"id": result["id"], "type": self.component_type, "text": text}) + except ModelProviderPolicyError as e: + logger.warning("add_component blocked by model-provider policy: %s", e) + return Data(data={"error": str(e)}) except (ValueError, KeyError) as e: logger.warning("add_component failed: %s", e) return Data(data={"error": str(e)}) @@ -184,17 +192,15 @@ def _current_policy_user_id() -> str | None: return current_user_id() -def _model_providers_in_params(flow: dict, component_id: str, params: dict) -> set[str]: - """Extract provider names from model-typed fields without mutating the flow.""" - node = _find_node(flow, component_id) - if node is None: - return set() - template = node.get("data", {}).get("node", {}).get("template", {}) +def _model_providers_in_template(template: dict, params: dict | None = None) -> set[str]: + """Extract providers from model selections and their scalar provider override.""" providers: set[str] = set() - for field_name, value in params.items(): - field = template.get(field_name) + has_model_field = False + for field_name, field in template.items(): if not isinstance(field, dict) or field.get("type") != "model": continue + has_model_field = True + value = params.get(field_name, field.get("value")) if params is not None else field.get("value") normalized = fb_coerce_model_value(value) if not isinstance(normalized, list): continue @@ -204,18 +210,132 @@ def _model_providers_in_params(flow: dict, component_id: str, params: dict) -> s provider = entry.get("provider") if isinstance(provider, str) and provider.strip(): providers.add(provider.strip()) + + # Unified LanguageModel/EmbeddingModel wrappers can replace the provider + # selected in their ModelInput at runtime through a scalar `provider` + # field. Treat that override as part of the model configuration too. + provider_field = template.get("provider") + if has_model_field and isinstance(provider_field, dict): + provider = ( + params.get("provider", provider_field.get("value")) if params is not None else provider_field.get("value") + ) + if isinstance(provider, str) and provider.strip(): + providers.add(provider.strip()) + return providers + + +def _model_providers_in_params(flow: dict, component_id: str, params: dict) -> set[str]: + """Extract provider names from model configuration without mutating the flow.""" + node = _find_node(flow, component_id) + if node is None: + return set() + node_data = node.get("data", {}) + component = node_data.get("node", {}) + template = component.get("template", {}) if isinstance(component, dict) else {} + providers = _model_providers_in_template(template, params) if isinstance(template, dict) else set() + component_type = node_data.get("type") + if isinstance(component_type, str) and isinstance(component, dict): + provider_id = _registry_component_provider_id({component_type: component}, component_type) + if provider_id: + providers.add(provider_id) + return providers + + +def _registry_component_provider_id(registry: dict[str, dict], component_type: str) -> str | None: + """Return the provider ID for a standalone model component registry entry.""" + entry = registry.get(component_type) + if not isinstance(entry, dict): + return None + template = entry.get("template") + metadata = entry.get("metadata") + metadata = metadata if isinstance(metadata, dict) else {} + policy_mode = entry.get("model_provider_policy_mode") or metadata.get("model_provider_policy_mode") + code_field = template.get("code") if isinstance(template, dict) else None + source = code_field.get("value", "") if isinstance(code_field, dict) else "" + if policy_mode in {"delegate", "none"} or ( + isinstance(source, str) and re.search(r"""model_provider_policy_mode\s*=\s*["'](?:delegate|none)["']""", source) + ): + return None + if isinstance(template, dict) and any( + isinstance(field, dict) and field.get("type") == "model" for field in template.values() + ): + # Unified selectors delegate policy to their explicit model value. + return None + base_classes = entry.get("base_classes") + if not isinstance(base_classes, list) or not {"LanguageModel", "Embeddings"}.intersection(base_classes): + return None + component = SimpleNamespace( + display_name=entry.get("display_name"), + model_provider_id=entry.get("model_provider_id") or metadata.get("model_provider_id"), + ) + return model_component_provider_id(component, module_name=entry.get("module") or metadata.get("module")) + + +def _model_providers_in_flow_spec(spec: str, registry: dict[str, dict]) -> set[str]: + """Extract every provider a full-flow spec would configure, without building it.""" + from lfx.graph.flow_builder.spec import parse_flow_spec + + parsed = parse_flow_spec(spec) + node_types = {node["id"]: node["type"] for node in parsed["nodes"]} + providers = { + provider_id + for component_type in node_types.values() + if (provider_id := _registry_component_provider_id(registry, component_type)) + } + for node_id, params in parsed.get("config", {}).items(): + entry = registry.get(node_types.get(node_id, "")) + template = entry.get("template") if isinstance(entry, dict) else None + if not isinstance(template, dict): + continue + providers.update(_model_providers_in_template(template, params)) return providers +def _model_providers_in_flow(flow: dict, registry: dict[str, dict]) -> set[str]: + """Extract configured providers from a built flow before it is persisted.""" + providers: set[str] = set() + for node in flow.get("data", {}).get("nodes", []): + node_data = node.get("data", {}) + component_type = node_data.get("type") + if isinstance(component_type, str): + provider_id = _registry_component_provider_id(registry, component_type) + if provider_id: + providers.add(provider_id) + template = node_data.get("node", {}).get("template", {}) + if not isinstance(template, dict): + continue + providers.update(_model_providers_in_template(template)) + return providers + + +def _filter_registry_by_model_provider_policy(registry: dict[str, dict]) -> dict[str, dict]: + """Hide standalone provider components denied for configuration.""" + providers_by_type = { + component_type: provider_id + for component_type in registry + if (provider_id := _registry_component_provider_id(registry, component_type)) + } + if not providers_by_type: + return registry + policy = resolve_model_provider_policy( + user_id=_current_policy_user_id(), + providers=[*get_registry_snapshot().provider_ids, *providers_by_type.values()], + purpose=ModelProviderPolicyPurpose.CONFIGURE, + ) + return { + component_type: entry + for component_type, entry in registry.items() + if (provider_id := providers_by_type.get(component_type)) is None or policy.allows(provider_id) + } + + def _require_allowed_model_providers(providers: set[str]) -> None: """Require CONFIGURE policy before model values reach configuration or catalogs.""" if not providers: return - from lfx.base.models.unified_models import get_model_providers - policy = resolve_model_provider_policy( user_id=_current_policy_user_id(), - providers=[*get_model_providers(), *providers], + providers=[*get_registry_snapshot().provider_ids, *providers], purpose=ModelProviderPolicyPurpose.CONFIGURE, ) for provider in providers: diff --git a/src/lfx/src/lfx/mcp/flow_builder_tools/read_tools.py b/src/lfx/src/lfx/mcp/flow_builder_tools/read_tools.py index f4acd0dd0452..d21f7d5a8227 100644 --- a/src/lfx/src/lfx/mcp/flow_builder_tools/read_tools.py +++ b/src/lfx/src/lfx/mcp/flow_builder_tools/read_tools.py @@ -43,6 +43,9 @@ def search_components(self) -> Data: def producer() -> Data: registry = _load_registry_user_aware() + from .mutate_tools import _filter_registry_by_model_provider_policy + + registry = _filter_registry_by_model_provider_policy(registry) results = search_registry(registry, query=self.query or None) return Data(data={"results": results, "count": len(results)}) @@ -82,6 +85,9 @@ def describe_component(self) -> Data: def producer() -> Data: registry = _load_registry_user_aware() + from .mutate_tools import _filter_registry_by_model_provider_policy + + registry = _filter_registry_by_model_provider_policy(registry) try: result = describe_component(registry, component_type) except ValueError as e: diff --git a/src/lfx/src/lfx/mcp/flow_builder_tools/run_tools.py b/src/lfx/src/lfx/mcp/flow_builder_tools/run_tools.py index ef3c48b820ec..23e654506099 100644 --- a/src/lfx/src/lfx/mcp/flow_builder_tools/run_tools.py +++ b/src/lfx/src/lfx/mcp/flow_builder_tools/run_tools.py @@ -17,6 +17,7 @@ from lfx.io import MessageTextInput, Output from lfx.log.logger import logger from lfx.schema import Data +from lfx.services.model_provider_policy import ModelProviderPolicyError from ._state import ( _current_flow_id_var, @@ -126,7 +127,29 @@ def build_flow(self) -> Data: # Pass the user-aware registry so user-registered Components # (created via Layer-2 validated generation) are addressable in # the spec by their class name. - result = build_flow_from_spec(self.spec, registry=_load_registry_user_aware()) + registry = _load_registry_user_aware() + from .mutate_tools import ( + _model_providers_in_flow, + _model_providers_in_flow_spec, + _require_allowed_model_providers, + ) + + try: + from lfx.graph.flow_builder.spec import parse_flow_spec + + parse_flow_spec(self.spec) + except ValueError: + # Preserve the builder's established invalid-spec error contract. + configured_providers = set() + else: + configured_providers = _model_providers_in_flow_spec(self.spec, registry) + try: + _require_allowed_model_providers(configured_providers) + except ModelProviderPolicyError as exc: + logger.warning("build_flow blocked by model-provider policy: %s", exc) + return Data(data={"error": str(exc), "text": str(exc)}) + + result = build_flow_from_spec(self.spec, registry=registry) if "error" in result: error_msg = f"Flow build failed: {result['error']}" if "details" in result: @@ -134,6 +157,11 @@ def build_flow(self) -> Data: logger.warning("build_flow_from_spec failed: %s", result["error"]) result["text"] = error_msg elif "flow" in result: + try: + _require_allowed_model_providers(_model_providers_in_flow(result["flow"], registry)) + except ModelProviderPolicyError as exc: + logger.warning("built flow blocked by model-provider policy: %s", exc) + return Data(data={"error": str(exc), "text": str(exc)}) orphan_ids = _find_orphan_nodes(result["flow"]) # A single-component flow has no edges by definition — it is a # valid standalone flow (e.g. the agent built one component to @@ -312,7 +340,7 @@ async def run_flow(self) -> Data: requested.get("api_key_var"), overwrite_existing_model=True, ) - elif run_provider and run_model_name: + elif run_provider and run_model_name and run_model.get("allow_configuration", True): # No explicit request: only FILL an Agent that has no model with # the assistant's verified runtime model (preserve a set one). inject_model_into_flow( diff --git a/src/lfx/tests/unit/test_flow_builder_tools.py b/src/lfx/tests/unit/test_flow_builder_tools.py index 0bccc45cbb1e..4763da0a411e 100644 --- a/src/lfx/tests/unit/test_flow_builder_tools.py +++ b/src/lfx/tests/unit/test_flow_builder_tools.py @@ -18,6 +18,13 @@ reset_working_flow, ) +_DENIED_CONFIGURE_CATALOG_CALLS: list[None] = [] + + +def _denied_configure_catalog_loader(): + _DENIED_CONFIGURE_CATALOG_CALLS.append(None) + return [{"name": "blocked-model", "model_type": "llm"}] + class TestSearchComponentTypes: def test_search_returns_results(self): @@ -810,6 +817,407 @@ def fail_if_called(*_args, **_kwargs): assert str(exc_info.value) == "The requested model provider is not available" + def test_policy_gate_does_not_execute_denied_extension_catalog(self, monkeypatch): + from lfx.base.models.provider_registry import ProviderSpec, register_provider, unregister_provider + from lfx.mcp.flow_builder_tools import ConfigureComponent, mutate_tools + + provider = "Denied Configure Extension" + register_provider( + ProviderSpec( + name=provider, + provider_id="denied-configure-extension", + metadata={ + "icon": "Bot", + "variables": [], + "mapping": {"model_class": "ChatOpenAI", "model_param": "model"}, + }, + catalog_loader=f"{__name__}:_denied_configure_catalog_loader", + ) + ) + _DENIED_CONFIGURE_CATALOG_CALLS.clear() + monkeypatch.setattr( + mutate_tools, + "resolve_model_provider_policy", + lambda **_kwargs: self._deny_all_snapshot(), + ) + agent_id = _make_agent_node_for_model_test() + + try: + cfg = ConfigureComponent() + cfg.set(component_id=agent_id, params=f'{{"model": [{{"provider": "{provider}"}}]}}') + result = cfg.configure_component() + finally: + unregister_provider(provider) + + assert result.data == {"error": "The requested model provider is not available"} + assert _DENIED_CONFIGURE_CATALOG_CALLS == [] + + def test_add_standalone_model_component_is_policy_gated(self, monkeypatch): + from lfx.mcp.flow_builder_tools import AddComponent, _ensure_working_flow, mutate_tools + + registry = { + "OpenAIModel": { + "base_classes": ["LanguageModel"], + "display_name": "OpenAI", + "metadata": {"module": "lfx_openai.chat_models.OpenAIModel"}, + "template": {}, + } + } + monkeypatch.setattr(mutate_tools, "_load_registry_user_aware", lambda: registry) + monkeypatch.setattr( + mutate_tools, + "resolve_model_provider_policy", + lambda **_kwargs: self._deny_all_snapshot(), + ) + reset_working_flow() + + component = AddComponent() + component.set(component_type="OpenAIModel") + result = component.add_component() + + assert result.data == {"error": "The requested model provider is not available"} + assert _ensure_working_flow()["data"]["nodes"] == [] + + def test_build_flow_spec_model_selection_is_policy_gated(self, monkeypatch): + from lfx.mcp.flow_builder_tools import BuildFlowFromSpec, _ensure_working_flow, mutate_tools, run_tools + + registry = { + "Agent": { + "base_classes": ["Message"], + "display_name": "Agent", + "module": "lfx.components.agents.agent.AgentComponent", + "template": {"model": {"type": "model", "value": ""}}, + } + } + monkeypatch.setattr(run_tools, "_load_registry_user_aware", lambda: registry) + monkeypatch.setattr( + mutate_tools, + "resolve_model_provider_policy", + lambda **_kwargs: self._deny_all_snapshot(), + ) + reset_working_flow() + + builder = BuildFlowFromSpec() + builder.set( + spec=( + "name: Blocked\n" + "nodes:\n" + " A: Agent\n" + "config:\n" + ' A.model: [{"provider":"Anthropic","name":"claude-test"}]\n' + ) + ) + result = builder.build_flow() + + assert result.data == { + "error": "The requested model provider is not available", + "text": "The requested model provider is not available", + } + assert _ensure_working_flow()["data"]["nodes"] == [] + + def test_configure_component_scalar_provider_override_is_policy_gated(self, monkeypatch): + from lfx.mcp.flow_builder_tools import ConfigureComponent, mutate_tools + + flow = { + "name": "Wrapper", + "data": { + "nodes": [ + _node( + "LanguageModel-1", + "LanguageModel", + { + "model": { + "type": "model", + "value": [{"provider": "OpenAI", "name": "gpt-test"}], + }, + "model_name": {"type": "str", "value": ""}, + "provider": {"type": "str", "value": ""}, + }, + ) + ], + "edges": [], + }, + } + init_working_flow(flow, "wrapper-flow") + monkeypatch.setattr( + mutate_tools, + "resolve_model_provider_policy", + lambda **_kwargs: self._deny_all_snapshot(), + ) + + cfg = ConfigureComponent() + cfg.set( + component_id="LanguageModel-1", + params='{"provider": "Anthropic", "model_name": "claude-test"}', + ) + result = cfg.configure_component() + + assert result.data == {"error": "The requested model provider is not available"} + template = get_working_flow()["data"]["nodes"][0]["data"]["node"]["template"] + assert template["provider"]["value"] == "" + assert template["model_name"]["value"] == "" + + def test_configure_component_uses_existing_scalar_provider_for_policy(self, monkeypatch): + from lfx.mcp.flow_builder_tools import ConfigureComponent, mutate_tools + + flow = { + "name": "Wrapper", + "data": { + "nodes": [ + _node( + "EmbeddingModel-1", + "EmbeddingModel", + { + "model": { + "type": "model", + "value": [{"provider": "OpenAI", "name": "text-embedding-test"}], + }, + "model_name": {"type": "str", "value": "blocked-embedding"}, + "provider": {"type": "str", "value": "Anthropic"}, + }, + ) + ], + "edges": [], + }, + } + init_working_flow(flow, "wrapper-flow") + monkeypatch.setattr( + mutate_tools, + "resolve_model_provider_policy", + lambda **_kwargs: self._deny_all_snapshot(), + ) + + cfg = ConfigureComponent() + cfg.set(component_id="EmbeddingModel-1", params='{"model_name": "still-blocked"}') + result = cfg.configure_component() + + assert result.data == {"error": "The requested model provider is not available"} + template = get_working_flow()["data"]["nodes"][0]["data"]["node"]["template"] + assert template["model_name"]["value"] == "blocked-embedding" + + def test_configure_existing_standalone_provider_is_policy_gated(self, monkeypatch): + from lfx.mcp.flow_builder_tools import ConfigureComponent, mutate_tools + + flow = { + "name": "Standalone", + "data": { + "nodes": [ + { + "data": { + "id": "OpenAIModel-1", + "type": "OpenAIModel", + "node": { + "base_classes": ["LanguageModel"], + "display_name": "OpenAI", + "metadata": {"module": "lfx_openai.chat_models.OpenAIModel"}, + "template": {"temperature": {"type": "float", "value": 0.1}}, + }, + } + } + ], + "edges": [], + }, + } + init_working_flow(flow, "standalone-flow") + monkeypatch.setattr( + mutate_tools, + "resolve_model_provider_policy", + lambda **_kwargs: self._deny_all_snapshot(), + ) + + cfg = ConfigureComponent() + cfg.set(component_id="OpenAIModel-1", params='{"temperature": 0.9}') + result = cfg.configure_component() + + assert result.data == {"error": "The requested model provider is not available"} + template = get_working_flow()["data"]["nodes"][0]["data"]["node"]["template"] + assert template["temperature"]["value"] == 0.1 + + def test_propose_field_edit_scalar_provider_override_is_policy_gated(self, monkeypatch): + from lfx.mcp.flow_builder_tools import ( + ProposeFieldEdit, + drain_flow_events, + mutate_tools, + set_apply_edits_live, + ) + + flow = { + "name": "Wrapper", + "data": { + "nodes": [ + _node( + "LanguageModel-1", + "LanguageModel", + { + "model": { + "type": "model", + "value": [{"provider": "OpenAI", "name": "gpt-test"}], + }, + "provider": {"type": "str", "value": ""}, + }, + ) + ], + "edges": [], + }, + } + init_working_flow(flow, "wrapper-flow") + set_apply_edits_live(enabled=True) + monkeypatch.setattr( + mutate_tools, + "resolve_model_provider_policy", + lambda **_kwargs: self._deny_all_snapshot(), + ) + + edit = ProposeFieldEdit() + edit.set(component_id="LanguageModel-1", field_name="provider", new_value="Anthropic") + result = edit.propose_field_edit() + + assert result.data == {"error": "The requested model provider is not available"} + template = get_working_flow()["data"]["nodes"][0]["data"]["node"]["template"] + assert template["provider"]["value"] == "" + assert drain_flow_events() == [] + + def test_propose_field_edit_existing_standalone_provider_is_policy_gated(self, monkeypatch): + from lfx.mcp.flow_builder_tools import ProposeFieldEdit, drain_flow_events, mutate_tools + + flow = { + "name": "Standalone", + "data": { + "nodes": [ + { + "data": { + "id": "OpenAIModel-1", + "type": "OpenAIModel", + "node": { + "base_classes": ["LanguageModel"], + "display_name": "OpenAI", + "metadata": {"module": "lfx_openai.chat_models.OpenAIModel"}, + "template": {"temperature": {"type": "float", "value": 0.1}}, + }, + } + } + ], + "edges": [], + }, + } + init_working_flow(flow, "standalone-flow") + monkeypatch.setattr( + mutate_tools, + "resolve_model_provider_policy", + lambda **_kwargs: self._deny_all_snapshot(), + ) + + edit = ProposeFieldEdit() + edit.set(component_id="OpenAIModel-1", field_name="temperature", new_value="0.9") + result = edit.propose_field_edit() + + assert result.data == {"error": "The requested model provider is not available"} + template = get_working_flow()["data"]["nodes"][0]["data"]["node"]["template"] + assert template["temperature"]["value"] == 0.1 + assert drain_flow_events() == [] + + def test_build_flow_spec_scalar_provider_override_is_policy_gated(self, monkeypatch): + from lfx.mcp.flow_builder_tools import BuildFlowFromSpec, _ensure_working_flow, mutate_tools, run_tools + + registry = { + "LanguageModel": { + "base_classes": ["LanguageModel"], + "display_name": "Language Model", + "model_provider_policy_mode": "delegate", + "module": "lfx.components.models_and_agents.language_model.LanguageModelComponent", + "template": { + "model": { + "type": "model", + "value": [{"provider": "OpenAI", "name": "gpt-test"}], + }, + "model_name": {"type": "str", "value": ""}, + "provider": {"type": "str", "value": ""}, + }, + } + } + monkeypatch.setattr(run_tools, "_load_registry_user_aware", lambda: registry) + monkeypatch.setattr( + mutate_tools, + "resolve_model_provider_policy", + lambda **_kwargs: self._deny_all_snapshot(), + ) + reset_working_flow() + + builder = BuildFlowFromSpec() + builder.set( + spec=( + "name: Blocked Override\n" + "nodes:\n" + " A: LanguageModel\n" + "config:\n" + " A.provider: Anthropic\n" + " A.model_name: claude-test\n" + ) + ) + result = builder.build_flow() + + assert result.data == { + "error": "The requested model provider is not available", + "text": "The requested model provider is not available", + } + assert _ensure_working_flow()["data"]["nodes"] == [] + + def test_component_discovery_hides_denied_standalone_provider(self, monkeypatch): + from lfx.mcp.flow_builder_tools import ( + DescribeComponentType, + SearchComponentTypes, + mutate_tools, + read_tools, + ) + from lfx.mcp.tool_cache import reset_tool_cache + + registry = { + "ChatInput": { + "base_classes": ["Message"], + "category": "inputs", + "display_name": "Chat Input", + "module": "lfx.components.inputs.chat.ChatInput", + "template": {}, + }, + "FakeEmbeddings": { + "base_classes": ["Embeddings"], + "category": "models", + "display_name": "Fake Embeddings", + "metadata": {"module": "lfx.components.langchain_utilities.fake_embeddings.FakeEmbeddingsComponent"}, + "template": { + "code": { + "type": "code", + "value": 'class FakeEmbeddings:\n model_provider_policy_mode = "none"\n', + } + }, + }, + "OpenAIModel": { + "base_classes": ["LanguageModel"], + "category": "models", + "display_name": "OpenAI", + "metadata": {"module": "lfx_openai.chat_models.OpenAIModel"}, + "template": {}, + }, + } + monkeypatch.setattr(read_tools, "_load_registry_user_aware", lambda: registry) + monkeypatch.setattr( + mutate_tools, + "resolve_model_provider_policy", + lambda **_kwargs: self._deny_all_snapshot(), + ) + reset_tool_cache() + + search = SearchComponentTypes() + search.set(query="") + results = search.search_components() + describe = DescribeComponentType() + describe.set(component_type="OpenAIModel") + description = describe.describe_component() + + assert {result["type"] for result in results.data["results"]} == {"ChatInput", "FakeEmbeddings"} + assert description.data["error"].startswith("Unknown component: OpenAIModel") + reset_tool_cache() + class TestConfigureComponentModelFieldSerializedSpec: r"""Regression: PR-12575 round 6 bug 2.