Skip to content

Commit 1a665f8

Browse files
committed
feat(models): govern provider configuration paths
1 parent 2eefb24 commit 1a665f8

14 files changed

Lines changed: 1039 additions & 53 deletions

File tree

.secrets.baseline

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1263,7 +1263,7 @@
12631263
"filename": "src/backend/tests/unit/agentic/services/test_assistant_service_run_intent.py",
12641264
"hashed_secret": "e9a5f12a8ecbb3eb46eca5096b5c52aa5e7c9fdd",
12651265
"is_verified": false,
1266-
"line_number": 369,
1266+
"line_number": 386,
12671267
"is_secret": false
12681268
}
12691269
],
@@ -1337,7 +1337,7 @@
13371337
"filename": "src/backend/tests/unit/agentic/services/test_provider_service.py",
13381338
"hashed_secret": "c92b9809dacd9240dc85e86da6388e9b1bfc8a7d",
13391339
"is_verified": false,
1340-
"line_number": 118,
1340+
"line_number": 126,
13411341
"is_secret": false
13421342
}
13431343
],
@@ -7203,5 +7203,5 @@
72037203
}
72047204
]
72057205
},
7206-
"generated_at": "2026-07-29T21:58:24Z"
7206+
"generated_at": "2026-07-29T22:45:05Z"
72077207
}

src/backend/base/langflow/agentic/flows/flow_builder_assistant.py

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -323,13 +323,16 @@
323323
running — otherwise the run fails with "No model selected".
324324
325325
Pick the model in this STRICT priority order:
326-
1. **The model the user EXPLICITLY named WINS — always.** If the user asked
326+
1. **The model the user EXPLICITLY named WINS unless a runtime
327+
`[Model provider policy ...]` notice says its provider is unavailable.**
328+
If the user asked
327329
for a specific model ("use GPT-5.4", "use the OpenAI 5.4 model", "switch to
328330
claude-sonnet-4-5", "troque para gemini-2.5-pro"), use THAT model — never
329331
substitute a different version or the `preferred` model for it, even if the
330-
requested model is not in the `[Available language models ...]` block and
331-
even if a "preferred" model is offered. (e.g. user said "5.4" / "gpt-5.4"
332-
and preferred is "gpt-5.5" → you MUST set the 5.4 model, NOT gpt-5.5.)
332+
exact requested model is not listed and even if a "preferred" model is
333+
offered. (e.g. user said "5.4" / "gpt-5.4" and preferred is "gpt-5.5"
334+
→ you MUST set the 5.4 model, NOT gpt-5.5.) If a policy notice omits or
335+
rejects the provider, do not discover, configure, or run it.
333336
BUT set the **canonical model id** — the EXACT id as it appears in the
334337
provider catalog / `describe_component` / the `[Available language models]`
335338
block, NOT the user's loose wording. Provider model ids are CASE-SENSITIVE
@@ -343,19 +346,18 @@
343346
`preferred`; if there is none, pick ANY provider from "providers with
344347
credentials configured" (provider-agnostic — do NOT assume OpenAI; use
345348
whatever the user actually has keys for, e.g. Anthropic, Google, Groq).
346-
3. Only if no such block is present at all may you fall back to
347-
`provider="OpenAI", name="gpt-4o-mini"`.
349+
3. Only if neither an Available-language-model block nor a restrictive
350+
Model-provider-policy notice is present may you preserve the historical
351+
fallback `provider="OpenAI", name="gpt-4o-mini"`.
348352
349353
`configure_component(component_id="Agent-...", params='{"model": [{"provider": "<provider>", "name": "<name>"}]}')`.
350354
Never run a flow whose Agent has no model. NEVER claim in your reply that you
351355
used a model different from the one you actually set on the canvas — report the
352356
EXACT model you configured.
353357
354-
Common providers and example model names:
355-
- `OpenAI` — `gpt-4o`, `gpt-4o-mini`, `gpt-5`, `o1-mini`
356-
- `Anthropic` — `claude-sonnet-4-5-20250929`, `claude-haiku-4-5`
357-
- `Google Generative AI` — `gemini-2.5-flash`, `gemini-2.5-pro`
358-
- `Groq`, `Azure OpenAI`, `Ollama`, `IBM WatsonX`
358+
Provider and model names are deployment-specific. Treat the runtime
359+
`[Available language models ...]` block as the authority; never infer that a
360+
provider is available from these instructions or from an example below.
359361
360362
Add a SEPARATE model component (OpenAIModel etc.) only when the user
361363
EXPLICITLY says "add an OpenAIModel component" / "create a model node" — never

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

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,40 @@ class AgentRunModel(TypedDict):
1818
provider: str | None
1919
model_name: str | None
2020
api_key_var: str | None
21+
allow_configuration: bool
22+
23+
24+
class RequestedAgentModel(TypedDict):
25+
provider: str
26+
model_name: str
27+
api_key_var: str | None
2128

2229

2330
_model_var: contextvars.ContextVar[AgentRunModel | None] = contextvars.ContextVar("agentic_run_model", default=None)
2431

25-
_requested_model_var: contextvars.ContextVar[AgentRunModel | None] = contextvars.ContextVar(
32+
_requested_model_var: contextvars.ContextVar[RequestedAgentModel | None] = contextvars.ContextVar(
2633
"agentic_requested_model", default=None
2734
)
2835

2936
_iterations_var: contextvars.ContextVar[int | None] = contextvars.ContextVar("agentic_run_iterations", default=None)
3037

3138

32-
def set_agent_run_model(provider: str | None, model_name: str | None, api_key_var: str | None) -> None:
33-
"""Bind the request's provider/model/api-key to the current context."""
34-
_model_var.set({"provider": provider, "model_name": model_name, "api_key_var": api_key_var})
39+
def set_agent_run_model(
40+
provider: str | None,
41+
model_name: str | None,
42+
api_key_var: str | None,
43+
*,
44+
allow_configuration: bool = True,
45+
) -> None:
46+
"""Bind the request model and whether it may be injected into the canvas."""
47+
_model_var.set(
48+
{
49+
"provider": provider,
50+
"model_name": model_name,
51+
"api_key_var": api_key_var,
52+
"allow_configuration": allow_configuration,
53+
}
54+
)
3555

3656

3757
def current_agent_run_model() -> AgentRunModel | None:
@@ -83,7 +103,7 @@ def set_requested_agent_model(provider: str | None, model_name: str | None, api_
83103
_requested_model_var.set(None)
84104

85105

86-
def current_requested_agent_model() -> AgentRunModel | None:
106+
def current_requested_agent_model() -> RequestedAgentModel | None:
87107
"""Return the user's explicitly-requested model (or ``None`` when unset)."""
88108
return _requested_model_var.get()
89109

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

Lines changed: 69 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from fastapi import HTTPException
1414
from lfx.base.models.model_remediation import cached_overrides, find_remediation, remember, restore_overrides
15+
from lfx.base.models.provider_registry import get_registry_snapshot
1516
from lfx.graph.flow_builder.flow import flow_to_spec_summary
1617
from lfx.log.logger import logger
1718
from lfx.mcp.flow_builder_tools import (
@@ -23,6 +24,7 @@
2324
set_propose_existing_edits,
2425
)
2526
from lfx.mcp.tool_cache import reset_tool_cache
27+
from lfx.services.model_provider_policy import ModelProviderPolicyPurpose, aresolve_model_provider_policy
2628

2729
from langflow.agentic.helpers.code_extraction import extract_component_code, extract_flow_json
2830
from langflow.agentic.helpers.code_security import scan_code_security
@@ -718,29 +720,79 @@ def _complete(data: dict) -> str:
718720
f"{current_input}"
719721
)
720722

721-
# Tell the agent which language model(s) it can safely put on any Agent
722-
# it builds — building an Agent without a model makes the run fail with
723-
# "No model selected". The PREFERRED one is the model the assistant
723+
# Tell the agent which language model(s) it can safely put on any Agent it
724+
# builds — building an Agent without a model makes the run fail with
725+
# "No model selected".
726+
# Resolve one CONFIGURE snapshot before exposing names or binding the
727+
# classifier's requested model; this keeps the assistant from reintroducing
728+
# a provider hidden by governance through prompt context or deterministic
729+
# run-model injection.
730+
# The PREFERRED one is the model the assistant
724731
# itself runs with (key guaranteed). We also list every provider whose
725732
# API key is configured (provider-agnostic, detected from the env-built
726733
# global variables — NO OpenAI bias). Omitted (input byte-identical to
727734
# before) only when neither is available.
728735
from langflow.agentic.services.flow_preparation import available_model_providers
729736

737+
_available_provider_names = available_model_providers(global_variables)
738+
_registered_provider_names = sorted(
739+
descriptor.name for descriptor in get_registry_snapshot().descriptors_by_id.values()
740+
)
741+
_policy_candidates = list(
742+
dict.fromkeys(
743+
[
744+
*_registered_provider_names,
745+
*_available_provider_names,
746+
*([provider] if provider else []),
747+
*([intent_result.requested_provider] if intent_result.requested_provider else []),
748+
]
749+
)
750+
)
751+
try:
752+
_provider_policy = await aresolve_model_provider_policy(
753+
user_id=user_id,
754+
providers=_policy_candidates,
755+
purpose=ModelProviderPolicyPurpose.CONFIGURE,
756+
)
757+
except BaseException:
758+
# The current canvas was already seeded above, but the main request
759+
# try/finally has not started yet. A fail-closed policy error or
760+
# cancellation must not leak that ContextVar state to the next request.
761+
reset_working_flow()
762+
raise
763+
_requested_provider = intent_result.requested_provider
764+
_requested_provider_allowed = not _requested_provider or _provider_policy.allows(_requested_provider)
765+
_allowed_configuration_providers = _provider_policy.filter(_registered_provider_names)
766+
_catalog_is_restricted = any(not _provider_policy.allows(name) for name in _policy_candidates)
767+
730768
_model_parts: list[str] = []
731-
if provider and model_name:
769+
if provider and model_name and _provider_policy.allows(provider):
732770
_model_parts.append(f"preferred: provider={provider!r}, name={model_name!r}")
733-
_avail = available_model_providers(global_variables)
771+
_avail = _provider_policy.filter(_available_provider_names)
734772
if _avail:
735773
_model_parts.append("providers with credentials configured: " + ", ".join(_avail))
736774
if _model_parts:
737775
current_input = (
738776
f"[Available language models — these are a DEFAULT only. If the user explicitly named a "
739-
f"model, set EXACTLY that model (verbatim) and IGNORE this block. ONLY when the user did "
740-
f"NOT name a model, configure an Agent's `model` field with the one marked `preferred` "
741-
f"(else any listed provider) so the flow can run: "
777+
f"model and no Model provider policy notice rejects it, set EXACTLY that model and IGNORE "
778+
f"this block. ONLY when the user did NOT name a model, configure an Agent's `model` field "
779+
f"with the one marked `preferred` (else any listed provider) so the flow can run: "
742780
f"{'; '.join(_model_parts)}]\n\n{current_input}"
743781
)
782+
if _catalog_is_restricted or not _requested_provider_allowed:
783+
allowed_notice = (
784+
"Configure only these providers: " + ", ".join(_allowed_configuration_providers) + ". "
785+
if _allowed_configuration_providers
786+
else "No model providers are available for configuration. "
787+
)
788+
requested_notice = (
789+
"The explicitly requested provider is unavailable. " if not _requested_provider_allowed else ""
790+
)
791+
current_input = (
792+
f"[Model provider policy: {requested_notice}{allowed_notice}"
793+
"Do not discover, configure, or run any other provider.]\n\n"
794+
f"{current_input}"
795+
)
744796

745797
# Headless callers (MCP) have no review UI, so steer the agent away from a
746798
# "proposed/pending approval" narration the user can never act on (#13641).
@@ -829,16 +881,22 @@ async def check_cancelled() -> bool:
829881
set_current_user_id(user_id)
830882
# The generate_component tool re-runs the component-gen LLM flow
831883
# mid-loop and needs the same provider/model the request used.
832-
set_agent_run_model(provider, model_name, api_key_var)
884+
set_agent_run_model(
885+
provider,
886+
model_name,
887+
api_key_var,
888+
allow_configuration=bool(provider and _provider_policy.allows(provider)),
889+
)
833890
set_agent_run_iterations(_iterations_from_globals(global_variables))
834891
# If the user EXPLICITLY named a model (e.g. "use the OpenAI gpt-5.4
835892
# model"), bind it so the run-time injector ENFORCES it on the Agent —
836893
# the canvas must show exactly what the user asked for, never the
837894
# assistant's own runtime model. Same-provider runs reuse the verified
838895
# api_key_var; a different provider falls back to its default var.
839-
_req_provider = intent_result.requested_provider
896+
_req_provider = intent_result.requested_provider if _requested_provider_allowed else None
840897
_req_api_key_var = api_key_var if (_req_provider and provider and _req_provider == provider) else None
841-
set_requested_agent_model(_req_provider, intent_result.requested_model, _req_api_key_var)
898+
_req_model = intent_result.requested_model if _req_provider else None
899+
set_requested_agent_model(_req_provider, _req_model, _req_api_key_var)
842900

843901
# max_retries=0 means 1 attempt (no retries), matching non-streaming semantics
844902
total_attempts = max_retries + 1

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

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,9 @@
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
9+
from lfx.base.models.provider_registry import get_registry_snapshot, is_api_key_optional
1010
from lfx.base.models.unified_models import (
1111
get_model_provider_variable_mapping,
12-
get_model_providers,
1312
get_provider_required_variable_keys,
1413
get_unified_models_detailed,
1514
)
@@ -45,6 +44,11 @@
4544
"""
4645

4746

47+
def _get_registered_provider_names() -> list[str]:
48+
"""Return provider names without executing extension catalog loaders."""
49+
return sorted(descriptor.name for descriptor in get_registry_snapshot().descriptors_by_id.values())
50+
51+
4852
async def get_enabled_providers_for_user(
4953
user_id: UUID | str,
5054
session: AsyncSession,
@@ -64,7 +68,7 @@ async def get_enabled_providers_for_user(
6468
all_variable_names = {var.name for var in all_variables}
6569

6670
provider_variable_map = get_model_provider_variable_mapping()
67-
registered_providers = get_model_providers()
71+
registered_providers = _get_registered_provider_names()
6872
provider_candidates = [
6973
*provider_variable_map,
7074
*(
@@ -76,7 +80,7 @@ async def get_enabled_providers_for_user(
7680
provider_policy = resolve_model_provider_policy(
7781
user_id=user_id,
7882
providers=[*registered_providers, *provider_candidates],
79-
purpose=ModelProviderPolicyPurpose.USE,
83+
purpose=ModelProviderPolicyPurpose.CONFIGURE,
8084
)
8185

8286
enabled_providers = []

0 commit comments

Comments
 (0)