Merge remote-tracking branch 'origin/release-1.9.2' into release-1.10.0

# Conflicts:
#	.secrets.baseline
#	pyproject.toml
#	src/backend/base/langflow/initial_setup/starter_projects/Knowledge Retrieval.json
#	src/backend/base/langflow/initial_setup/starter_projects/Vector Store RAG.json
#	src/backend/base/pyproject.toml
#	src/frontend/package-lock.json
#	src/frontend/package.json
#	src/lfx/pyproject.toml
#	src/lfx/src/lfx/_assets/component_index.json
#	src/sdk/pyproject.toml
#	uv.lock
This commit is contained in:
Eric Hare
2026-05-01 14:22:58 -07:00
75 changed files with 4879 additions and 2172 deletions

File diff suppressed because it is too large Load Diff

View File

@ -90,4 +90,26 @@ This telemetry event is sent when an unhandled exception is captured by Langflow
- **Type**: The exception class name, such as `ValueError`.
- **Message**: The exception message that was raised.
- **Context**: Additional contextual information related to where the exception occurred, such as route, component, or operation details, when available.
- **StackTraceHash**: A hash of the stack trace used to group similar exceptions for easier analysis.
- **StackTraceHash**: A hash of the stack trace used to group similar exceptions for easier analysis.
### Deployment provider
This telemetry event is sent for various lifecycle operations on deployment provider accounts, such as create, delete, and update.
- **DeploymentAction**: The specific action performed, such as `provider.create` or `provider.delete`.
- **DeploymentProvider**: The deployment provider used, such as `watsonx-orchestrate`.
- **DeploymentSeconds**: Duration in seconds for the operation, providing performance insights.
- **DeploymentSuccess**: Boolean value indicating whether the operation was successful.
- **DeploymentErrorMessage**: Error message details if the operation was unsuccessful.
- **WxoTenantId**: A unique identifier for the tenant, populated only for `watsonx-orchestrate` deployments, used to understand multi-tenant usage patterns without collecting personal information.
### Deployment
This telemetry event is sent for various lifecycle operations on deployment resources, such as create, delete, and update.
- **DeploymentAction**: The specific action performed, such as `deployment.create`.
- **DeploymentProvider**: The deployment provider used, such as `watsonx-orchestrate`.
- **DeploymentSeconds**: Duration in seconds for the operation, providing performance insights.
- **DeploymentSuccess**: Boolean value indicating whether the operation was successful.
- **DeploymentErrorMessage**: Error message details if the operation was unsuccessful.
- **WxoTenantId**: A unique identifier for the tenant, populated only for `watsonx-orchestrate` deployments, used to understand multi-tenant usage patterns without collecting personal information.

View File

@ -8,6 +8,14 @@ import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import PartialGlobalModelProviders from '@site/docs/_partial-global-model-providers.mdx';
:::tip
As of Langflow 1.9.2, the IBM watsonx Orchestrate deployments feature is behind a feature flag. To enable it, set the following environment variable before starting Langflow:
```bash
LANGFLOW_FEATURE_WXO_DEPLOYMENTS=true
```
:::
Create a flow and deploy it to [IBM watsonx Orchestrate](https://www.ibm.com/docs/en/watsonx/watson-orchestrate/base?topic=getting-started-watsonx-orchestrate).
Deploying a flow on IBM watsonx Orchestrate is different from the other Langflow deployment options.

View File

@ -90,4 +90,26 @@ This telemetry event is sent when an unhandled exception is captured by Langflow
- **Type**: The exception class name, such as `ValueError`.
- **Message**: The exception message that was raised.
- **Context**: Additional contextual information related to where the exception occurred, such as route, component, or operation details, when available.
- **StackTraceHash**: A hash of the stack trace used to group similar exceptions for easier analysis.
- **StackTraceHash**: A hash of the stack trace used to group similar exceptions for easier analysis.
### Deployment provider
This telemetry event is sent for various lifecycle operations on deployment provider accounts, such as create, delete, and update.
- **DeploymentAction**: The specific action performed, such as `provider.create` or `provider.delete`.
- **DeploymentProvider**: The deployment provider used, such as `watsonx-orchestrate`.
- **DeploymentSeconds**: Duration in seconds for the operation, providing performance insights.
- **DeploymentSuccess**: Boolean value indicating whether the operation was successful.
- **DeploymentErrorMessage**: Error message details if the operation was unsuccessful.
- **WxoTenantId**: A unique identifier for the tenant, populated only for `watsonx-orchestrate` deployments, used to understand multi-tenant usage patterns without collecting personal information.
### Deployment
This telemetry event is sent for various lifecycle operations on deployment resources, such as create, delete, and update.
- **DeploymentAction**: The specific action performed, such as `deployment.create`.
- **DeploymentProvider**: The deployment provider used, such as `watsonx-orchestrate`.
- **DeploymentSeconds**: Duration in seconds for the operation, providing performance insights.
- **DeploymentSuccess**: Boolean value indicating whether the operation was successful.
- **DeploymentErrorMessage**: Error message details if the operation was unsuccessful.
- **WxoTenantId**: A unique identifier for the tenant, populated only for `watsonx-orchestrate` deployments, used to understand multi-tenant usage patterns without collecting personal information.

View File

@ -112,6 +112,8 @@ For all changes, see the [Changelog](https://github.com/langflow-ai/langflow/rel
This workflow packages a selected flow version for use in IBM watsonx Orchestrate.
For more information, see [Deploy Langflow on watsonx Orchestrate](../Deployment/deployment-wxo.mdx).
As of Langflow 1.9.2, this feature is behind a feature flag. To enable it, set `LANGFLOW_FEATURE_WXO_DEPLOYMENTS=true` before starting Langflow.
- **Policies** component (beta)
The **Policies** component uses [ToolGuard](https://github.com/AgentToolkit/toolguard) to generate guard code from natural-language business policies and apply it to agent tools.

View File

@ -145,7 +145,7 @@ override-dependencies = [
"Markdown>=3.8.0",
"dynaconf>=3.2.13",
"pillow>=12.1.1", # Force Pillow 12.1.1+ to prevent CVE-vulnerable versions
"playwright>=1.58.0", # Latest available on PyPI; ensures updated Chromium with CVE fixes
"playwright>=1.59.0", # Latest available on PyPI; ensures updated Chromium with CVE fixes
]
[project.scripts]

View File

@ -43,6 +43,7 @@ from langflow.api.utils.flow_utils import (
build_graph_from_db,
build_graph_from_db_no_cache,
cascade_delete_flow,
scope_session_to_namespace,
verify_public_flow_and_get_user,
)
@ -85,6 +86,7 @@ __all__ = [
"parse_value",
"raise_error_if_astra_cloud_env",
"remove_api_keys",
"scope_session_to_namespace",
"validate_is_component",
"verify_public_flow_and_get_user",
]

View File

@ -135,6 +135,26 @@ def compute_virtual_flow_id(identifier: str | uuid.UUID, flow_id: uuid.UUID) ->
return uuid.uuid5(uuid.NAMESPACE_DNS, f"{identifier}_{flow_id}")
def scope_session_to_namespace(session: str | None, namespace: str) -> str | None:
"""Wrap a caller-supplied session ID under a (client_id, flow_id) namespace.
Mitigates CVE-2026-33017: an unauthenticated public-flow caller cannot
address a session that lives outside its own namespace through a Memory
component, regardless of whether the caller supplies a non-empty,
pre-prefixed, or empty string.
Returns ``None`` unchanged. Returns the value unchanged when it equals the
namespace or already starts with ``f"{namespace}:"``. Otherwise prefixes
it -- including the empty-string case, which becomes ``f"{namespace}:"``.
"""
if session is None:
return session
prefix = f"{namespace}:"
if session == namespace or session.startswith(prefix):
return session
return f"{prefix}{session}"
async def verify_public_flow_and_get_user(
flow_id: uuid.UUID,
client_id: str | None,

View File

@ -31,6 +31,7 @@ from langflow.api.utils import (
format_exception_message,
get_top_level_vertices,
parse_exception,
scope_session_to_namespace,
verify_public_flow_and_get_user,
)
from langflow.api.v1.schemas import (
@ -661,6 +662,9 @@ async def build_public_tmp(
- The 'data' parameter is NOT accepted to prevent flow definition tampering
- Public flows must execute the stored flow definition only
- The flow definition is always loaded from the database
- Caller-supplied 'inputs.session' is namespaced under the (client_id,
flow_id) virtual flow ID so an unauthenticated caller cannot address a
session that lives outside its own namespace (CVE-2026-33017)
The endpoint:
1. Verifies the requested flow is marked as public in the database
@ -703,6 +707,12 @@ async def build_public_tmp(
authenticated_user_id=authenticated_user_id,
)
# Defends CVE-2026-33017: scope caller session into the (client_id, flow_id) namespace.
if inputs is not None and inputs.session is not None:
scoped_session = scope_session_to_namespace(inputs.session, str(new_flow_id))
if scoped_session != inputs.session:
inputs = inputs.model_copy(update={"session": scoped_session})
# Validate the stored flow data after the public-access boundary.
# Public flows never accept client-supplied data.
async with session_scope() as session:

View File

@ -1,5 +1,8 @@
from __future__ import annotations
import time
from collections.abc import AsyncIterator
from dataclasses import dataclass
from typing import Annotated
from uuid import UUID
@ -107,6 +110,57 @@ from langflow.services.database.models.flow_version_deployment_attachment.crud i
list_deployment_attachments_for_flow_version_ids,
update_flow_version_by_provider_snapshot_id,
)
from langflow.services.deps import get_telemetry_service
from langflow.services.telemetry.schema import DeploymentPayload
@dataclass
class DeploymentTelemetryCtx:
"""Mutable context that routes write into; passed via Depends."""
provider: str = "unknown"
wxo_tenant_id: str | None = None
def _make_telemetry_dep(action: str, log_method_name: str):
async def _dep() -> AsyncIterator[DeploymentTelemetryCtx]:
ctx = DeploymentTelemetryCtx()
started_at = time.perf_counter()
success: bool = True
error_message: str = ""
try:
yield ctx
except Exception as exc:
success = False
error_message = str(exc)
raise
finally:
try:
ts = get_telemetry_service()
payload = DeploymentPayload(
deployment_action=action,
deployment_provider=ctx.provider,
deployment_seconds=time.perf_counter() - started_at,
deployment_success=success,
deployment_error_message=error_message,
wxo_tenant_id=ctx.wxo_tenant_id,
)
await getattr(ts, log_method_name)(payload)
except Exception: # noqa: BLE001
logger.debug("deployment telemetry emit failed", exc_info=True)
return _dep
deployment_create_telemetry = _make_telemetry_dep("deployment.create", "log_package_deployment")
deployment_update_telemetry = _make_telemetry_dep("deployment.update", "log_package_deployment")
deployment_delete_telemetry = _make_telemetry_dep("deployment.delete", "log_package_deployment")
deployment_run_telemetry = _make_telemetry_dep("deployment.run", "log_package_deployment_run")
provider_create_telemetry = _make_telemetry_dep("provider.create", "log_package_deployment_provider")
provider_update_telemetry = _make_telemetry_dep("provider.update", "log_package_deployment_provider")
provider_delete_telemetry = _make_telemetry_dep("provider.delete", "log_package_deployment_provider")
snapshot_update_telemetry = _make_telemetry_dep("snapshot.update", "log_package_deployment")
router = APIRouter(prefix="/deployments", tags=["Deployments"], include_in_schema=False)
@ -267,7 +321,9 @@ async def create_provider_account(
session: DbSession,
payload: DeploymentProviderAccountCreateRequest,
current_user: CurrentActiveUser,
telemetry: Annotated[DeploymentTelemetryCtx, Depends(provider_create_telemetry)],
):
telemetry.provider = payload.provider_key
deployment_mapper = get_deployment_mapper(payload.provider_key)
deployment_adapter = resolve_deployment_adapter(payload.provider_key)
@ -289,6 +345,7 @@ async def create_provider_account(
)
except ValueError as exc:
_raise_http_for_provider_account_value_error(exc)
telemetry.wxo_tenant_id = provider_account.provider_tenant_id
return deployment_mapper.resolve_provider_account_response(provider_account)
@ -338,12 +395,15 @@ async def delete_provider_account(
provider_id: DeploymentProviderAccountIdPath,
session: DbSession,
current_user: CurrentActiveUser,
telemetry: Annotated[DeploymentTelemetryCtx, Depends(provider_delete_telemetry)],
):
provider_account = await get_owned_provider_account_or_404(
provider_id=provider_id,
user_id=current_user.id,
db=session,
)
telemetry.provider = provider_account.provider_key
telemetry.wxo_tenant_id = provider_account.provider_tenant_id
deployment_count = await _count_provider_deployments_after_reconciliation(
session=session,
provider_account=provider_account,
@ -371,12 +431,15 @@ async def update_provider_account(
session: DbSession,
payload: DeploymentProviderAccountUpdateRequest,
current_user: CurrentActiveUser,
telemetry: Annotated[DeploymentTelemetryCtx, Depends(provider_update_telemetry)],
):
provider_account = await get_owned_provider_account_or_404(
provider_id=provider_id,
user_id=current_user.id,
db=session,
)
telemetry.provider = provider_account.provider_key
telemetry.wxo_tenant_id = provider_account.provider_tenant_id
deployment_mapper = get_deployment_mapper(provider_account.provider_key)
verify_input = None
@ -421,6 +484,7 @@ async def create_deployment(
session: DbSession,
payload: DeploymentCreateRequest,
current_user: CurrentActiveUser,
telemetry: Annotated[DeploymentTelemetryCtx, Depends(deployment_create_telemetry)],
):
provider_id = payload.provider_id
provider_account = await get_owned_provider_account_or_404(
@ -428,6 +492,8 @@ async def create_deployment(
user_id=current_user.id,
db=session,
)
telemetry.provider = provider_account.provider_key
telemetry.wxo_tenant_id = provider_account.provider_tenant_id
# fail fast if the deployment name already exists
# we could have races but that is more
# acceptable than provider-side rollback failure
@ -767,12 +833,21 @@ async def create_deployment_run(
session: DbSession,
payload: RunCreateRequest,
current_user: CurrentActiveUser,
telemetry: Annotated[DeploymentTelemetryCtx, Depends(deployment_run_telemetry)],
):
deployment_row, deployment_adapter, deployment_mapper, _provider_key = await resolve_adapter_mapper_from_deployment(
(
deployment_row,
deployment_adapter,
deployment_mapper,
_provider_key,
provider_tenant_id,
) = await resolve_adapter_mapper_from_deployment(
deployment_id=deployment_id,
user_id=current_user.id,
db=session,
)
telemetry.provider = _provider_key
telemetry.wxo_tenant_id = provider_tenant_id
adapter_execution_payload = await deployment_mapper.resolve_execution_create(
deployment_resource_key=deployment_row.resource_key,
db=session,
@ -801,7 +876,13 @@ async def get_deployment_run(
session: DbSessionReadOnly,
current_user: CurrentActiveUser,
):
deployment_row, deployment_adapter, deployment_mapper, _provider_key = await resolve_adapter_mapper_from_deployment(
(
deployment_row,
deployment_adapter,
deployment_mapper,
_provider_key,
_provider_tenant_id,
) = await resolve_adapter_mapper_from_deployment(
deployment_id=deployment_id,
user_id=current_user.id,
db=session,
@ -966,6 +1047,7 @@ async def update_snapshot(
body: SnapshotUpdateRequest,
session: DbSession,
current_user: CurrentActiveUser,
telemetry: Annotated[DeploymentTelemetryCtx, Depends(snapshot_update_telemetry)],
):
"""Replace an existing provider snapshot's content with a new flow version.
@ -1021,6 +1103,8 @@ async def update_snapshot(
user_id=current_user.id,
db=session,
)
telemetry.provider = provider_account.provider_key
telemetry.wxo_tenant_id = provider_account.provider_tenant_id
deployment_adapter = resolve_deployment_adapter(provider_account.provider_key)
deployment_mapper = get_deployment_mapper(provider_account.provider_key)
@ -1132,7 +1216,13 @@ async def get_deployment(
session: DbSession,
current_user: CurrentActiveUser,
):
deployment_row, deployment_adapter, deployment_mapper, provider_key = await resolve_adapter_mapper_from_deployment(
(
deployment_row,
deployment_adapter,
deployment_mapper,
provider_key,
_provider_tenant_id,
) = await resolve_adapter_mapper_from_deployment(
deployment_id=deployment_id,
user_id=current_user.id,
db=session,
@ -1248,12 +1338,21 @@ async def update_deployment(
session: DbSession,
payload: DeploymentUpdateRequest,
current_user: CurrentActiveUser,
telemetry: Annotated[DeploymentTelemetryCtx, Depends(deployment_update_telemetry)],
):
deployment_row, deployment_adapter, deployment_mapper, provider_key = await resolve_adapter_mapper_from_deployment(
(
deployment_row,
deployment_adapter,
deployment_mapper,
provider_key,
provider_tenant_id,
) = await resolve_adapter_mapper_from_deployment(
deployment_id=deployment_id,
user_id=current_user.id,
db=session,
)
telemetry.provider = provider_key
telemetry.wxo_tenant_id = provider_tenant_id
deployment_row_id = deployment_row.id
deployment_resource_key = deployment_row.resource_key
deployment_provider_account_id = deployment_row.deployment_provider_account_id
@ -1350,14 +1449,17 @@ async def delete_deployment(
deployment_id: DeploymentIdPath,
session: DbSession,
current_user: CurrentActiveUser,
telemetry: Annotated[DeploymentTelemetryCtx, Depends(deployment_delete_telemetry)],
*,
include_provider: IncludeProviderDeleteQuery = True,
):
deployment_row, deployment_adapter, _provider_key = await resolve_adapter_from_deployment(
deployment_row, deployment_adapter, _provider_key, provider_tenant_id = await resolve_adapter_from_deployment(
deployment_id=deployment_id,
user_id=current_user.id,
db=session,
)
telemetry.provider = _provider_key
telemetry.wxo_tenant_id = provider_tenant_id
if include_provider:
try:
with handle_adapter_errors(), deployment_provider_scope(deployment_row.deployment_provider_account_id):
@ -1393,7 +1495,7 @@ async def get_deployment_status(
session: DbSessionReadOnly,
current_user: CurrentActiveUser,
):
deployment_row, deployment_adapter, provider_key = await resolve_adapter_from_deployment(
deployment_row, deployment_adapter, provider_key, _provider_tenant_id = await resolve_adapter_from_deployment(
deployment_id=deployment_id,
user_id=current_user.id,
db=session,
@ -1438,7 +1540,13 @@ async def list_deployment_flow_versions(
),
] = None,
):
deployment_row, deployment_adapter, deployment_mapper, _provider_key = await resolve_adapter_mapper_from_deployment(
(
deployment_row,
deployment_adapter,
deployment_mapper,
_provider_key,
_provider_tenant_id,
) = await resolve_adapter_mapper_from_deployment(
deployment_id=deployment_id,
user_id=current_user.id,
db=session,

View File

@ -437,8 +437,8 @@ async def resolve_adapter_from_deployment(
deployment_id: UUID,
user_id: UUID,
db: DbSession,
) -> tuple[Deployment, DeploymentServiceProtocol, str]:
"""Returns ``(deployment_row, adapter, provider_key)``."""
) -> tuple[Deployment, DeploymentServiceProtocol, str, str | None]:
"""Returns ``(deployment_row, adapter, provider_key, provider_tenant_id)``."""
deployment_row = await get_deployment_row_or_404(deployment_id=deployment_id, user_id=user_id, db=db)
provider_account = await get_owned_provider_account_or_404(
provider_id=deployment_row.deployment_provider_account_id,
@ -446,7 +446,7 @@ async def resolve_adapter_from_deployment(
db=db,
)
deployment_adapter = resolve_deployment_adapter(provider_account.provider_key)
return deployment_row, deployment_adapter, provider_account.provider_key
return deployment_row, deployment_adapter, provider_account.provider_key, provider_account.provider_tenant_id
async def resolve_adapter_mapper_from_deployment(
@ -454,8 +454,8 @@ async def resolve_adapter_mapper_from_deployment(
deployment_id: UUID,
user_id: UUID,
db: DbSession,
) -> tuple[Deployment, DeploymentServiceProtocol, BaseDeploymentMapper, str]:
"""Returns ``(deployment_row, adapter, mapper, provider_key)``."""
) -> tuple[Deployment, DeploymentServiceProtocol, BaseDeploymentMapper, str, str | None]:
"""Returns ``(deployment_row, adapter, mapper, provider_key, provider_tenant_id)``."""
from langflow.api.v1.mappers.deployments.registry import get_deployment_mapper
deployment_row = await get_deployment_row_or_404(deployment_id=deployment_id, user_id=user_id, db=db)
@ -466,7 +466,13 @@ async def resolve_adapter_mapper_from_deployment(
)
deployment_adapter = resolve_deployment_adapter(provider_account.provider_key)
deployment_mapper = get_deployment_mapper(provider_account.provider_key)
return deployment_row, deployment_adapter, deployment_mapper, provider_account.provider_key
return (
deployment_row,
deployment_adapter,
deployment_mapper,
provider_account.provider_key,
provider_account.provider_tenant_id,
)
async def resolve_project_id_for_deployment_create(

View File

@ -1320,7 +1320,7 @@
},
{
"name": "langchain_core",
"version": "1.3.1"
"version": "1.3.2"
},
{
"name": "pydantic",
@ -1328,7 +1328,7 @@
},
{
"name": "googleapiclient",
"version": "2.194.0"
"version": "2.195.0"
}
],
"total_dependencies": 4

View File

@ -1522,7 +1522,7 @@
},
{
"name": "langchain_core",
"version": "1.3.1"
"version": "1.3.2"
},
{
"name": "lfx",

View File

@ -2086,7 +2086,7 @@
},
{
"name": "langchain_core",
"version": "1.3.1"
"version": "1.3.2"
}
],
"total_dependencies": 3

View File

@ -1196,7 +1196,7 @@
},
{
"name": "langchain_core",
"version": "1.3.1"
"version": "1.3.2"
}
],
"total_dependencies": 3

View File

@ -562,7 +562,7 @@
},
{
"name": "langchain_openai",
"version": "1.2.0"
"version": "1.2.1"
},
{
"name": "langchain_huggingface",
@ -582,7 +582,7 @@
},
{
"name": "langchain_ibm",
"version": "1.0.6"
"version": "1.0.7"
}
],
"total_dependencies": 11

View File

@ -1206,7 +1206,7 @@
},
{
"name": "langchain_core",
"version": "1.3.1"
"version": "1.3.2"
}
],
"total_dependencies": 3

View File

@ -1190,7 +1190,7 @@
},
{
"name": "langchain_core",
"version": "1.3.1"
"version": "1.3.2"
}
],
"total_dependencies": 3
@ -1805,7 +1805,7 @@
},
{
"name": "googleapiclient",
"version": "2.194.0"
"version": "2.195.0"
}
],
"total_dependencies": 7

View File

@ -814,7 +814,7 @@
},
{
"name": "langchain_core",
"version": "1.3.1"
"version": "1.3.2"
}
],
"total_dependencies": 3
@ -2127,7 +2127,7 @@
"dependencies": [
{
"name": "langchain_core",
"version": "1.3.1"
"version": "1.3.2"
},
{
"name": "pydantic",

View File

@ -1255,7 +1255,7 @@
},
{
"name": "langchain_core",
"version": "1.3.1"
"version": "1.3.2"
}
],
"total_dependencies": 3

View File

@ -945,7 +945,7 @@
},
{
"name": "langchain_core",
"version": "1.3.1"
"version": "1.3.2"
},
{
"name": "pydantic",
@ -953,7 +953,7 @@
},
{
"name": "googleapiclient",
"version": "2.194.0"
"version": "2.195.0"
}
],
"total_dependencies": 4

View File

@ -1625,7 +1625,7 @@
},
{
"name": "langchain_core",
"version": "1.3.1"
"version": "1.3.2"
}
],
"total_dependencies": 3

View File

@ -2825,7 +2825,7 @@
},
{
"name": "langchain_core",
"version": "1.3.1"
"version": "1.3.2"
}
],
"total_dependencies": 3

View File

@ -907,7 +907,7 @@
},
{
"name": "langchain_core",
"version": "1.3.1"
"version": "1.3.2"
}
],
"total_dependencies": 3

View File

@ -956,7 +956,7 @@
},
{
"name": "langchain_core",
"version": "1.3.1"
"version": "1.3.2"
}
],
"total_dependencies": 3

View File

@ -371,7 +371,7 @@
},
{
"name": "langchain_core",
"version": "1.3.1"
"version": "1.3.2"
}
],
"total_dependencies": 3
@ -980,7 +980,7 @@
},
{
"name": "langchain_core",
"version": "1.3.1"
"version": "1.3.2"
}
],
"total_dependencies": 3
@ -2447,7 +2447,7 @@
},
{
"name": "langchain_core",
"version": "1.3.1"
"version": "1.3.2"
}
],
"total_dependencies": 3
@ -3040,7 +3040,7 @@
},
{
"name": "langchain_core",
"version": "1.3.1"
"version": "1.3.2"
},
{
"name": "pydantic",

View File

@ -954,7 +954,7 @@
},
{
"name": "langchain_core",
"version": "1.3.1"
"version": "1.3.2"
}
],
"total_dependencies": 3

View File

@ -160,7 +160,7 @@
},
{
"name": "langchain_core",
"version": "1.3.1"
"version": "1.3.2"
},
{
"name": "pydantic",
@ -392,7 +392,7 @@
},
{
"name": "langchain_core",
"version": "1.3.1"
"version": "1.3.2"
},
{
"name": "pydantic",
@ -1306,7 +1306,7 @@
},
{
"name": "langchain_core",
"version": "1.3.1"
"version": "1.3.2"
}
],
"total_dependencies": 3

View File

@ -2637,7 +2637,7 @@
},
{
"name": "langchain_core",
"version": "1.3.1"
"version": "1.3.2"
},
{
"name": "pydantic",
@ -2645,7 +2645,7 @@
},
{
"name": "googleapiclient",
"version": "2.194.0"
"version": "2.195.0"
}
],
"total_dependencies": 4

View File

@ -1719,7 +1719,7 @@
},
{
"name": "langchain_core",
"version": "1.3.1"
"version": "1.3.2"
}
],
"total_dependencies": 3
@ -2323,7 +2323,7 @@
},
{
"name": "langchain_core",
"version": "1.3.1"
"version": "1.3.2"
}
],
"total_dependencies": 3
@ -2927,7 +2927,7 @@
},
{
"name": "langchain_core",
"version": "1.3.1"
"version": "1.3.2"
}
],
"total_dependencies": 3

View File

@ -1639,7 +1639,7 @@
},
{
"name": "langchain_core",
"version": "1.3.1"
"version": "1.3.2"
},
{
"name": "pydantic",
@ -1647,7 +1647,7 @@
},
{
"name": "googleapiclient",
"version": "2.194.0"
"version": "2.195.0"
}
],
"total_dependencies": 4
@ -2691,7 +2691,7 @@
},
{
"name": "cryptography",
"version": "46.0.7"
"version": "47.0.0"
},
{
"name": "langchain_chroma",
@ -3077,7 +3077,7 @@
},
{
"name": "langchain_openai",
"version": "1.2.0"
"version": "1.2.1"
},
{
"name": "langchain_huggingface",
@ -3097,7 +3097,7 @@
},
{
"name": "langchain_ibm",
"version": "1.0.6"
"version": "1.0.7"
}
],
"total_dependencies": 11

View File

@ -274,7 +274,7 @@
},
{
"name": "googleapiclient",
"version": "2.194.0"
"version": "2.195.0"
},
{
"name": "lfx",
@ -514,7 +514,7 @@
},
{
"name": "langchain_core",
"version": "1.3.1"
"version": "1.3.2"
}
],
"total_dependencies": 3

View File

@ -19,6 +19,15 @@ class RunPayload(BasePayload):
run_id: str | None = Field(None, serialization_alias="runId")
class DeploymentPayload(BasePayload):
deployment_action: str = Field(serialization_alias="deploymentAction")
deployment_provider: str = Field(serialization_alias="deploymentProvider")
deployment_seconds: float = Field(serialization_alias="deploymentSeconds")
deployment_success: bool = Field(serialization_alias="deploymentSuccess")
deployment_error_message: str = Field(default="", serialization_alias="deploymentErrorMessage")
wxo_tenant_id: str | None = Field(default=None, serialization_alias="wxoTenantId")
class ShutdownPayload(BasePayload):
time_running: int = Field(serialization_alias="timeRunning")

View File

@ -18,6 +18,7 @@ from langflow.services.telemetry.schema import (
ComponentIndexPayload,
ComponentInputsPayload,
ComponentPayload,
DeploymentPayload,
EmailPayload,
ExceptionPayload,
PlaygroundPayload,
@ -110,6 +111,15 @@ class TelemetryService(Service):
async def log_package_run(self, payload: RunPayload) -> None:
await self._queue_event((self.send_telemetry_data, payload, "run"))
async def log_package_deployment(self, payload: DeploymentPayload) -> None:
await self._queue_event((self.send_telemetry_data, payload, "deployment"))
async def log_package_deployment_provider(self, payload: DeploymentPayload) -> None:
await self._queue_event((self.send_telemetry_data, payload, "deployment_provider"))
async def log_package_deployment_run(self, payload: DeploymentPayload) -> None:
await self._queue_event((self.send_telemetry_data, payload, "deployment_run"))
async def log_package_shutdown(self) -> None:
payload = ShutdownPayload(time_running=(datetime.now(timezone.utc) - self._start_time).seconds)
await self._queue_event(payload)

View File

@ -257,7 +257,7 @@ fastavro = [
'fastavro==1.9.7; python_version < "3.13"',
'fastavro>=1.9.8,<2.0.0; python_version >= "3.13"',
]
gitpython = ["GitPython==3.1.43"]
gitpython = ["GitPython==3.1.47"]
nltk = ["nltk>=3.9.4"]
lark = ["lark==1.2.2"]

1640
src/backend/base/uv.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -16,6 +16,7 @@ from uuid import uuid4
import pytest
from fastapi import HTTPException
from langflow.api.v1.deployments import DeploymentTelemetryCtx
from langflow.api.v1.mappers.deployments.contracts import ProviderSnapshotBinding
from langflow.api.v1.schemas.deployments import (
DeploymentLlmListResponse,
@ -58,12 +59,14 @@ def _fake_provider_account(
provider_key: str = DeploymentProviderKey.WATSONX_ORCHESTRATE,
provider_url: str = "https://api.us-south.wxo.cloud.ibm.com/instances/tenant-1",
api_key: str = "encrypted-key",
provider_tenant_id: str | None = "tenant-1",
) -> SimpleNamespace:
return SimpleNamespace(
id=uuid4(),
provider_key=provider_key,
provider_url=provider_url,
api_key=api_key,
provider_tenant_id=provider_tenant_id,
)
@ -85,6 +88,10 @@ def _fake_user() -> SimpleNamespace:
return SimpleNamespace(id=uuid4())
def _fake_telemetry() -> DeploymentTelemetryCtx:
return DeploymentTelemetryCtx()
def _fake_attachment(*, provider_snapshot_id: str | None = None) -> SimpleNamespace:
return SimpleNamespace(
flow_version_id=uuid4(),
@ -182,7 +189,9 @@ class TestCreateDeploymentRollback:
payload.description = None
with pytest.raises(RuntimeError, match="DB commit failed"):
await create_deployment(session=session, payload=payload, current_user=_fake_user())
await create_deployment(
session=session, payload=payload, current_user=_fake_user(), telemetry=_fake_telemetry()
)
mock_rollback.assert_awaited_once()
assert mock_rollback.call_args.kwargs["resource_id"] == create_result.id
@ -240,7 +249,9 @@ class TestCreateDeploymentRollback:
payload.description = None
mapper.shape_deployment_create_result.return_value = MagicMock()
await create_deployment(session=session, payload=payload, current_user=_fake_user())
await create_deployment(
session=session, payload=payload, current_user=_fake_user(), telemetry=_fake_telemetry()
)
mock_rollback.assert_not_awaited()
@ -301,7 +312,9 @@ class TestCreateDeploymentRollback:
payload.description = "desc"
with pytest.raises(RuntimeError, match="DB commit failed"):
await create_deployment(session=session, payload=payload, current_user=_fake_user())
await create_deployment(
session=session, payload=payload, current_user=_fake_user(), telemetry=_fake_telemetry()
)
mock_rollback.assert_awaited_once()
assert mock_rollback.call_args.kwargs["resource_id"] == "existing-agent-1"
@ -362,7 +375,9 @@ class TestCreateDeploymentExistingAgent:
payload.description = None
mapper.shape_deployment_create_result.return_value = MagicMock()
await create_deployment(session=session, payload=payload, current_user=_fake_user())
await create_deployment(
session=session, payload=payload, current_user=_fake_user(), telemetry=_fake_telemetry()
)
_ = (mock_name_exists, mock_get_by_resource_key, mock_validate_fv, mock_attach)
adapter.create.assert_not_awaited()
@ -423,7 +438,9 @@ class TestCreateDeploymentExistingAgent:
payload.description = "desc"
mapper.shape_deployment_create_result.return_value = MagicMock()
await create_deployment(session=session, payload=payload, current_user=_fake_user())
await create_deployment(
session=session, payload=payload, current_user=_fake_user(), telemetry=_fake_telemetry()
)
_ = (mock_name_exists, mock_get_by_resource_key, mock_validate_fv, mock_attach)
adapter.create.assert_not_awaited()
@ -487,7 +504,9 @@ class TestCreateDeploymentExistingAgent:
payload.description = "desc"
mapper.shape_deployment_create_result.return_value = MagicMock()
await create_deployment(session=session, payload=payload, current_user=_fake_user())
await create_deployment(
session=session, payload=payload, current_user=_fake_user(), telemetry=_fake_telemetry()
)
_ = (mock_name_exists, mock_get_by_resource_key, mock_validate_fv, mock_attach)
adapter.create.assert_not_awaited()
@ -538,7 +557,9 @@ class TestCreateDeploymentExistingAgent:
payload.description = None
with pytest.raises(HTTPException) as exc_info:
await create_deployment(session=AsyncMock(), payload=payload, current_user=_fake_user())
await create_deployment(
session=AsyncMock(), payload=payload, current_user=_fake_user(), telemetry=_fake_telemetry()
)
assert exc_info.value.status_code == 409
_ = mock_name_exists
@ -1216,6 +1237,7 @@ class TestUpdateSnapshotRoute:
body=SnapshotUpdateRequest(flow_version_id=target_flow_version_id),
session=session,
current_user=user,
telemetry=_fake_telemetry(),
)
assert response.flow_version_id == target_flow_version_id
@ -1294,6 +1316,7 @@ class TestUpdateSnapshotRoute:
body=SnapshotUpdateRequest(flow_version_id=target_flow_version_id),
session=session,
current_user=user,
telemetry=_fake_telemetry(),
)
session.commit.assert_awaited_once()
@ -1330,7 +1353,7 @@ class TestListDeploymentFlowVersionsRoute:
)
]
)
mock_resolve.return_value = (deployment_row, adapter, mapper, "watsonx-orchestrate")
mock_resolve.return_value = (deployment_row, adapter, mapper, "watsonx-orchestrate", "tenant-1")
mock_list_flow_versions_synced.return_value = (rows, 7, snapshot_result)
mapper.shape_flow_version_list_result.return_value = SimpleNamespace(
page=2,
@ -1411,6 +1434,7 @@ class TestProviderAccountRoutes:
session=session,
payload=DeploymentProviderAccountUpdateRequest(name="renamed"),
current_user=_fake_user(),
telemetry=_fake_telemetry(),
)
mapper.resolve_verify_credentials_for_update.assert_not_called()
@ -1452,6 +1476,7 @@ class TestProviderAccountRoutes:
session=AsyncMock(),
payload=DeploymentProviderAccountUpdateRequest(provider_data={"api_key": "new-api-key"}),
current_user=_fake_user(),
telemetry=_fake_telemetry(),
)
assert exc_info.value.status_code == 401
@ -1493,6 +1518,7 @@ class TestProviderAccountRoutes:
session=AsyncMock(),
payload=payload,
current_user=_fake_user(),
telemetry=_fake_telemetry(),
)
assert exc_info.value.status_code == 409
@ -1531,6 +1557,7 @@ class TestProviderAccountRoutes:
session=AsyncMock(),
payload=DeploymentProviderAccountUpdateRequest(name="prod"),
current_user=_fake_user(),
telemetry=_fake_telemetry(),
)
assert exc_info.value.status_code == 409
@ -1575,6 +1602,7 @@ class TestProviderAccountRoutes:
session=AsyncMock(),
payload=DeploymentProviderAccountUpdateRequest(provider_data={"tenant_id": "tenant-renamed"}),
current_user=_fake_user(),
telemetry=_fake_telemetry(),
)
@pytest.mark.asyncio
@ -1615,6 +1643,7 @@ class TestProviderAccountRoutes:
session=AsyncMock(),
payload=DeploymentProviderAccountUpdateRequest(provider_data={"tenant_id": "tenant-renamed"}),
current_user=_fake_user(),
telemetry=_fake_telemetry(),
)
@pytest.mark.asyncio
@ -1646,6 +1675,7 @@ class TestProviderAccountRoutes:
provider_id=existing_account.id,
session=AsyncMock(),
current_user=_fake_user(),
telemetry=_fake_telemetry(),
)
assert exc_info.value.status_code == 409
@ -1682,6 +1712,7 @@ class TestProviderAccountRoutes:
provider_id=existing_account.id,
session=session,
current_user=_fake_user(),
telemetry=_fake_telemetry(),
)
assert response.status_code == 204
@ -1736,6 +1767,7 @@ class TestProviderAccountRoutes:
provider_id=existing_account.id,
session=AsyncMock(),
current_user=_fake_user(),
telemetry=_fake_telemetry(),
)
assert response.status_code == 204
@ -1783,6 +1815,7 @@ class TestProviderAccountRoutes:
provider_id=existing_account.id,
session=AsyncMock(),
current_user=_fake_user(),
telemetry=_fake_telemetry(),
)
assert exc_info.value.status_code == 409
@ -1817,6 +1850,7 @@ class TestProviderAccountRoutes:
provider_id=existing_account.id,
session=session,
current_user=_fake_user(),
telemetry=_fake_telemetry(),
)
assert response.status_code == 204
@ -1862,7 +1896,7 @@ class TestUpdateDeploymentRollback:
adapter.update.return_value = update_result
mapper.resolve_deployment_update = AsyncMock(return_value=MagicMock())
mapper.shape_deployment_update_result.return_value = MagicMock()
mock_resolve_amm.return_value = (dep_row, adapter, mapper, "watsonx-orchestrate")
mock_resolve_amm.return_value = (dep_row, adapter, mapper, "watsonx-orchestrate", "tenant-1")
session = AsyncMock()
session.commit.side_effect = RuntimeError("DB commit failed")
@ -1877,6 +1911,7 @@ class TestUpdateDeploymentRollback:
session=session,
payload=payload,
current_user=_fake_user(),
telemetry=_fake_telemetry(),
)
mock_rollback.assert_awaited_once()
@ -1914,7 +1949,7 @@ class TestUpdateDeploymentRollback:
adapter.update.return_value = update_result
mapper.resolve_deployment_update = AsyncMock(return_value=MagicMock())
mapper.shape_deployment_update_result.return_value = MagicMock()
mock_resolve_amm.return_value = (dep_row, adapter, mapper, "watsonx-orchestrate")
mock_resolve_amm.return_value = (dep_row, adapter, mapper, "watsonx-orchestrate", "tenant-1")
session = AsyncMock()
session.commit.return_value = None
@ -1928,6 +1963,7 @@ class TestUpdateDeploymentRollback:
session=session,
payload=payload,
current_user=_fake_user(),
telemetry=_fake_telemetry(),
)
mock_rollback.assert_not_awaited()
@ -1970,7 +2006,7 @@ class TestUpdateDeploymentAlreadyAttachedFiltering:
adapter.update.return_value = update_result
mapper.resolve_deployment_update = AsyncMock(return_value=MagicMock())
mapper.shape_deployment_update_result.return_value = MagicMock()
mock_resolve_amm.return_value = (dep_row, adapter, mapper, "watsonx-orchestrate")
mock_resolve_amm.return_value = (dep_row, adapter, mapper, "watsonx-orchestrate", "tenant-1")
reused_fv_id = uuid4()
new_fv_id = uuid4()
@ -1992,6 +2028,7 @@ class TestUpdateDeploymentAlreadyAttachedFiltering:
session=session,
payload=payload,
current_user=_fake_user(),
telemetry=_fake_telemetry(),
)
mock_resolve_snap.assert_called_once()
@ -2028,7 +2065,7 @@ class TestUpdateDeploymentAlreadyAttachedFiltering:
adapter.update.return_value = update_result
mapper.resolve_deployment_update = AsyncMock(return_value=MagicMock())
mapper.shape_deployment_update_result.return_value = MagicMock()
mock_resolve_amm.return_value = (dep_row, adapter, mapper, "watsonx-orchestrate")
mock_resolve_amm.return_value = (dep_row, adapter, mapper, "watsonx-orchestrate", "tenant-1")
fv_id_1 = uuid4()
fv_id_2 = uuid4()
@ -2051,6 +2088,7 @@ class TestUpdateDeploymentAlreadyAttachedFiltering:
session=session,
payload=payload,
current_user=_fake_user(),
telemetry=_fake_telemetry(),
)
resolved_fv_ids = mock_resolve_snap.call_args.kwargs["added_flow_version_ids"]
@ -2082,7 +2120,7 @@ class TestUpdateDeploymentAlreadyAttachedFiltering:
adapter.update.return_value = update_result
mapper.resolve_deployment_update = AsyncMock(return_value=MagicMock())
mapper.shape_deployment_update_result.return_value = MagicMock()
mock_resolve_amm.return_value = (dep_row, adapter, mapper, "watsonx-orchestrate")
mock_resolve_amm.return_value = (dep_row, adapter, mapper, "watsonx-orchestrate", "tenant-1")
fv_id_1 = uuid4()
fv_id_2 = uuid4()
@ -2102,6 +2140,7 @@ class TestUpdateDeploymentAlreadyAttachedFiltering:
session=session,
payload=payload,
current_user=_fake_user(),
telemetry=_fake_telemetry(),
)
resolved_fv_ids = mock_resolve_snap.call_args.kwargs["added_flow_version_ids"]
@ -2143,7 +2182,7 @@ class TestUpdateDeploymentMetadataPersistence:
adapter.update.return_value = update_result
mapper.resolve_deployment_update = AsyncMock(return_value=MagicMock())
mapper.shape_deployment_update_result.return_value = MagicMock()
mock_resolve_amm.return_value = (dep_row, adapter, mapper, "watsonx-orchestrate")
mock_resolve_amm.return_value = (dep_row, adapter, mapper, "watsonx-orchestrate", "tenant-1")
mock_update_db.return_value = updated_row
session = AsyncMock()
@ -2161,6 +2200,7 @@ class TestUpdateDeploymentMetadataPersistence:
session=session,
payload=payload,
current_user=_fake_user(),
telemetry=_fake_telemetry(),
)
mock_update_db.assert_awaited_once()
@ -2193,7 +2233,7 @@ class TestGetDeploymentSync:
dep_row = _fake_deployment_row()
adapter = AsyncMock()
adapter.get.side_effect = DeploymentNotFoundError(message="gone")
mock_resolve.return_value = (dep_row, adapter, BaseDeploymentMapper(), "watsonx-orchestrate")
mock_resolve.return_value = (dep_row, adapter, BaseDeploymentMapper(), "watsonx-orchestrate", "tenant-1")
user = _fake_user()
session = AsyncMock()
@ -2220,7 +2260,7 @@ class TestGetDeploymentSync:
dep_row = _fake_deployment_row()
adapter = AsyncMock()
adapter.get.side_effect = AuthenticationError(message="bad creds", error_code="authentication_error")
mock_resolve.return_value = (dep_row, adapter, BaseDeploymentMapper(), "watsonx-orchestrate")
mock_resolve.return_value = (dep_row, adapter, BaseDeploymentMapper(), "watsonx-orchestrate", "tenant-1")
session = AsyncMock()
@ -2245,7 +2285,7 @@ class TestGetDeploymentSync:
dep_row = _fake_deployment_row()
adapter = AsyncMock()
adapter.get.side_effect = ServiceUnavailableError(message="provider down")
mock_resolve.return_value = (dep_row, adapter, BaseDeploymentMapper(), "watsonx-orchestrate")
mock_resolve.return_value = (dep_row, adapter, BaseDeploymentMapper(), "watsonx-orchestrate", "tenant-1")
session = AsyncMock()
@ -2298,7 +2338,7 @@ class TestGetDeploymentSync:
}
}
adapter.get.return_value = provider_deployment
mock_resolve.return_value = (dep_row, adapter, _MapperForGet(), "watsonx-orchestrate")
mock_resolve.return_value = (dep_row, adapter, _MapperForGet(), "watsonx-orchestrate", "tenant-1")
session = AsyncMock()
result = await get_deployment(deployment_id=dep_row.id, session=session, current_user=_fake_user())
@ -2340,7 +2380,7 @@ class TestGetDeploymentSync:
ProviderSnapshotBinding(resource_key=dep_row.resource_key, snapshot_id="snap-1")
]
mapper.shape_deployment_get_data.return_value = None
mock_resolve.return_value = (dep_row, adapter, mapper, "watsonx-orchestrate")
mock_resolve.return_value = (dep_row, adapter, mapper, "watsonx-orchestrate", "tenant-1")
att_good = _fake_attachment(provider_snapshot_id="snap-1")
mock_list_att.return_value = [att_good]
@ -2378,7 +2418,7 @@ class TestGetDeploymentSync:
adapter.get.return_value = provider_deployment
mapper.extract_snapshot_bindings_for_get.side_effect = NotImplementedError("not supported")
mapper.shape_deployment_get_data.return_value = None
mock_resolve.return_value = (dep_row, adapter, mapper, "watsonx-orchestrate")
mock_resolve.return_value = (dep_row, adapter, mapper, "watsonx-orchestrate", "tenant-1")
mock_list_att.return_value = [_fake_attachment(provider_snapshot_id=None)]
session = AsyncMock()
@ -2416,7 +2456,7 @@ class TestGetDeploymentSync:
ProviderSnapshotBinding(resource_key=dep_row.resource_key, snapshot_id="snap-1")
]
mapper.shape_deployment_get_data.return_value = None
mock_resolve.return_value = (dep_row, adapter, mapper, "watsonx-orchestrate")
mock_resolve.return_value = (dep_row, adapter, mapper, "watsonx-orchestrate", "tenant-1")
att1 = _fake_attachment(provider_snapshot_id="snap-1")
att2 = _fake_attachment(provider_snapshot_id="snap-2")
@ -2453,7 +2493,7 @@ class TestGetDeploymentSync:
ProviderSnapshotBinding(resource_key="agent-rk-1", snapshot_id="snap-1")
]
mapper.shape_deployment_get_data.return_value = None
mock_resolve.return_value = (dep_row, adapter, mapper, "watsonx-orchestrate")
mock_resolve.return_value = (dep_row, adapter, mapper, "watsonx-orchestrate", "tenant-1")
mock_list_att.return_value = [_fake_attachment(provider_snapshot_id="snap-1")]
session = AsyncMock()
@ -2490,12 +2530,14 @@ class TestDeleteDeployment:
dep_row = _fake_deployment_row()
adapter = AsyncMock()
adapter.delete.side_effect = DeploymentNotFoundError(message="gone")
mock_resolve.return_value = (dep_row, adapter, "watsonx-orchestrate")
mock_resolve.return_value = (dep_row, adapter, "watsonx-orchestrate", "tenant-1")
user = _fake_user()
session = AsyncMock()
response = await delete_deployment(deployment_id=dep_row.id, session=session, current_user=user)
response = await delete_deployment(
deployment_id=dep_row.id, session=session, current_user=user, telemetry=_fake_telemetry()
)
assert response.status_code == 204
mock_delete_row.assert_awaited_once_with(session, user_id=user.id, deployment_id=dep_row.id)
@ -2516,12 +2558,14 @@ class TestDeleteDeployment:
dep_row = _fake_deployment_row()
adapter = AsyncMock()
adapter.delete.side_effect = AuthenticationError(message="bad creds", error_code="authentication_error")
mock_resolve.return_value = (dep_row, adapter, "watsonx-orchestrate")
mock_resolve.return_value = (dep_row, adapter, "watsonx-orchestrate", "tenant-1")
session = AsyncMock()
with pytest.raises(HTTPException) as exc_info:
await delete_deployment(deployment_id=dep_row.id, session=session, current_user=_fake_user())
await delete_deployment(
deployment_id=dep_row.id, session=session, current_user=_fake_user(), telemetry=_fake_telemetry()
)
assert exc_info.value.status_code == 401
mock_delete_row.assert_not_awaited()
@ -2540,13 +2584,15 @@ class TestDeleteDeployment:
dep_row = _fake_deployment_row()
adapter = AsyncMock()
mock_resolve.return_value = (dep_row, adapter, "watsonx-orchestrate")
mock_resolve.return_value = (dep_row, adapter, "watsonx-orchestrate", "tenant-1")
user = _fake_user()
session = AsyncMock()
session.commit.side_effect = [RuntimeError("commit failed"), None]
response = await delete_deployment(deployment_id=dep_row.id, session=session, current_user=user)
response = await delete_deployment(
deployment_id=dep_row.id, session=session, current_user=user, telemetry=_fake_telemetry()
)
assert response.status_code == 204
assert mock_delete_row.await_count == 2
@ -2566,13 +2612,15 @@ class TestDeleteDeployment:
dep_row = _fake_deployment_row()
adapter = AsyncMock()
mock_resolve.return_value = (dep_row, adapter, "watsonx-orchestrate")
mock_resolve.return_value = (dep_row, adapter, "watsonx-orchestrate", "tenant-1")
session = AsyncMock()
session.commit.side_effect = [RuntimeError("commit failed"), RuntimeError("still failing")]
with pytest.raises(HTTPException) as exc_info:
await delete_deployment(deployment_id=dep_row.id, session=session, current_user=_fake_user())
await delete_deployment(
deployment_id=dep_row.id, session=session, current_user=_fake_user(), telemetry=_fake_telemetry()
)
assert exc_info.value.status_code == 500
assert mock_delete_row.await_count == 2
@ -2592,13 +2640,17 @@ class TestDeleteDeployment:
dep_row = _fake_deployment_row()
adapter = AsyncMock()
mock_resolve.return_value = (dep_row, adapter, "watsonx-orchestrate")
mock_resolve.return_value = (dep_row, adapter, "watsonx-orchestrate", "tenant-1")
user = _fake_user()
session = AsyncMock()
response = await delete_deployment(
deployment_id=dep_row.id, session=session, current_user=user, include_provider=True
deployment_id=dep_row.id,
session=session,
current_user=user,
include_provider=True,
telemetry=_fake_telemetry(),
)
assert response.status_code == 204
@ -2618,13 +2670,17 @@ class TestDeleteDeployment:
dep_row = _fake_deployment_row()
adapter = AsyncMock()
mock_resolve.return_value = (dep_row, adapter, "watsonx-orchestrate")
mock_resolve.return_value = (dep_row, adapter, "watsonx-orchestrate", "tenant-1")
user = _fake_user()
session = AsyncMock()
response = await delete_deployment(
deployment_id=dep_row.id, session=session, current_user=user, include_provider=False
deployment_id=dep_row.id,
session=session,
current_user=user,
include_provider=False,
telemetry=_fake_telemetry(),
)
assert response.status_code == 204
@ -2657,7 +2713,9 @@ class TestCreateDeploymentDuplicateName:
payload.name = "duplicate-name"
with pytest.raises(HTTPException) as exc_info:
await create_deployment(session=AsyncMock(), payload=payload, current_user=_fake_user())
await create_deployment(
session=AsyncMock(), payload=payload, current_user=_fake_user(), telemetry=_fake_telemetry()
)
assert exc_info.value.status_code == 409
assert "duplicate-name" in exc_info.value.detail
@ -2683,7 +2741,9 @@ class TestCreateDeploymentDuplicateName:
payload.name = "taken"
with pytest.raises(HTTPException):
await create_deployment(session=AsyncMock(), payload=payload, current_user=_fake_user())
await create_deployment(
session=AsyncMock(), payload=payload, current_user=_fake_user(), telemetry=_fake_telemetry()
)
mock_resolve_adapter.assert_not_called()
@ -2731,7 +2791,9 @@ class TestCreateDeploymentProjectValidation:
payload.provider_id = pa.id
with pytest.raises(HTTPException) as exc_info:
await create_deployment(session=AsyncMock(), payload=payload, current_user=_fake_user())
await create_deployment(
session=AsyncMock(), payload=payload, current_user=_fake_user(), telemetry=_fake_telemetry()
)
assert exc_info.value.status_code == 404
mock_validate_fv.assert_awaited_once()
@ -2786,7 +2848,9 @@ class TestCreateDeploymentProjectValidation:
patch(f"{ROUTES_MODULE}.attach_flow_versions", new_callable=AsyncMock),
):
mapper.shape_deployment_create_result.return_value = MagicMock()
await create_deployment(session=session, payload=payload, current_user=_fake_user())
await create_deployment(
session=session, payload=payload, current_user=_fake_user(), telemetry=_fake_telemetry()
)
mock_validate_fv.assert_awaited_once()
assert mock_validate_fv.call_args.kwargs["flow_version_ids"] == []
@ -2832,7 +2896,9 @@ class TestCreateDeploymentSchemaValidation:
payload.provider_id = pa.id
with pytest.raises(HTTPException) as exc_info:
await create_deployment(session=AsyncMock(), payload=payload, current_user=_fake_user())
await create_deployment(
session=AsyncMock(), payload=payload, current_user=_fake_user(), telemetry=_fake_telemetry()
)
assert exc_info.value.status_code == 422
mock_validate_fv.assert_awaited_once()
@ -2862,7 +2928,7 @@ class TestUpdateDeploymentProjectValidation:
adapter = AsyncMock()
mapper = MagicMock()
mapper.resolve_deployment_update = AsyncMock(return_value=MagicMock())
mock_resolve_amm.return_value = (dep_row, adapter, mapper, "watsonx-orchestrate")
mock_resolve_amm.return_value = (dep_row, adapter, mapper, "watsonx-orchestrate", "tenant-1")
add_ids = [uuid4()]
remove_ids = [uuid4()]
@ -2878,6 +2944,7 @@ class TestUpdateDeploymentProjectValidation:
session=AsyncMock(),
payload=payload,
current_user=_fake_user(),
telemetry=_fake_telemetry(),
)
assert exc_info.value.status_code == 404

View File

@ -0,0 +1,331 @@
# ruff: noqa: ARG001
from __future__ import annotations
from contextlib import ExitStack
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from fastapi import status
from lfx.services.settings.feature_flags import FEATURE_FLAGS
if TYPE_CHECKING:
from httpx import AsyncClient
# We'll use a mocked adapter so we don't need real credentials.
# We need to mock the adapter resolution and the telemetry service.
pytestmark = pytest.mark.skipif(
not FEATURE_FLAGS.wxo_deployments,
reason="wxo_deployments feature flag is disabled; deployment routes are not mounted.",
)
@pytest.fixture
def mock_telemetry_service():
with patch("langflow.api.v1.deployments.get_telemetry_service") as mock_get:
mock_ts = AsyncMock()
mock_get.return_value = mock_ts
yield mock_ts
@pytest.fixture
def mock_adapter():
with patch("langflow.api.v1.deployments.resolve_deployment_adapter") as mock_resolve:
mock_ad = AsyncMock()
mock_resolve.return_value = mock_ad
yield mock_ad
@pytest.fixture
def mock_mapper():
with patch("langflow.api.v1.deployments.get_deployment_mapper") as mock_get:
mock_map = MagicMock()
# Ensure it returns something valid for verify_credentials
mock_map.resolve_verify_credentials_for_create.return_value = {}
mock_map.resolve_verify_credentials_for_update.return_value = {}
mock_map.resolve_provider_account_create.return_value = AsyncMock(
id=uuid4(), provider_key="watsonx-orchestrate"
)
mock_map.resolve_provider_account_response.return_value = {
"id": str(uuid4()),
"provider_key": "watsonx-orchestrate",
"name": "Test",
}
mock_map.util_existing_deployment_resource_key_for_create.return_value = None
mock_map.resolve_deployment_create = AsyncMock(return_value={})
mock_map.resolve_deployment_update = AsyncMock(return_value={})
mock_map.util_create_flow_version_ids.return_value = []
mock_map.shape_deployment_create_result.return_value = {
"id": str(uuid4()),
"provider_id": str(uuid4()),
"provider_key": "watsonx-orchestrate",
"name": "Test",
"type": "agent",
"resource_key": "res-1",
}
mock_map.shape_deployment_update_result.return_value = {
"id": str(uuid4()),
"provider_id": str(uuid4()),
"provider_key": "watsonx-orchestrate",
"name": "Test",
"type": "agent",
"resource_key": "res-1",
}
mock_map.resolve_execution_create = AsyncMock(return_value={})
mock_map.shape_execution_create_result.return_value = {"id": "run-1", "deployment_id": str(uuid4())}
mock_map.resolve_snapshot_update_artifact.return_value = {}
mock_get.return_value = mock_map
yield mock_map
@pytest.fixture
def mock_db_crud(mock_mapper):
with ExitStack() as stack:
mock_create = stack.enter_context(patch("langflow.api.v1.deployments.create_provider_account_row"))
mock_get_owned = stack.enter_context(patch("langflow.api.v1.deployments.get_owned_provider_account_or_404"))
_mock_del_prov = stack.enter_context(patch("langflow.api.v1.deployments.delete_provider_account_row"))
_mock_upd_prov = stack.enter_context(patch("langflow.api.v1.deployments.update_provider_account_row"))
mock_name_exists = stack.enter_context(patch("langflow.api.v1.deployments.deployment_name_exists"))
mock_proj_id = stack.enter_context(
patch("langflow.api.v1.deployments.resolve_project_id_for_deployment_create")
)
mock_create_dep = stack.enter_context(patch("langflow.api.v1.deployments.create_deployment_db"))
_mock_attach = stack.enter_context(patch("langflow.api.v1.deployments.attach_flow_versions"))
mock_res_am = stack.enter_context(patch("langflow.api.v1.deployments.resolve_adapter_mapper_from_deployment"))
mock_res_patch = stack.enter_context(patch("langflow.api.v1.deployments.resolve_flow_version_patch_for_update"))
_mock_val_proj = stack.enter_context(
patch("langflow.api.v1.deployments.validate_project_scoped_flow_version_ids")
)
mock_list_att = stack.enter_context(
patch("langflow.api.v1.deployments.list_deployment_attachments_for_flow_version_ids")
)
_mock_apply_patch = stack.enter_context(
patch("langflow.api.v1.deployments.apply_flow_version_patch_attachments")
)
mock_upd_dep = stack.enter_context(patch("langflow.api.v1.deployments.update_deployment_db"))
mock_res_ad = stack.enter_context(patch("langflow.api.v1.deployments.resolve_adapter_from_deployment"))
_mock_del_dep = stack.enter_context(
patch("langflow.api.v1.deployments._delete_local_deployment_row_with_commit_retry")
)
mock_get_att = stack.enter_context(patch("langflow.api.v1.deployments.get_attachment_by_provider_snapshot_id"))
mock_get_dep_row = stack.enter_context(
patch("langflow.services.database.models.deployment.crud.get_deployment")
)
mock_get_fv = stack.enter_context(
patch("langflow.services.database.models.flow_version.crud.get_flow_version_entry")
)
mock_upd_fv = stack.enter_context(
patch("langflow.api.v1.deployments.update_flow_version_by_provider_snapshot_id")
)
mock_count_deps = stack.enter_context(
patch("langflow.api.v1.deployments._count_provider_deployments_after_reconciliation")
)
mock_create.return_value = AsyncMock(
id=uuid4(), provider_key="watsonx-orchestrate", provider_tenant_id="tenant-test"
)
mock_get_owned.return_value = AsyncMock(
id=uuid4(), provider_key="watsonx-orchestrate", provider_tenant_id="tenant-test"
)
mock_name_exists.return_value = False
mock_proj_id.return_value = uuid4()
mock_create_dep.return_value = AsyncMock(id=uuid4())
mock_res_am.return_value = (
AsyncMock(id=uuid4(), resource_key="res-1", deployment_provider_account_id=uuid4(), project_id=uuid4()),
AsyncMock(),
mock_mapper,
"watsonx-orchestrate",
"tenant-test",
)
mock_res_patch.return_value = ([], [])
mock_list_att.return_value = []
mock_upd_dep.return_value = AsyncMock()
mock_res_ad.return_value = (
AsyncMock(id=uuid4(), resource_key="res-1", deployment_provider_account_id=uuid4()),
AsyncMock(),
"watsonx-orchestrate",
"tenant-test",
)
mock_get_att.return_value = AsyncMock(deployment_id=uuid4(), flow_version_id=uuid4())
mock_get_dep_row.return_value = AsyncMock(deployment_provider_account_id=uuid4())
mock_get_fv.return_value = AsyncMock(flow_id=uuid4(), data={})
mock_upd_fv.return_value = 1
mock_count_deps.return_value = 0
yield
@pytest.mark.asyncio
async def test_create_provider_account_telemetry(
client: AsyncClient, mock_telemetry_service, mock_adapter, mock_mapper, mock_db_crud, logged_in_headers
):
response = await client.post(
"api/v1/deployments/providers",
json={"provider_key": "watsonx-orchestrate", "name": "Test", "provider_data": {"foo": "bar"}},
headers=logged_in_headers,
)
assert response.status_code == status.HTTP_201_CREATED
mock_telemetry_service.log_package_deployment_provider.assert_awaited_once()
payload = mock_telemetry_service.log_package_deployment_provider.call_args[0][0]
assert payload.deployment_action == "provider.create"
assert payload.deployment_provider == "watsonx-orchestrate"
assert payload.deployment_success is True
assert payload.wxo_tenant_id == "tenant-test"
@pytest.mark.asyncio
async def test_update_provider_account_telemetry(
client: AsyncClient, mock_telemetry_service, mock_adapter, mock_mapper, mock_db_crud, logged_in_headers
):
response = await client.patch(
f"api/v1/deployments/providers/{uuid4()}",
json={"name": "Test", "provider_data": {"foo": "bar"}},
headers=logged_in_headers,
)
assert response.status_code == status.HTTP_200_OK
mock_telemetry_service.log_package_deployment_provider.assert_awaited_once()
payload = mock_telemetry_service.log_package_deployment_provider.call_args[0][0]
assert payload.deployment_action == "provider.update"
assert payload.deployment_provider == "watsonx-orchestrate"
assert payload.deployment_success is True
assert payload.wxo_tenant_id == "tenant-test"
@pytest.mark.asyncio
async def test_delete_provider_account_telemetry(
client: AsyncClient, mock_telemetry_service, mock_adapter, mock_mapper, mock_db_crud, logged_in_headers
):
response = await client.delete(f"api/v1/deployments/providers/{uuid4()}", headers=logged_in_headers)
assert response.status_code == status.HTTP_204_NO_CONTENT
mock_telemetry_service.log_package_deployment_provider.assert_awaited_once()
payload = mock_telemetry_service.log_package_deployment_provider.call_args[0][0]
assert payload.deployment_action == "provider.delete"
assert payload.deployment_provider == "watsonx-orchestrate"
assert payload.deployment_success is True
assert payload.wxo_tenant_id == "tenant-test"
@pytest.mark.asyncio
async def test_create_deployment_telemetry(
client: AsyncClient, mock_telemetry_service, mock_adapter, mock_mapper, mock_db_crud, logged_in_headers
):
response = await client.post(
"api/v1/deployments",
json={"provider_id": str(uuid4()), "name": "Test", "type": "agent", "provider_data": {}},
headers=logged_in_headers,
)
assert response.status_code == status.HTTP_201_CREATED, response.json()
mock_telemetry_service.log_package_deployment.assert_awaited_once()
payload = mock_telemetry_service.log_package_deployment.call_args[0][0]
assert payload.deployment_action == "deployment.create"
assert payload.deployment_provider == "watsonx-orchestrate"
assert payload.deployment_success is True
assert payload.wxo_tenant_id == "tenant-test"
@pytest.mark.asyncio
async def test_update_deployment_telemetry(
client: AsyncClient, mock_telemetry_service, mock_adapter, mock_mapper, mock_db_crud, logged_in_headers
):
response = await client.patch(f"api/v1/deployments/{uuid4()}", json={"name": "Test"}, headers=logged_in_headers)
assert response.status_code == status.HTTP_200_OK
mock_telemetry_service.log_package_deployment.assert_awaited_once()
payload = mock_telemetry_service.log_package_deployment.call_args[0][0]
assert payload.deployment_action == "deployment.update"
assert payload.deployment_provider == "watsonx-orchestrate"
assert payload.deployment_success is True
assert payload.wxo_tenant_id == "tenant-test"
@pytest.mark.asyncio
async def test_delete_deployment_telemetry(
client: AsyncClient, mock_telemetry_service, mock_adapter, mock_mapper, mock_db_crud, logged_in_headers
):
response = await client.delete(f"api/v1/deployments/{uuid4()}", headers=logged_in_headers)
assert response.status_code == status.HTTP_204_NO_CONTENT
mock_telemetry_service.log_package_deployment.assert_awaited_once()
payload = mock_telemetry_service.log_package_deployment.call_args[0][0]
assert payload.deployment_action == "deployment.delete"
assert payload.deployment_provider == "watsonx-orchestrate"
assert payload.deployment_success is True
assert payload.wxo_tenant_id == "tenant-test"
@pytest.mark.asyncio
async def test_create_deployment_run_telemetry(
client: AsyncClient, mock_telemetry_service, mock_adapter, mock_mapper, mock_db_crud, logged_in_headers
):
response = await client.post(
f"api/v1/deployments/{uuid4()}/runs", json={"provider_data": {}}, headers=logged_in_headers
)
assert response.status_code == status.HTTP_201_CREATED, response.json()
mock_telemetry_service.log_package_deployment_run.assert_awaited_once()
payload = mock_telemetry_service.log_package_deployment_run.call_args[0][0]
assert payload.deployment_action == "deployment.run"
assert payload.deployment_provider == "watsonx-orchestrate"
assert payload.deployment_success is True
assert payload.wxo_tenant_id == "tenant-test"
@pytest.mark.asyncio
async def test_update_snapshot_telemetry(
client: AsyncClient, mock_telemetry_service, mock_adapter, mock_mapper, mock_db_crud, logged_in_headers
):
response = await client.patch(
"api/v1/deployments/snapshots/snap-1", json={"flow_version_id": str(uuid4())}, headers=logged_in_headers
)
assert response.status_code == status.HTTP_200_OK
mock_telemetry_service.log_package_deployment.assert_awaited_once()
payload = mock_telemetry_service.log_package_deployment.call_args[0][0]
assert payload.deployment_action == "snapshot.update"
assert payload.deployment_provider == "watsonx-orchestrate"
assert payload.deployment_success is True
assert payload.wxo_tenant_id == "tenant-test"
@pytest.mark.asyncio
async def test_create_provider_account_telemetry_error(
client: AsyncClient, mock_telemetry_service, mock_adapter, mock_mapper, mock_db_crud, logged_in_headers
):
mock_adapter.verify_credentials.side_effect = ValueError("Invalid credentials")
response = await client.post(
"api/v1/deployments/providers",
json={"provider_key": "watsonx-orchestrate", "name": "Test", "provider_data": {"foo": "bar"}},
headers=logged_in_headers,
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
mock_telemetry_service.log_package_deployment_provider.assert_awaited_once()
payload = mock_telemetry_service.log_package_deployment_provider.call_args[0][0]
assert payload.deployment_action == "provider.create"
assert payload.deployment_provider == "watsonx-orchestrate"
assert payload.deployment_success is False
assert payload.deployment_error_message == "400: Invalid credentials"
# verify_credentials raises before the row is created, so the tenant_id is not yet set.
assert payload.wxo_tenant_id is None
@pytest.mark.asyncio
async def test_cross_route_smoke_exception_after_provider_set(
client: AsyncClient, mock_telemetry_service, mock_adapter, mock_mapper, mock_db_crud, logged_in_headers
):
# Simulate an error during adapter.create after the provider has been set in the route
mock_adapter.create.side_effect = RuntimeError("Something went wrong")
response = await client.post(
"api/v1/deployments",
json={"provider_id": str(uuid4()), "name": "Test", "type": "agent", "provider_data": {}},
headers=logged_in_headers,
)
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
mock_telemetry_service.log_package_deployment.assert_awaited_once()
payload = mock_telemetry_service.log_package_deployment.call_args[0][0]
assert payload.deployment_action == "deployment.create"
assert payload.deployment_provider == "watsonx-orchestrate" # Provider should be captured!
assert payload.deployment_success is False
assert payload.deployment_error_message == (
"500: An unexpected error occurred while communicating with the deployment provider."
)
# tenant_id is captured before the adapter call that raises.
assert payload.wxo_tenant_id == "tenant-test"

View File

@ -251,7 +251,7 @@ async def test_get_config_unauthenticated_returns_correct_field_types(client: As
assert isinstance(result["frontend_timeout"], int), "frontend_timeout must be an integer"
assert isinstance(result["voice_mode_available"], bool), "voice_mode_available must be a boolean"
assert isinstance(result["feature_flags"], dict), "feature_flags must be an object"
assert result["feature_flags"].get("wxo_deployments") is True, "wxo_deployments flag should default to true"
assert result["feature_flags"].get("wxo_deployments") is False, "wxo_deployments flag should default to false"
assert result["event_delivery"] in ["polling", "streaming", "direct"], (
"event_delivery must be one of: polling, streaming, direct"
)
@ -385,6 +385,6 @@ async def test_deprecated_upload_enforces_max_file_size(
headers=logged_in_headers,
)
assert response.status_code == status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, (
assert response.status_code == status.HTTP_413_CONTENT_TOO_LARGE, (
f"Expected 413 for oversized upload, got {response.status_code}: {response.text}"
)

View File

@ -8,6 +8,7 @@ import re
import pytest
from langflow.services.telemetry.schema import (
ComponentPayload,
DeploymentPayload,
EmailPayload,
PlaygroundPayload,
RunPayload,
@ -16,6 +17,93 @@ from langflow.services.telemetry.schema import (
)
class TestDeploymentPayload:
"""Test cases for DeploymentPayload."""
def test_deployment_payload_initialization_with_valid_data(self):
"""Test DeploymentPayload initialization with valid parameters."""
payload = DeploymentPayload(
deployment_action="deployment.create",
deployment_provider="test_provider",
deployment_seconds=1.5,
deployment_success=True,
deployment_error_message="",
wxo_tenant_id="tenant-abc",
client_type="oss",
)
assert payload.deployment_action == "deployment.create"
assert payload.deployment_provider == "test_provider"
assert payload.deployment_seconds == 1.5
assert payload.deployment_success is True
assert payload.deployment_error_message == ""
assert payload.wxo_tenant_id == "tenant-abc"
assert payload.client_type == "oss"
def test_deployment_payload_initialization_with_defaults(self):
"""Test DeploymentPayload initialization with default values."""
payload = DeploymentPayload(
deployment_action="deployment.delete",
deployment_provider="test_provider",
deployment_seconds=0.5,
deployment_success=False,
deployment_error_message="404: Provider not found.",
)
assert payload.deployment_action == "deployment.delete"
assert payload.deployment_provider == "test_provider"
assert payload.deployment_seconds == 0.5
assert payload.deployment_success is False
assert payload.deployment_error_message == "404: Provider not found."
assert payload.wxo_tenant_id is None # Default value
assert payload.client_type is None # Default value
def test_deployment_payload_serialization(self):
"""Test DeploymentPayload serialization to dictionary."""
payload = DeploymentPayload(
deployment_action="deployment.update",
deployment_provider="test_provider",
deployment_seconds=2.0,
deployment_success=True,
deployment_error_message="",
wxo_tenant_id="tenant-xyz",
client_type="desktop",
)
data = payload.model_dump(by_alias=True, exclude_none=True)
assert data["deploymentAction"] == "deployment.update"
assert data["deploymentProvider"] == "test_provider"
assert data["deploymentSeconds"] == 2.0
assert data["deploymentSuccess"] is True
assert data["deploymentErrorMessage"] == ""
assert data["wxoTenantId"] == "tenant-xyz"
assert data["clientType"] == "desktop"
def test_deployment_payload_roundtrip(self):
"""Test DeploymentPayload roundtrip serialization."""
payload = DeploymentPayload(
deployment_action="provider.create",
deployment_provider="test_provider",
deployment_seconds=1.0,
deployment_success=False,
deployment_error_message="500: Adapter exploded.",
wxo_tenant_id="tenant-abc",
client_type="oss",
)
data = payload.model_dump()
new_payload = DeploymentPayload(**data)
assert new_payload.deployment_action == payload.deployment_action
assert new_payload.deployment_provider == payload.deployment_provider
assert new_payload.deployment_seconds == payload.deployment_seconds
assert new_payload.deployment_success == payload.deployment_success
assert new_payload.deployment_error_message == payload.deployment_error_message
assert new_payload.wxo_tenant_id == payload.wxo_tenant_id
assert new_payload.client_type == payload.client_type
class TestRunPayload:
"""Test cases for RunPayload."""

View File

@ -849,3 +849,313 @@ async def test_job_owner_cleaned_up_after_cleanup_job():
service._cleanup_task.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await service._cleanup_task
# ---------------------------------------------------------------------------
# CVE-2026-33017: session-id namespacing on build_public_tmp
# ---------------------------------------------------------------------------
def _stub_start_flow_build(monkeypatch, captured: dict) -> None:
"""Capture the kwargs that would be dispatched to start_flow_build without running the build."""
import langflow.api.v1.chat as chat_module
async def _fake_start_flow_build(**kwargs):
captured.update(kwargs)
return "00000000-0000-0000-0000-00000000ffff"
monkeypatch.setattr(chat_module, "start_flow_build", _fake_start_flow_build)
def _send_unauthenticated(client, client_id: str) -> None:
"""Drop login cookies and set the public client_id cookie.
The shared AsyncClient persists access-token cookies from logged_in_headers
that would otherwise let get_current_user_optional resolve a user and
namespace under user_id -- not the unauthenticated shape the CVE targets.
"""
client.cookies.clear()
client.cookies.set("client_id", client_id)
@pytest.mark.benchmark
@pytest.mark.security
async def test_build_public_tmp_namespaces_caller_session(
client, json_memory_chatbot_no_llm, logged_in_headers, monkeypatch
):
"""Caller-supplied session equal to the real flow UUID is wrapped under the namespace.
The threat: /api/v1/run hands out session_id == flow_id by default, and the
flow UUID is visible in URLs. Without namespacing, an unauthenticated caller
can pass that UUID as inputs.session and a Memory component reads its history.
"""
from langflow.api.utils.flow_utils import compute_virtual_flow_id
flow_id = await create_flow(client, json_memory_chatbot_no_llm, logged_in_headers)
patch_response = await client.patch(
f"api/v1/flows/{flow_id}",
json={"access_type": "PUBLIC"},
headers=logged_in_headers,
)
assert patch_response.status_code == codes.OK
captured: dict = {}
_stub_start_flow_build(monkeypatch, captured)
client_id = "ns-test-client"
_send_unauthenticated(client, client_id)
victim_session = str(flow_id)
response = await client.post(
f"api/v1/build_public_tmp/{flow_id}/flow",
json={"inputs": {"session": victim_session}},
headers={"Content-Type": "application/json"},
)
assert response.status_code == codes.OK
expected_namespace = str(compute_virtual_flow_id(client_id, flow_id))
sent_inputs = captured["inputs"]
assert sent_inputs is not None
assert sent_inputs.session == f"{expected_namespace}:{victim_session}"
assert sent_inputs.session != victim_session
@pytest.mark.benchmark
@pytest.mark.security
async def test_build_public_tmp_session_already_namespaced_unchanged(
client, json_memory_chatbot_no_llm, logged_in_headers, monkeypatch
):
"""Idempotency: a value already in-namespace is forwarded as-is, not double-wrapped."""
from langflow.api.utils.flow_utils import compute_virtual_flow_id
flow_id = await create_flow(client, json_memory_chatbot_no_llm, logged_in_headers)
patch_response = await client.patch(
f"api/v1/flows/{flow_id}",
json={"access_type": "PUBLIC"},
headers=logged_in_headers,
)
assert patch_response.status_code == codes.OK
captured: dict = {}
_stub_start_flow_build(monkeypatch, captured)
client_id = "ns-passthrough-client"
_send_unauthenticated(client, client_id)
namespace = str(compute_virtual_flow_id(client_id, flow_id))
already_scoped = f"{namespace}:thread-1"
response = await client.post(
f"api/v1/build_public_tmp/{flow_id}/flow",
json={"inputs": {"session": already_scoped}},
headers={"Content-Type": "application/json"},
)
assert response.status_code == codes.OK
assert captured["inputs"].session == already_scoped
@pytest.mark.benchmark
@pytest.mark.security
async def test_build_public_tmp_isolates_disjoint_clients(
client, json_memory_chatbot_no_llm, logged_in_headers, monkeypatch
):
"""Different client_ids submitting the same session string land in disjoint namespaces."""
flow_id = await create_flow(client, json_memory_chatbot_no_llm, logged_in_headers)
patch_response = await client.patch(
f"api/v1/flows/{flow_id}",
json={"access_type": "PUBLIC"},
headers=logged_in_headers,
)
assert patch_response.status_code == codes.OK
captured: dict = {}
_stub_start_flow_build(monkeypatch, captured)
shared_session = "shared-session-name"
_send_unauthenticated(client, "client-A")
response_a = await client.post(
f"api/v1/build_public_tmp/{flow_id}/flow",
json={"inputs": {"session": shared_session}},
headers={"Content-Type": "application/json"},
)
assert response_a.status_code == codes.OK
session_a = captured["inputs"].session
captured.clear()
_send_unauthenticated(client, "client-B")
response_b = await client.post(
f"api/v1/build_public_tmp/{flow_id}/flow",
json={"inputs": {"session": shared_session}},
headers={"Content-Type": "application/json"},
)
assert response_b.status_code == codes.OK
session_b = captured["inputs"].session
assert session_a != session_b
assert session_a.endswith(f":{shared_session}")
assert session_b.endswith(f":{shared_session}")
@pytest.mark.benchmark
@pytest.mark.security
async def test_build_public_tmp_no_session_passthrough(
client, json_memory_chatbot_no_llm, logged_in_headers, monkeypatch
):
"""No inputs supplied: namespacing is skipped; downstream falls back to the virtual flow ID."""
flow_id = await create_flow(client, json_memory_chatbot_no_llm, logged_in_headers)
patch_response = await client.patch(
f"api/v1/flows/{flow_id}",
json={"access_type": "PUBLIC"},
headers=logged_in_headers,
)
assert patch_response.status_code == codes.OK
captured: dict = {}
_stub_start_flow_build(monkeypatch, captured)
_send_unauthenticated(client, "ns-default-client")
response = await client.post(
f"api/v1/build_public_tmp/{flow_id}/flow",
json={"inputs": None},
headers={"Content-Type": "application/json"},
)
assert response.status_code == codes.OK
assert captured["inputs"] is None
@pytest.mark.benchmark
@pytest.mark.security
async def test_build_public_tmp_empty_session_is_namespaced(
client, json_memory_chatbot_no_llm, logged_in_headers, monkeypatch
):
"""An empty-string session is scoped, not forwarded as-is.
Empty string is currently *coincidentally* safe (downstream `or virtual_id`
fallbacks save it), but a refactor of either branch would silently regress.
Pin the contract here: empty becomes ``f"{namespace}:"``.
"""
from langflow.api.utils.flow_utils import compute_virtual_flow_id
flow_id = await create_flow(client, json_memory_chatbot_no_llm, logged_in_headers)
patch_response = await client.patch(
f"api/v1/flows/{flow_id}",
json={"access_type": "PUBLIC"},
headers=logged_in_headers,
)
assert patch_response.status_code == codes.OK
captured: dict = {}
_stub_start_flow_build(monkeypatch, captured)
client_id = "ns-empty-client"
_send_unauthenticated(client, client_id)
response = await client.post(
f"api/v1/build_public_tmp/{flow_id}/flow",
json={"inputs": {"session": ""}},
headers={"Content-Type": "application/json"},
)
assert response.status_code == codes.OK
expected_namespace = str(compute_virtual_flow_id(client_id, flow_id))
sent_session = captured["inputs"].session
assert sent_session != ""
assert sent_session == f"{expected_namespace}:"
@pytest.mark.benchmark
@pytest.mark.security
async def test_build_public_tmp_authenticated_namespace_uses_user_id(
client, json_memory_chatbot_no_llm, logged_in_headers, active_user, monkeypatch
):
"""AUTO_LOGIN=False (prod-like) + valid bearer: the namespace is derived from user.id."""
from langflow.api.utils.flow_utils import compute_virtual_flow_id
flow_id = await create_flow(client, json_memory_chatbot_no_llm, logged_in_headers)
patch_response = await client.patch(
f"api/v1/flows/{flow_id}",
json={"access_type": "PUBLIC"},
headers=logged_in_headers,
)
assert patch_response.status_code == codes.OK
captured: dict = {}
_stub_start_flow_build(monkeypatch, captured)
client.cookies.set("client_id", "should-be-ignored")
response = await client.post(
f"api/v1/build_public_tmp/{flow_id}/flow",
json={"inputs": {"session": "thread-A"}},
headers={**logged_in_headers, "Content-Type": "application/json"},
)
assert response.status_code == codes.OK
expected_namespace = str(compute_virtual_flow_id(active_user.id, flow_id))
assert captured["inputs"].session == f"{expected_namespace}:thread-A"
@pytest.mark.benchmark
@pytest.mark.security
async def test_build_public_tmp_namespacing_blocks_memory_query_collision(
client, json_memory_chatbot_no_llm, logged_in_headers, monkeypatch
):
"""End-to-end proof that namespacing prevents Memory query collision.
A victim message stored under ``session_id == flow_id`` is unreachable via a
Memory query keyed on the namespaced session that build_public_tmp forwards.
This is the test that catches a regression in either the endpoint guard or
the helper -- the shape-only tests above would still pass if the rewrite
was applied but the downstream query stopped honoring it.
"""
from lfx.memory import aadd_messages, aget_messages
from lfx.schema.message import Message
flow_id = await create_flow(client, json_memory_chatbot_no_llm, logged_in_headers)
patch_response = await client.patch(
f"api/v1/flows/{flow_id}",
json={"access_type": "PUBLIC"},
headers=logged_in_headers,
)
assert patch_response.status_code == codes.OK
victim_session = str(flow_id)
await aadd_messages(
Message(text="victim-secret", sender="User", sender_name="User", session_id=victim_session),
flow_id=flow_id,
)
seeded = await aget_messages(session_id=victim_session)
assert any(m.text == "victim-secret" for m in seeded)
captured: dict = {}
_stub_start_flow_build(monkeypatch, captured)
_send_unauthenticated(client, "leak-test-client")
response = await client.post(
f"api/v1/build_public_tmp/{flow_id}/flow",
json={"inputs": {"session": victim_session}},
headers={"Content-Type": "application/json"},
)
assert response.status_code == codes.OK
namespaced_session = captured["inputs"].session
assert namespaced_session != victim_session
leaked = await aget_messages(session_id=namespaced_session)
assert all(m.text != "victim-secret" for m in leaked)
still_seeded = await aget_messages(session_id=victim_session)
assert any(m.text == "victim-secret" for m in still_seeded)
def test_scope_session_to_namespace_helper():
from langflow.api.utils import scope_session_to_namespace
ns = "namespace-A"
assert scope_session_to_namespace(None, ns) is None
assert scope_session_to_namespace("", ns) == f"{ns}:"
assert scope_session_to_namespace(ns, ns) == ns
assert scope_session_to_namespace(f"{ns}:thread-1", ns) == f"{ns}:thread-1"
assert scope_session_to_namespace("victim-session", ns) == f"{ns}:victim-session"
assert scope_session_to_namespace("victim-session", "namespace-B") == "namespace-B:victim-session"
# A foreign-namespace prefix is treated as out-of-namespace and gets re-wrapped.
assert scope_session_to_namespace("namespace-B:victim", "namespace-A") == "namespace-A:namespace-B:victim"

View File

@ -100,3 +100,32 @@ class TestGetApiKeyForProviderDbFallback:
result = get_api_key_for_provider(user_id, "OpenAI", None)
assert result == "sk-from-database"
@patch("lfx.base.models.unified_models.credentials.get_model_provider_variable_mapping")
def test_should_fallback_to_env_when_user_id_is_none(self, mock_mapping, monkeypatch):
"""No user_id (lfx run) must still resolve credentials from os.environ.
Reproducer: a flow exported with empty api_key + load_from_db=False is executed
via `lfx run`. user_id is None, api_key is empty/None — the function should still
try the canonical env var (e.g. WATSONX_APIKEY) before giving up.
"""
from lfx.base.models.unified_models.credentials import get_api_key_for_provider
mock_mapping.return_value = {"IBM WatsonX": "WATSONX_APIKEY"}
monkeypatch.setenv("WATSONX_APIKEY", "shell-exported-key")
result = get_api_key_for_provider(None, "IBM WatsonX", None)
assert result == "shell-exported-key"
@patch("lfx.base.models.unified_models.credentials.get_model_provider_variable_mapping")
def test_should_return_none_when_user_id_none_and_env_unset(self, mock_mapping, monkeypatch):
"""No user_id and no env var: nothing to return."""
from lfx.base.models.unified_models.credentials import get_api_key_for_provider
mock_mapping.return_value = {"IBM WatsonX": "WATSONX_APIKEY"}
monkeypatch.delenv("WATSONX_APIKEY", raising=False)
result = get_api_key_for_provider(None, "IBM WatsonX", None)
assert result is None

View File

@ -4,6 +4,83 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
import pytest
from langflow.services.telemetry.opentelemetry import OpenTelemetry
from langflow.services.telemetry.schema import DeploymentPayload
from langflow.services.telemetry.service import TelemetryService
@pytest.fixture
def mock_settings_service(mocker):
settings = mocker.MagicMock()
settings.settings.telemetry_base_url = "http://test.telemetry"
settings.settings.prometheus_enabled = False
settings.settings.do_not_track = False
return settings
@pytest.fixture
def telemetry_service(mock_settings_service):
return TelemetryService(mock_settings_service)
@pytest.mark.asyncio
async def test_log_package_deployment(telemetry_service):
payload = DeploymentPayload(
deployment_action="deployment.create",
deployment_provider="test_provider",
deployment_seconds=1.0,
deployment_success=True,
)
await telemetry_service.log_package_deployment(payload)
func, queued_payload, path = await telemetry_service.telemetry_queue.get()
assert func == telemetry_service.send_telemetry_data
assert queued_payload == payload
assert path == "deployment"
@pytest.mark.asyncio
async def test_log_package_deployment_provider(telemetry_service):
payload = DeploymentPayload(
deployment_action="provider.create",
deployment_provider="test_provider",
deployment_seconds=1.0,
deployment_success=True,
)
await telemetry_service.log_package_deployment_provider(payload)
func, queued_payload, path = await telemetry_service.telemetry_queue.get()
assert func == telemetry_service.send_telemetry_data
assert queued_payload == payload
assert path == "deployment_provider"
@pytest.mark.asyncio
async def test_log_package_deployment_run(telemetry_service):
payload = DeploymentPayload(
deployment_action="deployment.run",
deployment_provider="test_provider",
deployment_seconds=1.0,
deployment_success=True,
)
await telemetry_service.log_package_deployment_run(payload)
func, queued_payload, path = await telemetry_service.telemetry_queue.get()
assert func == telemetry_service.send_telemetry_data
assert queued_payload == payload
assert path == "deployment_run"
@pytest.mark.asyncio
async def test_log_package_deployment_do_not_track(telemetry_service):
telemetry_service.do_not_track = True
payload = DeploymentPayload(
deployment_action="deployment.create",
deployment_provider="test_provider",
deployment_seconds=1.0,
deployment_success=True,
)
await telemetry_service.log_package_deployment(payload)
await telemetry_service.log_package_deployment_provider(payload)
await telemetry_service.log_package_deployment_run(payload)
assert telemetry_service.telemetry_queue.empty()
fixed_labels = {"flow_id": "this_flow_id", "service": "this", "user": "that"}

View File

@ -1,9 +1,10 @@
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from lfx.base.models.unified_models import (
_get_all_provider_mapped_fields,
apply_provider_variable_config_to_build_config,
get_embedding_model_options,
get_embeddings,
get_unified_models_detailed,
handle_model_input_update,
@ -89,6 +90,22 @@ def test_filter_by_model_type_embeddings():
assert model["metadata"].get("model_type", "llm") == "embeddings"
@patch("lfx.base.models.unified_models.model_catalog._fetch_enabled_providers_for_user", new_callable=AsyncMock)
@patch("lfx.base.models.unified_models.model_catalog._get_model_status", new_callable=AsyncMock)
def test_google_embedding_options_map_dimensions_to_output_dimensionality(mock_get_model_status, mock_fetch_providers):
mock_get_model_status.return_value = (set(), set())
mock_fetch_providers.return_value = {"Google Generative AI"}
options = get_embedding_model_options(user_id="test-user")
google_embedding = next(
option
for option in options
if option["provider"] == "Google Generative AI" and option["name"] == "models/gemini-embedding-001"
)
assert google_embedding["metadata"]["param_mapping"]["dimensions"] == "output_dimensionality"
def test_update_model_options_with_custom_field_name():
"""Test that update_model_options_in_build_config works with custom field names."""
# Create mock component
@ -484,7 +501,7 @@ def test_get_embeddings_optional_params_only_added_when_mapped(mock_get_class, m
@patch("lfx.base.models.unified_models.get_api_key_for_provider")
@patch("lfx.base.models.unified_models.get_embedding_class")
def test_get_embeddings_google_timeout_wrapped_in_dict(mock_get_class, mock_get_api_key):
"""For Google Generative AI, request_timeout should be wrapped as {'timeout': value}."""
"""For Google Generative AI, request_timeout is wrapped and dimensions are passed through."""
mock_get_api_key.return_value = "google-key"
mock_embedding_class = MagicMock()
mock_get_class.return_value = mock_embedding_class
@ -497,14 +514,21 @@ def test_get_embeddings_google_timeout_wrapped_in_dict(mock_get_class, mock_get_
"param_mapping": {
"model": "model",
"api_key": "google_api_key", # pragma: allowlist secret
"dimensions": "output_dimensionality",
"request_timeout": "request_options",
},
},
}
get_embeddings([google_model], api_key="google-key", request_timeout=30.0) # pragma: allowlist secret
get_embeddings(
[google_model],
api_key="google-key", # pragma: allowlist secret
dimensions=768,
request_timeout=30.0,
)
kwargs = mock_embedding_class.call_args.kwargs
assert kwargs.get("output_dimensionality") == 768
assert kwargs.get("request_options") == {"timeout": 30.0}

View File

@ -5467,16 +5467,16 @@
"license": "ISC"
},
"node_modules/@storybook/addon-docs": {
"version": "10.3.5",
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.3.5.tgz",
"integrity": "sha512-WuHbxia/o5TX4Rg/IFD0641K5qId/Nk0dxhmAUNoFs5L0+yfZUwh65XOBbzXqrkYmYmcVID4v7cgDRmzstQNkA==",
"version": "10.3.6",
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.3.6.tgz",
"integrity": "sha512-TvIdADVPtauxW0LzXIpIv7X6GxwetorhyNh+6+7MHC27XSBCWVxxRUwL63YeLlHTuXsIk0quG3b1xgwVRzWOJA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@mdx-js/react": "^3.0.0",
"@storybook/csf-plugin": "10.3.5",
"@storybook/csf-plugin": "10.3.6",
"@storybook/icons": "^2.0.1",
"@storybook/react-dom-shim": "10.3.5",
"@storybook/react-dom-shim": "10.3.6",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"ts-dedent": "^2.0.0"
@ -5486,13 +5486,13 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
"storybook": "^10.3.5"
"storybook": "^10.3.6"
}
},
"node_modules/@storybook/addon-links": {
"version": "10.3.5",
"resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.3.5.tgz",
"integrity": "sha512-Xe2wCGZ+hpZ0cDqAIBHk+kPc8nODNbu585ghd5bLrlYJMDVXoNM/fIlkrLgjIDVbfpgeJLUEg7vldJrn+FyOLw==",
"version": "10.3.6",
"resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.3.6.tgz",
"integrity": "sha512-tv9Xd68qRGBAvEubaxNo3FuFq4GwuMiBriD+gLGuFK0+/u3cnkuA264aoR1v6YCH3sT3er3+MBimuyKM3jLDxg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -5504,7 +5504,7 @@
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "^10.3.5"
"storybook": "^10.3.6"
},
"peerDependenciesMeta": {
"react": {
@ -5513,13 +5513,13 @@
}
},
"node_modules/@storybook/builder-vite": {
"version": "10.3.5",
"resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.3.5.tgz",
"integrity": "sha512-i4KwCOKbhtlbQIbhm53+Kk7bMnxa0cwTn1pxmtA/x5wm1Qu7FrrBQV0V0DNjkUqzcSKo1CjspASJV/HlY0zYlw==",
"version": "10.3.6",
"resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.3.6.tgz",
"integrity": "sha512-gpvR/sE4BcrFtmQZ+Ker7zD23oQzoVeqD9nF6cK6yzY+Q0svJXyX2EPmFG4y+EwygD5/vNzDpP84gGMut8VRwg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/csf-plugin": "10.3.5",
"@storybook/csf-plugin": "10.3.6",
"ts-dedent": "^2.0.0"
},
"funding": {
@ -5527,14 +5527,14 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
"storybook": "^10.3.5",
"storybook": "^10.3.6",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/@storybook/csf-plugin": {
"version": "10.3.5",
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.3.5.tgz",
"integrity": "sha512-qlEzNKxOjq86pvrbuMwiGD/bylnsXk1dg7ve0j77YFjEEchqtl7qTlrXvFdNaLA89GhW6D/EV6eOCu/eobPDgw==",
"version": "10.3.6",
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.3.6.tgz",
"integrity": "sha512-9kBf7VRdRqTSIYo+rPtVn5yjYYyK8kP2QhEYx3oiXvfwy4RexmbJnhk/tXa/lNiTqukA1TqaWQ2+5MqF4fu6YQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -5547,7 +5547,7 @@
"peerDependencies": {
"esbuild": "*",
"rollup": "*",
"storybook": "^10.3.5",
"storybook": "^10.3.6",
"vite": "*",
"webpack": "*"
},
@ -5585,14 +5585,14 @@
}
},
"node_modules/@storybook/react": {
"version": "10.3.5",
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.3.5.tgz",
"integrity": "sha512-tpLTLaVGoA6fLK3ReyGzZUricq7lyPaV2hLPpj5wqdXLV/LpRtAHClUpNoPDYSBjlnSjL81hMZijbkGC3mA+gw==",
"version": "10.3.6",
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.3.6.tgz",
"integrity": "sha512-oZQZ6xayWe5IdHmFUTL0TL8rX/gpNNh9gWhT2vzW5eeUvlkVG/RBKdsja6Ndrk2s1D9vcnwiI6r6CNXy3IEEmg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/global": "^5.0.0",
"@storybook/react-dom-shim": "10.3.5",
"@storybook/react-dom-shim": "10.3.6",
"react-docgen": "^8.0.2",
"react-docgen-typescript": "^2.2.2"
},
@ -5603,7 +5603,7 @@
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "^10.3.5",
"storybook": "^10.3.6",
"typescript": ">= 4.9.x"
},
"peerDependenciesMeta": {
@ -5613,9 +5613,9 @@
}
},
"node_modules/@storybook/react-dom-shim": {
"version": "10.3.5",
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.3.5.tgz",
"integrity": "sha512-Gw8R7XZm0zSUH0XAuxlQJhmizsLzyD6x00KOlP6l7oW9eQHXGfxg3seNDG3WrSAcW07iP1/P422kuiriQlOv7g==",
"version": "10.3.6",
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.3.6.tgz",
"integrity": "sha512-/Tu1gPu+Fw+zOnAGmxRmOD30FX3a04LxcTAKflEtdpmtIMVR5bA3qpjy+f5YhoyDCecbXyKmL1OeIU2FIIZHqQ==",
"dev": true,
"license": "MIT",
"funding": {
@ -5625,20 +5625,20 @@
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "^10.3.5"
"storybook": "^10.3.6"
}
},
"node_modules/@storybook/react-vite": {
"version": "10.3.5",
"resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.3.5.tgz",
"integrity": "sha512-UB5sJHeh26bfd8sNMx2YPGYRYmErIdTRaLOT28m4bykQIa1l9IgVktsYg/geW7KsJU0lXd3oTbnUjLD+enpi3w==",
"version": "10.3.6",
"resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.3.6.tgz",
"integrity": "sha512-tySQRc+8q7V2NkylQMNJjDV8zXy6tkxb8oDqw/DIhHhI9Xn77MTKVZ8Cihbo5NMm7HYTB6xDKr6wqdSMgdufYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@joshwooding/vite-plugin-react-docgen-typescript": "^0.7.0",
"@rollup/pluginutils": "^5.0.2",
"@storybook/builder-vite": "10.3.5",
"@storybook/react": "10.3.5",
"@storybook/builder-vite": "10.3.6",
"@storybook/react": "10.3.6",
"empathic": "^2.0.0",
"magic-string": "^0.30.0",
"react-docgen": "^8.0.0",
@ -5652,7 +5652,7 @@
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "^10.3.5",
"storybook": "^10.3.6",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
@ -6009,9 +6009,9 @@
}
},
"node_modules/@swc/core": {
"version": "1.15.30",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.30.tgz",
"integrity": "sha512-R8VQbQY1BZcbIF2p3gjlTCwAQzx1A194ugWfwld5y+WgVVWqVKm7eURGGOVbQVubgKWzidP2agomBbg96rZilQ==",
"version": "1.15.32",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.32.tgz",
"integrity": "sha512-/eWL0n43D64QWEUHLtTE+jDqjkJhyidjkDhv6f0uJohOUAhywxQ9wXYp845DNNds0JpCdI4Uo0a9bl+vbXf+ew==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
@ -6028,18 +6028,18 @@
"url": "https://opencollective.com/swc"
},
"optionalDependencies": {
"@swc/core-darwin-arm64": "1.15.30",
"@swc/core-darwin-x64": "1.15.30",
"@swc/core-linux-arm-gnueabihf": "1.15.30",
"@swc/core-linux-arm64-gnu": "1.15.30",
"@swc/core-linux-arm64-musl": "1.15.30",
"@swc/core-linux-ppc64-gnu": "1.15.30",
"@swc/core-linux-s390x-gnu": "1.15.30",
"@swc/core-linux-x64-gnu": "1.15.30",
"@swc/core-linux-x64-musl": "1.15.30",
"@swc/core-win32-arm64-msvc": "1.15.30",
"@swc/core-win32-ia32-msvc": "1.15.30",
"@swc/core-win32-x64-msvc": "1.15.30"
"@swc/core-darwin-arm64": "1.15.32",
"@swc/core-darwin-x64": "1.15.32",
"@swc/core-linux-arm-gnueabihf": "1.15.32",
"@swc/core-linux-arm64-gnu": "1.15.32",
"@swc/core-linux-arm64-musl": "1.15.32",
"@swc/core-linux-ppc64-gnu": "1.15.32",
"@swc/core-linux-s390x-gnu": "1.15.32",
"@swc/core-linux-x64-gnu": "1.15.32",
"@swc/core-linux-x64-musl": "1.15.32",
"@swc/core-win32-arm64-msvc": "1.15.32",
"@swc/core-win32-ia32-msvc": "1.15.32",
"@swc/core-win32-x64-msvc": "1.15.32"
},
"peerDependencies": {
"@swc/helpers": ">=0.5.17"
@ -6051,9 +6051,9 @@
}
},
"node_modules/@swc/core-darwin-arm64": {
"version": "1.15.30",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.30.tgz",
"integrity": "sha512-VvpP+vq08HmGYewMWvrdsxh9s2lthz/808zXm8Yu5kaqeR8Yia2b0eYXleHQ3VAjoStUDk6LzTheBW9KXYQdMA==",
"version": "1.15.32",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.32.tgz",
"integrity": "sha512-/YWMvJDPu+AAwuUsM2G+DNQ/7zhodURGzdQyewEqcvgklAdDHs3LwQmLLnyn6SJl8DT8UOxkbzK+D1PmPeelRg==",
"cpu": [
"arm64"
],
@ -6068,9 +6068,9 @@
}
},
"node_modules/@swc/core-darwin-x64": {
"version": "1.15.30",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.30.tgz",
"integrity": "sha512-WiJA0hiZI3nwQAO6mu5RqigtWGDtth4Hiq6rbZxAaQyhIcqKIg5IoMRc1Y071lrNJn29eEDMC86Rq58xgUxlDg==",
"version": "1.15.32",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.32.tgz",
"integrity": "sha512-KOTXJXdAhWL+hZ77MYP3z+4pcMFaQhQ74yqyN1uz093q0YnbxpqMtYpPISbYvMHzVRNNx5kN+9RZAXEaadhWVA==",
"cpu": [
"x64"
],
@ -6085,9 +6085,9 @@
}
},
"node_modules/@swc/core-linux-arm-gnueabihf": {
"version": "1.15.30",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.30.tgz",
"integrity": "sha512-YANuFUo48kIT6plJgCD0keae9HFXfjxsbvsgevqc0hr/07X/p7sAWTFOGYEc2SXcASaK7UvuQqzlbW8pr7R79g==",
"version": "1.15.32",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.32.tgz",
"integrity": "sha512-oOoxLweljlc0A4X8ybsgxV7cVaYTwBOg2iMDJcFR3Sr48C+lsv9VzSmqdK/IVIXF4W4GjLc3VqTAdSMXlfVLuQ==",
"cpu": [
"arm"
],
@ -6102,9 +6102,9 @@
}
},
"node_modules/@swc/core-linux-arm64-gnu": {
"version": "1.15.30",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.30.tgz",
"integrity": "sha512-VndG8jaR4ugY6u+iVOT0Q+d2fZd7sLgjPgN8W/Le+3EbZKl+cRfFxV7Eoz4gfLqhmneZPdcIzf9T3LkgkmqNLg==",
"version": "1.15.32",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.32.tgz",
"integrity": "sha512-oDzEkdl6D6BAWdMtU5KGO7y3HR5fJcvByNLyEk9+ugj8nP5Ovb7P4kBcStBXc4MPExFGQryehiINMlmY8HlclA==",
"cpu": [
"arm64"
],
@ -6119,9 +6119,9 @@
}
},
"node_modules/@swc/core-linux-arm64-musl": {
"version": "1.15.30",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.30.tgz",
"integrity": "sha512-1SYGs2l0Yyyi0pR/P/NKz/x0kqxkoiw+BXeJjLUdecSk/KasncWlJrc6hOvFSgKHOBrzgM5jwuluKtlT8dnrcA==",
"version": "1.15.32",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.32.tgz",
"integrity": "sha512-omcqjoZP/b8D8PuczVoRwJieC6ibj7qIxTftNYokz4/aSmKFHvsd7nIFfPk5ZvtzncbH4AY7+Dkr/Lp2gWxYeA==",
"cpu": [
"arm64"
],
@ -6136,9 +6136,9 @@
}
},
"node_modules/@swc/core-linux-ppc64-gnu": {
"version": "1.15.30",
"resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.30.tgz",
"integrity": "sha512-TXREtiXeRhbfDFbmhnkIsXpKfzbfT73YkV2ZF6w0sfxgjC5zI2ZAbaCOq25qxvegofj2K93DtOpm9RLaBgqR2g==",
"version": "1.15.32",
"resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.32.tgz",
"integrity": "sha512-KGkTMyz/Tbn3PBNu0AVZ4GTDFKnICrYcTiNPZq8DrvK42pnFsf3GNDrIG9E5AtQlTmC0YigkWKmu0eMcfTrmgA==",
"cpu": [
"ppc64"
],
@ -6153,9 +6153,9 @@
}
},
"node_modules/@swc/core-linux-s390x-gnu": {
"version": "1.15.30",
"resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.30.tgz",
"integrity": "sha512-DCR2YYeyd6DQE4OuDhImouuNcjXEiEdnn1Y0DyGteugPEDvVuvYk8Xddi+4o2SgWH6jiW8/I+3emZvbep1NC+g==",
"version": "1.15.32",
"resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.32.tgz",
"integrity": "sha512-G3Aa4tVS/3OGZBkoNIwUF9F6RAy+Osb4GOlo62SinLmDiErz/ykmM7KH0wkz6l9kM8jJq1HyAM6atJTUEbBk7g==",
"cpu": [
"s390x"
],
@ -6170,9 +6170,9 @@
}
},
"node_modules/@swc/core-linux-x64-gnu": {
"version": "1.15.30",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.30.tgz",
"integrity": "sha512-5Pizw3NgfOJ5BJOBK8TIRa59xFW2avESTOBDPTAYwZYa1JNDs+KMF9lUfjJiJLM5HiMs/wPheA9eiT0q9m2AoA==",
"version": "1.15.32",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.32.tgz",
"integrity": "sha512-ERsjfGcj6CBmj3vJnGDO8m8rTvw6RqMcWo1dogOtNx3/+/0+NNpJiXDobJrr1GwInI/BHAEkvSFIH6d2LqPcUQ==",
"cpu": [
"x64"
],
@ -6187,9 +6187,9 @@
}
},
"node_modules/@swc/core-linux-x64-musl": {
"version": "1.15.30",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.30.tgz",
"integrity": "sha512-qyqydP/wyH8alcIP4a2hnGSjHLJjm9H7yDFup+CPy9oTahFgLLwnNcv5UHXqO2Qs3AIND+cls5f/Bb6hqpxdgA==",
"version": "1.15.32",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.32.tgz",
"integrity": "sha512-N4Ggahe/8SUbTX50P6EdhbW9YWcgbZVb52R4cq6MK+zsoMjRq7rGvV5ztA05QnbaCYqMYx8rTY7KAIA3Crdo4Q==",
"cpu": [
"x64"
],
@ -6204,9 +6204,9 @@
}
},
"node_modules/@swc/core-win32-arm64-msvc": {
"version": "1.15.30",
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.30.tgz",
"integrity": "sha512-CaQENgDHVGOg1mSF5sQVgvfFHG9kjMor2rkLMLeLOkfZYNj13ppnJ9+lfaBZLZUMMbnlGQnavCJb8PVBUOso7Q==",
"version": "1.15.32",
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.32.tgz",
"integrity": "sha512-01yN0o9jvo8xBTP12aPK2wW8b41jmOlGbDDlAnoynotc4pO6xA0zby9f1z6j++qXDpGBttLySq1omgVrlQKYcw==",
"cpu": [
"arm64"
],
@ -6221,9 +6221,9 @@
}
},
"node_modules/@swc/core-win32-ia32-msvc": {
"version": "1.15.30",
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.30.tgz",
"integrity": "sha512-30VdLeGk6fugiUs/kUdJ/pAg7z/zpvVbR11RH60jZ0Z42WIeIniYx0rLEWN7h/pKJ3CopqsQ3RsogCAkRKiA2g==",
"version": "1.15.32",
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.32.tgz",
"integrity": "sha512-fLagI9XZYNpTcmlqAcp3KBtmj7E19WCmYD80Jxj1Kn5tGNa7yxNLd3NNdWxuZGUPl5iC0/KqZru7g08gF6Fsrw==",
"cpu": [
"ia32"
],
@ -6238,9 +6238,9 @@
}
},
"node_modules/@swc/core-win32-x64-msvc": {
"version": "1.15.30",
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.30.tgz",
"integrity": "sha512-4iObHPR+Q4oDY110EF5SF5eIaaVJNpMdG9C0q3Q92BsJ5y467uHz7sYQhP60WYlLFsLQ1el2YrIPUItUAQGOKg==",
"version": "1.15.32",
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.32.tgz",
"integrity": "sha512-gbc2bQ/T2CiR+w0OvcVKwLOFAcPZBvmWmolbwpg1E8UrpeC03DGtyMUApOHNXNYWA3SHFrYXCQtosrcMza1YFg==",
"cpu": [
"x64"
],
@ -6364,9 +6364,9 @@
}
},
"node_modules/@tanstack/query-core": {
"version": "5.99.2",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.99.2.tgz",
"integrity": "sha512-1HunU0bXVsR1ZJMZbcOPE6VtaBJxsW809RE9xPe4Gz7MlB0GWwQvuTPhMoEmQ/hIzFKJ/DWAuttIe7BOaWx0tA==",
"version": "5.100.6",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.6.tgz",
"integrity": "sha512-Os2CPUr98to98RYm+D4qGqGkiffn7MGSyl2547a4MljVkHE30AMJRqTiyCqBfMwzAx/I91vCkAxp5tHSla6Twg==",
"license": "MIT",
"funding": {
"type": "github",
@ -6374,12 +6374,12 @@
}
},
"node_modules/@tanstack/react-query": {
"version": "5.99.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.99.2.tgz",
"integrity": "sha512-vM91UEe45QUS9ED6OklsVL15i8qKcRqNwpWzPTVWvRPRSEgDudDgHpvyTjcdlwHcrKNa80T+xXYcchT2noPnZA==",
"version": "5.100.6",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.6.tgz",
"integrity": "sha512-uVSrps0PV16Cxmcn2rvL+dUhwTpTUtiRW347AEeYxMZXO2pZe9ja7E24PAMGoQ5u2g89DD8u4QhOviBk+RN8RA==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.99.2"
"@tanstack/query-core": "5.100.6"
},
"funding": {
"type": "github",
@ -7866,9 +7866,9 @@
}
},
"node_modules/ajv": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@ -8350,9 +8350,9 @@
}
},
"node_modules/bare-stream": {
"version": "2.13.0",
"resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.0.tgz",
"integrity": "sha512-3zAJRZMDFGjdn+RVnNpF9kuELw+0Fl3lpndM4NcEOhb9zwtSo/deETfuIwMSE5BXanA0FrN1qVjffGwAg2Y7EA==",
"version": "2.13.1",
"resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.1.tgz",
"integrity": "sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@ -8407,9 +8407,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.21",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.21.tgz",
"integrity": "sha512-Q+rUQ7Uz8AHM7DEaNdwvfFCTq7a43lNTzuS94eiWqwyxfV/wJv+oUivef51T91mmRY4d4A1u9rcSvkeufCVXlA==",
"version": "2.10.24",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.24.tgz",
"integrity": "sha512-I2NkZOOrj2XuguvWCK6OVh9GavsNjZjK908Rq3mIBK25+GD8vPX5w2WdxVqnQ7xx3SrZJiCiZFu+/Oz50oSYSA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@ -8812,9 +8812,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001790",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz",
"integrity": "sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==",
"version": "1.0.30001791",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz",
"integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==",
"dev": true,
"funding": [
{
@ -9784,9 +9784,9 @@
"license": "MIT"
},
"node_modules/dompurify": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.1.tgz",
"integrity": "sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz",
"integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@ -9837,9 +9837,9 @@
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.343",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.343.tgz",
"integrity": "sha512-YHnQ3MXI08icvL9ZKnEBy05F2EQ8ob01UaMOuMbM8l+4UcAq6MPPbBTJBbsBUg3H8JeZNt+O4fjsoWth3p6IFg==",
"version": "1.5.345",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.345.tgz",
"integrity": "sha512-F9JXQGiMrz6yVNPI2qOVPvB9HzjH5cGzhs8oJ6A28V5L/YnzN/0KsuiibqF+F1Fd9qxFzD1BUnYSd8JfULxTwg==",
"dev": true,
"license": "ISC"
},
@ -10293,9 +10293,9 @@
}
},
"node_modules/express-rate-limit": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz",
"integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==",
"version": "8.4.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.4.1.tgz",
"integrity": "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -11381,9 +11381,9 @@
}
},
"node_modules/hono": {
"version": "4.12.14",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz",
"integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
"version": "4.12.16",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.16.tgz",
"integrity": "sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg==",
"dev": true,
"license": "MIT",
"peer": true,
@ -12026,6 +12026,7 @@
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
"dev": true,
"license": "MIT",
"bin": {
@ -12733,6 +12734,7 @@
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
"dev": true,
"license": "MIT",
"bin": {
@ -13699,9 +13701,9 @@
}
},
"node_modules/jose": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
"integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
"dev": true,
"license": "MIT",
"funding": {
@ -16252,9 +16254,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
"version": "8.5.12",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
"funding": [
{
"type": "opencollective",
@ -16814,9 +16816,9 @@
"license": "MIT"
},
"node_modules/react-hook-form": {
"version": "7.73.1",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.73.1.tgz",
"integrity": "sha512-VAfVYOPcx3piiEVQy95vyFmBwbVUsP/AUIN+mpFG8h11yshDd444nn0VyfaGWSRnhOLVgiDu7HIuBtAIzxn9dA==",
"version": "7.74.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.74.0.tgz",
"integrity": "sha512-yR6wHr99p9wFv686jhRWVSFhUvDvNbdUf2dKlbno8/VKOCuoNobDGC6S+M2dua9A9Yo8vpcrp8assIYbsZCQ9g==",
"license": "MIT",
"peer": true,
"engines": {
@ -18206,9 +18208,9 @@
}
},
"node_modules/storybook": {
"version": "10.3.5",
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.3.5.tgz",
"integrity": "sha512-uBSZu/GZa9aEIW3QMGvdQPMZWhGxSe4dyRWU8B3/Vd47Gy/XLC7tsBxRr13txmmPOEDHZR94uLuq0H50fvuqBw==",
"version": "10.3.6",
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.3.6.tgz",
"integrity": "sha512-vbSz7g/1rGMC1uAULqMZjALkIuLu2QABqfhRYhyr/11kzyesi+vAmwyJLukZP1FfecxGOgMwOh6GS0YsGpHAvQ==",
"dev": true,
"license": "MIT",
"peer": true,
@ -18235,11 +18237,15 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
"prettier": "^2 || ^3"
"prettier": "^2 || ^3",
"vite-plus": "^0.1.15"
},
"peerDependenciesMeta": {
"prettier": {
"optional": true
},
"vite-plus": {
"optional": true
}
}
},
@ -18496,9 +18502,9 @@
}
},
"node_modules/svelte": {
"version": "5.55.4",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.4.tgz",
"integrity": "sha512-q8DFohk6vUswSng95IZb9nzWJnbINZsK7OiM1snAa3qCjJBL0ZQpvMyAaVXjUukdM75J/m8UE8xwqat8Ors/zQ==",
"version": "5.55.5",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.5.tgz",
"integrity": "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw==",
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
@ -18747,9 +18753,9 @@
}
},
"node_modules/tar-stream": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz",
"integrity": "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==",
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz",
"integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -19560,6 +19566,7 @@
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"

File diff suppressed because one or more lines are too long

View File

@ -702,7 +702,10 @@ class BaseFileComponent(Component, ABC):
data = file.data
if path.is_dir():
# Recurse into directories
# Recurse into directories. Skip symlinks defensively so that a
# link planted in a previously-extracted bundle (or a directory
# the user pointed at) cannot be dereferenced into an arbitrary
# host file (GHSA-ccv6-r384-xp75).
collected_files.extend(
[
BaseFileComponent.BaseFile(
@ -711,7 +714,7 @@ class BaseFileComponent(Component, ABC):
delete_after_processing=delete_after_processing,
)
for sub_path in path.rglob("*")
if sub_path.is_file()
if sub_path.is_file() and not sub_path.is_symlink()
]
)
elif path.suffix[1:] in self.SUPPORTED_BUNDLE_EXTENSIONS:
@ -720,7 +723,11 @@ class BaseFileComponent(Component, ABC):
self._temp_dirs.append(temp_dir)
temp_dir_path = Path(temp_dir.name)
self._unpack_bundle(path, temp_dir_path)
subpaths = list(temp_dir_path.iterdir())
# Drop any symlink that may have slipped through extraction.
# `_unpack_bundle` rejects link members for TAR archives, but
# this guard keeps the contract in place for any future bundle
# type added to SUPPORTED_BUNDLE_EXTENSIONS.
subpaths = [p for p in temp_dir_path.iterdir() if not p.is_symlink()]
self.log(f"Unpacked bundle {path.name} into {subpaths}")
collected_files.extend(
[
@ -768,11 +775,24 @@ class BaseFileComponent(Component, ABC):
bundle.extract(member, path=output_dir)
def _safe_extract_tar(bundle: tarfile.TarFile, output_dir: Path):
"""Safely extract TAR files."""
"""Safely extract TAR files.
Only regular files and directories are extracted. Symlinks, hardlinks,
and device/FIFO members are rejected because they could be made to
point at arbitrary locations on the host filesystem and lead to
arbitrary file read once the extracted entries are subsequently
ingested by `process_files()` (GHSA-ccv6-r384-xp75).
"""
for member in bundle.getmembers():
# Filter out resource fork information for automatic production of mac
if Path(member.name).name.startswith("._"):
continue
if member.issym() or member.islnk():
msg = f"Refusing to extract link member from TAR File: {member.name!r} -> {member.linkname!r}"
raise ValueError(msg)
if not (member.isfile() or member.isdir()):
msg = f"Refusing to extract non-regular TAR member: {member.name!r}"
raise ValueError(msg)
member_path = output_dir / member.name
# Ensure no path traversal outside `output_dir`
if not member_path.resolve().is_relative_to(output_dir.resolve()):

View File

@ -13,6 +13,7 @@ from lfx.base.constants import STREAM_INFO_TEXT
from lfx.custom.custom_component.component import Component
from lfx.field_typing import LanguageModel
from lfx.inputs.inputs import BoolInput, InputTypes, MessageInput, MultilineInput
from lfx.log.logger import logger
from lfx.schema.message import Message
from lfx.schema.properties import Usage
from lfx.schema.token_usage import extract_usage_from_message
@ -340,16 +341,40 @@ class LCModelComponent(Component):
session_id = self._session_id
else:
session_id = None
model_message = Message(
text=runnable.astream(inputs),
sender=MESSAGE_SENDER_AI,
sender_name="AI",
properties={"icon": self.icon, "state": "partial"},
session_id=session_id,
)
model_message.properties.source = self._build_source(self._id, self.display_name, self)
lf_message = await self.send_message(model_message)
result = lf_message.text or ""
# Streaming requires both a session_id and an event_manager:
# - session_id is required so astore_message validation passes when send_message
# persists the placeholder Message.
# - event_manager is required so the chunk iterator that backs Message.text gets
# drained; without one, no consumer iterates astream(), the iterator is stored
# verbatim, and downstream readers see empty text.
# If either is missing, fall back to a non-streaming ainvoke.
event_manager = getattr(self, "_event_manager", None)
if session_id and event_manager:
model_message = Message(
text=runnable.astream(inputs),
sender=MESSAGE_SENDER_AI,
sender_name="AI",
properties={"icon": self.icon, "state": "partial"},
session_id=session_id,
)
model_message.properties.source = self._build_source(self._id, self.display_name, self)
lf_message = await self.send_message(model_message)
result = lf_message.text or ""
else:
missing = []
if not session_id:
missing.append("session_id")
if not event_manager:
missing.append("event_manager")
component_label = getattr(self, "display_name", None) or getattr(self, "_id", "<unknown>")
logger.warning(
f"Streaming fallback to ainvoke for component '{component_label}' "
f"(id={getattr(self, '_id', '<unknown>')}): missing {', '.join(missing)}. "
"UI will not see token-by-token streaming for this run."
)
ai_message = await runnable.ainvoke(inputs)
result = ai_message.content if hasattr(ai_message, "content") else ai_message
result = _normalize_message_content(result)
else:
ai_message = await runnable.ainvoke(inputs)
result = ai_message.content if hasattr(ai_message, "content") else ai_message

View File

@ -67,36 +67,38 @@ def get_api_key_for_provider(user_id: UUID | str | None, provider: str, api_key:
# Literal API key (e.g. sk-...)
return var_name
# If no user_id or user_id is the string "None", we can't look up global variables
if user_id is None or (isinstance(user_id, str) and user_id == "None"):
return None
# Get primary variable (first required secret) from provider metadata
provider_variable_map = get_model_provider_variable_mapping()
variable_name = provider_variable_map.get(provider)
if not variable_name:
return None
# Try to get from global variables, fall back to environment
async def _get_variable():
async with session_scope() as session:
variable_service = get_variable_service()
if variable_service is None:
return None
try:
return await variable_service.get_variable(
user_id=UUID(user_id) if isinstance(user_id, str) else user_id,
name=variable_name,
field="",
session=session,
)
except ValueError:
return None
# Try the database-backed variable service first when a user_id is available.
# Fall through to os.environ regardless so lfx run (no user_id) can still pick
# up canonical credentials from the shell.
has_user = user_id is not None and not (isinstance(user_id, str) and user_id == "None")
api_key = None
if has_user:
try:
api_key = run_until_complete(_get_variable())
except (ValueError, Exception): # noqa: BLE001
api_key = None
async def _get_variable():
async with session_scope() as session:
variable_service = get_variable_service()
if variable_service is None:
return None
try:
return await variable_service.get_variable(
user_id=UUID(user_id) if isinstance(user_id, str) else user_id,
name=variable_name,
field="",
session=session,
)
except ValueError:
return None
try:
api_key = run_until_complete(_get_variable())
except (ValueError, Exception): # noqa: BLE001
api_key = None
if api_key:
return api_key

View File

@ -290,6 +290,7 @@ def get_embedding_model_options(
"Google Generative AI": {
"model": "model",
"api_key": "google_api_key",
"dimensions": "output_dimensionality",
"request_timeout": "request_options",
"model_kwargs": "client_options",
},

View File

@ -60,6 +60,14 @@ def register(app: typer.Typer) -> None:
show_default=True,
help="Include detailed timing information in output",
),
session_id: str | None = typer.Option(
None,
"--session-id",
help=(
"Session ID to attach to the run. "
"Agent and Memory Components will use this to track conversation history."
),
),
) -> None:
"""Run a flow directly (lazy-loaded)."""
from pathlib import Path
@ -81,6 +89,7 @@ def register(app: typer.Typer) -> None:
verbose_detailed=verbose_detailed,
verbose_full=verbose_full,
timing=timing,
session_id=session_id,
)
@app.command(name="serve", help="Serve a flow as an API", no_args_is_help=True, rich_help_panel="Running")

View File

@ -29,6 +29,7 @@ from lfx.cli.script_loader import (
load_graph_from_script,
)
from lfx.load import load_flow_from_json
from lfx.run._defaults import apply_run_defaults, resolve_fallback_to_env_vars
from lfx.schema.schema import InputValueRequest
if TYPE_CHECKING:
@ -299,12 +300,16 @@ def prepare_graph(graph, verbose_print):
raise typer.Exit(1) from e
async def execute_graph_with_capture(graph, input_value: str | None):
async def execute_graph_with_capture(graph, input_value: str | None, session_id: str | None = None):
"""Execute a graph and capture output.
Args:
graph: Graph object to execute
input_value: Input value to pass to the graph
session_id: Optional session ID. ``None`` auto-generates one so that
message-store paths (which validate session_id) succeed; an empty or
whitespace-only string is rejected with ``ValueError`` to surface
shell/env-var typos (see ``lfx.run._defaults.validate_provided_id``).
Returns:
Tuple of (results, captured_logs)
@ -312,6 +317,11 @@ async def execute_graph_with_capture(graph, input_value: str | None):
Raises:
Exception: Re-raises any exception that occurs during graph execution
"""
# Apply session_id, user_id, and Memory-vertex propagation defaults via the
# shared helper (same logic as run_flow). user_id is not exposed in this
# entry point, so any pre-existing graph.user_id is preserved.
apply_run_defaults(graph, session_id=session_id, user_id=None, overwrite_user_id=False)
# Create input request
inputs = InputValueRequest(input_value=input_value) if input_value else None
@ -323,10 +333,12 @@ async def execute_graph_with_capture(graph, input_value: str | None):
original_stdout = sys.stdout
original_stderr = sys.stderr
fallback_to_env_vars = resolve_fallback_to_env_vars()
try:
sys.stdout = captured_stdout
sys.stderr = captured_stderr
results = [result async for result in graph.async_start(inputs)]
results = [result async for result in graph.async_start(inputs, fallback_to_env_vars=fallback_to_env_vars)]
except Exception as exc:
# Capture any error output that was written to stderr
error_output = captured_stderr.getvalue()

View File

@ -102,6 +102,13 @@ async def run(
show_default=True,
help="Include detailed timing information in output",
),
session_id: str | None = typer.Option(
None,
"--session-id",
help=(
"Session ID to attach to the run. Agent and Memory Components will use this to track conversation history."
),
),
) -> None:
"""Execute a Langflow graph script or JSON flow and return the result.
@ -121,6 +128,7 @@ async def run(
stdin: Read JSON flow content from stdin
check_variables: Check global variables for environment compatibility
timing: Include detailed timing information in output
session_id: Optional session ID; auto-generated if not supplied
"""
# Determine verbosity for output formatting
verbosity = 3 if verbose_full else (2 if verbose_detailed else (1 if verbose else 0))
@ -139,6 +147,7 @@ async def run(
verbose_full=verbose_full,
timing=timing,
global_variables=None,
session_id=session_id,
)
# Output based on format

View File

@ -226,6 +226,7 @@ class RunRequest(BaseModel):
"""Request model for executing a LFX flow."""
input_value: str = Field(..., description="Input value passed to the flow")
session_id: str | None = Field(default=None, description="Session ID for maintaining conversation state")
class StreamRequest(BaseModel):
@ -329,7 +330,9 @@ async def run_flow_generator_for_serve(
# For the serve app, we'll use execute_graph_with_capture with streaming
# Note: This is a simplified version. In a full implementation, you might want
# to integrate with the full LFX streaming pipeline from endpoints.py
results, logs = await execute_graph_with_capture(graph, input_request.input_value)
results, logs = await execute_graph_with_capture(
graph, input_request.input_value, session_id=input_request.session_id
)
result_data = extract_result_data(results, logs)
# Send the final result
@ -422,7 +425,9 @@ def create_multi_serve_app(
try:
validate_flow_for_current_settings(graph)
graph_copy = deepcopy(graph)
results, logs = await execute_graph_with_capture(graph_copy, request.input_value)
results, logs = await execute_graph_with_capture(
graph_copy, request.input_value, session_id=request.session_id
)
result_data = extract_result_data(results, logs)
# Debug logging

View File

@ -368,7 +368,7 @@ class CugaComponent(ToolCallingAgentComponent):
sender_name="Cuga",
properties={"icon": "Bot", "state": "partial"},
content_blocks=[ContentBlock(title="Agent Steps", contents=[])],
session_id=self.graph.session_id,
session_id=self.graph.session_id or str(uuid.uuid4()),
)
# Pre-assign an ID for event processing, following the base agent pattern

View File

@ -361,6 +361,7 @@ class Graph:
event_manager: EventManager | None = None,
*,
reset_output_values: bool = True,
fallback_to_env_vars: bool = False,
):
# Preserve start_component_id from constructor if available
start_component_id = self._start.get_id() if self._start else None
@ -379,7 +380,9 @@ class Graph:
yielded_counts: dict[str, int] = defaultdict(int)
while should_continue(yielded_counts, max_iterations):
result = await self.astep(event_manager=event_manager, inputs=inputs)
result = await self.astep(
event_manager=event_manager, inputs=inputs, fallback_to_env_vars=fallback_to_env_vars
)
yield result
if isinstance(result, Finish):
return
@ -1441,6 +1444,8 @@ class Graph:
files: list[str] | None = None,
user_id: str | None = None,
event_manager: EventManager | None = None,
*,
fallback_to_env_vars: bool = False,
):
if not self._prepared:
msg = "Graph not prepared. Call prepare() first."
@ -1479,6 +1484,7 @@ class Graph:
get_cache=get_cache_func,
set_cache=set_cache_func,
event_manager=event_manager,
fallback_to_env_vars=fallback_to_env_vars,
)
next_runnable_vertices = await self.get_next_runnable_vertices(

View File

@ -41,10 +41,20 @@ async def astore_message(
)
raise ValueError(msg)
# Set flow_id if provided
# Set flow_id if provided. The stub is the fallback when no real database is
# registered, so be tolerant of non-UUID flow_ids (e.g. synthetic IDs from tests
# or callers that pass a string identifier). UUID parsing here only normalizes
# format; an invalid string is preserved verbatim, but logged so downstream
# UUID-expecting code paths have a breadcrumb if they later fail.
if flow_id:
if isinstance(flow_id, str):
flow_id = UUID(flow_id)
try:
flow_id = UUID(flow_id)
except ValueError:
logger.warning(
f"flow_id {flow_id!r} is not a valid UUID; preserving verbatim. "
"Downstream code that expects a UUID may surface a confusing error."
)
message.flow_id = str(flow_id)
# In lfx, we use the service architecture - this is a simplified implementation

View File

@ -0,0 +1,132 @@
"""Shared helpers for applying run-time defaults to a Graph.
The CLI's ``lfx run`` (``run_flow``) and ``lfx serve``/``execute_graph_with_capture``
both need to:
1. Reject empty/whitespace-only ``session_id`` / ``user_id`` (so a typo or
empty env var doesn't silently produce a random session and break Memory
continuity).
2. Auto-generate a UUID when neither is supplied (so component prechecks and
``astore_message`` validation don't fail).
3. Propagate ``session_id`` to ``Memory``/``MessageHistory`` vertices the way
``langflow.api.utils.flow_utils.build_graph_from_data`` does — preserving
any value already pinned in the flow JSON.
4. Resolve ``fallback_to_env_vars`` from settings (mirrors the langflow API
path in ``processing.process.run_graph_internal``).
Both call sites used to inline these steps; consolidating them here prevents
behavior drift between ``lfx run`` and ``lfx serve``.
"""
from __future__ import annotations
import uuid
from typing import TYPE_CHECKING
from lfx.log.logger import logger
from lfx.services.deps import get_settings_service
if TYPE_CHECKING:
from lfx.graph.graph.base import Graph
def validate_provided_id(name: str, value: str | None) -> None:
"""Reject empty/whitespace strings while allowing None (which means "auto-generate").
Empty strings reach this code via shell quirks (``--session-id ""``), env-var
expansion that resolved to empty, or callers that defaulted a missing arg to
``""`` instead of ``None``. Treating them the same as ``None`` would silently
fall through to UUID generation — and the user would only learn their session
didn't carry over by noticing Memory looks empty on the next run.
"""
if value is None:
return
if not isinstance(value, str) or not value.strip():
msg = (
f"{name} was provided but is empty or whitespace. "
f"Pass a non-empty value, or omit {name} to auto-generate one."
)
raise ValueError(msg)
def apply_run_defaults(
graph: Graph,
*,
session_id: str | None,
user_id: str | None,
overwrite_user_id: bool = True,
) -> tuple[str, str]:
"""Apply session_id, user_id, and vertex-propagation defaults to *graph*.
Validates non-None values are non-empty (raises ``ValueError`` otherwise).
Auto-generates UUID hex strings for any value left as ``None``. Sets the
resolved values on the graph and propagates ``session_id`` to vertices in
``graph.has_session_id_vertices`` that don't already have a hardcoded value.
Args:
graph: The graph to mutate.
session_id: Caller-supplied session_id. ``None`` -> auto-generate.
user_id: Caller-supplied user_id. ``None`` -> auto-generate.
overwrite_user_id: When False, an existing ``graph.user_id`` is preserved
(matches the prior ``execute_graph_with_capture`` behavior). When True,
the resolved user_id always wins (matches the prior ``run_flow``
behavior, which intentionally re-stamps the graph).
Returns:
``(session_id, user_id)`` — the values actually applied to the graph.
"""
validate_provided_id("session_id", session_id)
validate_provided_id("user_id", user_id)
if not user_id:
user_id = uuid.uuid4().hex
logger.debug(
f"No user_id provided; auto-generated {user_id} to satisfy component prechecks. "
"lfx's variable service is env-backed, so user_id is not used for variable scoping."
)
if overwrite_user_id or not getattr(graph, "user_id", None):
graph.user_id = user_id
else:
# Caller-supplied None plus an existing graph.user_id: preserve the existing.
user_id = graph.user_id
if not session_id:
session_id = uuid.uuid4().hex
# info, not warning: this is the normal case for one-shot CLI runs and
# would otherwise spam stderr on every invocation. Visible at -v.
logger.info(
f"No session_id provided; auto-generated {session_id}. "
"Memory/MessageHistory components will not see prior conversation state across runs."
)
graph.session_id = session_id
# Mirror langflow's build_graph_from_data: only write when raw_params has no
# session_id (hardcoded values pinned in the flow JSON win). graph.async_start
# bypasses the equivalent loop in Graph._run, so without this Memory components
# would read "" from their input field even when --session-id is set.
for vertex_id in graph.has_session_id_vertices:
vertex = graph.get_vertex(vertex_id)
if vertex is None:
continue
if not vertex.raw_params.get("session_id"):
vertex.update_raw_params({"session_id": session_id}, overwrite=True)
return session_id, user_id
def resolve_fallback_to_env_vars() -> bool:
"""Read ``fallback_to_env_var`` from settings, defaulting to True if the service is missing.
Mirrors the langflow API path (``processing.process.run_graph_internal``):
when a ``load_from_db`` variable misses (e.g. the ceremonial UUID has no
Variable row), fall through to ``os.environ`` instead of failing the build.
A missing settings_service is unusual — log a warning so the user can debug.
"""
settings_service = get_settings_service()
if settings_service is None:
logger.warning(
"settings_service is unavailable; defaulting fallback_to_env_vars=True. "
"If load_from_db variables are misbehaving, verify the settings service initialized."
)
return True
return bool(settings_service.settings.fallback_to_env_var)

View File

@ -16,6 +16,7 @@ from lfx.cli.script_loader import (
)
from lfx.cli.validation import validate_global_variables_for_env
from lfx.log.logger import logger
from lfx.run._defaults import apply_run_defaults, resolve_fallback_to_env_vars, validate_provided_id
from lfx.schema.schema import InputValueRequest
if TYPE_CHECKING:
@ -116,6 +117,18 @@ async def run_flow(
configure(log_level="CRITICAL", output_file=sys.stderr)
verbosity = 0
# Validate caller-supplied IDs up-front so users get a clear error before any
# graph loading work happens. None means "auto-generate later"; "" / whitespace
# is rejected so a typo or empty env var doesn't silently produce a fresh
# session and break Memory continuity.
try:
validate_provided_id("session_id", session_id)
validate_provided_id("user_id", user_id)
except ValueError as e:
error_msg = str(e)
output_error(error_msg, verbose=verbose, exception=e)
raise RunError(error_msg, e) from e
start_time = time.time() if timing else None
# Use either positional input_value or --input-value option
@ -221,17 +234,14 @@ async def run_flow(
error_msg = "No input source provided"
raise ValueError(error_msg)
# Set user_id on graph if provided (required for some components like AgentComponent)
if user_id:
graph.user_id = user_id
if verbosity > 0:
logger.info(f"Set graph user_id: {user_id}")
# Set session_id on graph if provided (isolates memory between requests)
if session_id:
graph.session_id = session_id
if verbosity > 0:
logger.info(f"Set graph session_id: {session_id}")
# Apply session_id, user_id, and Memory-vertex propagation defaults. Both
# are auto-generated when not supplied so component prechecks (custom_component
# .get_variable for user_id) and astore_message validation (for session_id)
# don't fail. See lfx.run._defaults.apply_run_defaults for the full rationale.
session_id, user_id = apply_run_defaults(graph, session_id=session_id, user_id=user_id)
if verbosity > 0:
logger.info(f"Set graph user_id: {user_id}")
logger.info(f"Set graph session_id: {session_id}")
# Inject global variables into graph context
if global_variables:
@ -348,7 +358,14 @@ async def run_flow(
logger.info("Starting graph execution...", level="DEBUG")
async for result in graph.async_start(inputs, event_manager=event_manager):
# See lfx.run._defaults.resolve_fallback_to_env_vars for why this flag is
# plumbed through (mirrors langflow's API path so load_from_db variables
# fall through to os.environ on miss instead of erroring the build).
fallback_to_env_vars = resolve_fallback_to_env_vars()
async for result in graph.async_start(
inputs, event_manager=event_manager, fallback_to_env_vars=fallback_to_env_vars
):
result_count += 1
if verbosity > 0:
logger.debug(f"Processing result #{result_count}")

View File

@ -307,7 +307,7 @@ def raise_as_deployment_error(
raise InvalidDeploymentOperationError(message=message, cause=cause) from cause
if status_code == status.HTTP_405_METHOD_NOT_ALLOWED:
raise InvalidDeploymentOperationError(message=message, cause=cause) from cause
if status_code == status.HTTP_413_REQUEST_ENTITY_TOO_LARGE:
if status_code == status.HTTP_413_CONTENT_TOO_LARGE:
raise InvalidContentError(message=message, cause=cause) from cause
if status_code == status.HTTP_415_UNSUPPORTED_MEDIA_TYPE:
raise InvalidContentError(message=message, cause=cause) from cause

View File

@ -2,7 +2,7 @@ from pydantic_settings import BaseSettings
class FeatureFlags(BaseSettings):
wxo_deployments: bool = True
wxo_deployments: bool = False
"""
Enable Watsonx Orchestrate deployments.
"""

View File

@ -22,14 +22,23 @@ class VariableService(Service):
self.set_ready()
logger.debug("Variable service initialized (env vars only)")
def get_variable(self, name: str, **kwargs) -> str | None: # noqa: ARG002
async def get_variable(self, name: str, **kwargs) -> str | None: # noqa: ARG002
"""Get a variable value.
First checks in-memory cache, then environment variables.
Async to match the call signature in custom_component.get_variable
(`await variable_service.get_variable(...)`), which is the path used
by component variable resolution. The lookup itself is sync — no I/O —
but the coroutine wrapper is required so callers can `await` it
regardless of which variable service implementation is registered
(lfx env-fallback vs langflow DB-backed).
Args:
name: Variable name
**kwargs: Additional arguments (ignored in minimal implementation)
**kwargs: Additional arguments (ignored; user_id/field/session
from langflow's call signature are absorbed and not used,
since this implementation has no per-user scope).
Returns:
Variable value or None if not found

View File

@ -0,0 +1,236 @@
"""Security regression tests for BaseFileComponent bundle extraction.
Covers GHSA-ccv6-r384-xp75: a TAR member that is a symlink, hardlink, or device
node could be made to point at an arbitrary host file. When the extracted entry
is later read by `process_files()` the host file's contents would be ingested
into the downstream sink. The fix in `_safe_extract_tar` rejects every member
that is not a regular file or directory, and `_unpack_and_collect_files` skips
any symlinks defensively before handing entries to `process_files()`.
"""
from __future__ import annotations
import io
import tarfile
import zipfile
from typing import TYPE_CHECKING
import pytest
from lfx.base.data.base_file import BaseFileComponent
if TYPE_CHECKING:
from pathlib import Path
class _StubFileComponent(BaseFileComponent):
"""Minimal concrete subclass used to exercise the unpack helpers."""
VALID_EXTENSIONS = ["txt"]
def __init__(self, **data):
super().__init__(**data)
self.set_attributes(
{
"path": [],
"file_path": None,
"separator": "\n\n",
"silent_errors": False,
"delete_server_file_after_processing": True,
"ignore_unsupported_extensions": True,
"ignore_unspecified_files": False,
}
)
def process_files(self, file_list): # pragma: no cover - not exercised here
return file_list
def _add_file(tar: tarfile.TarFile, name: str, payload: bytes) -> None:
info = tarfile.TarInfo(name=name)
info.size = len(payload)
info.type = tarfile.REGTYPE
tar.addfile(info, io.BytesIO(payload))
def _add_symlink(tar: tarfile.TarFile, name: str, target: str) -> None:
info = tarfile.TarInfo(name=name)
info.type = tarfile.SYMTYPE
info.linkname = target
tar.addfile(info)
def _add_hardlink(tar: tarfile.TarFile, name: str, target: str) -> None:
info = tarfile.TarInfo(name=name)
info.type = tarfile.LNKTYPE
info.linkname = target
tar.addfile(info)
def _add_fifo(tar: tarfile.TarFile, name: str) -> None:
info = tarfile.TarInfo(name=name)
info.type = tarfile.FIFOTYPE
tar.addfile(info)
def _build_tar(tmp_path: Path, name: str, populate) -> Path:
bundle = tmp_path / name
with tarfile.open(bundle, "w") as tar:
populate(tar)
return bundle
def _build_zip(tmp_path: Path, name: str, files: dict[str, bytes]) -> Path:
bundle = tmp_path / name
with zipfile.ZipFile(bundle, "w") as zf:
for member, payload in files.items():
zf.writestr(member, payload)
return bundle
@pytest.fixture
def component() -> _StubFileComponent:
return _StubFileComponent()
def test_tar_with_absolute_symlink_is_rejected(tmp_path, component):
"""A symlink whose target is an absolute host path must be refused."""
secret = tmp_path / "secret.txt"
secret.write_bytes(b"jwt-signing-secret")
bundle = _build_tar(
tmp_path,
"evil.tar",
lambda tar: _add_symlink(tar, "leak", str(secret)),
)
extract_dir = tmp_path / "out_abs"
extract_dir.mkdir()
with pytest.raises(ValueError, match="Refusing to extract link member"):
component._unpack_bundle(bundle, extract_dir)
assert list(extract_dir.iterdir()) == []
def test_tar_with_relative_escape_symlink_is_rejected(tmp_path, component):
"""A symlink that uses ../ to escape the extract dir must be refused."""
bundle = _build_tar(
tmp_path,
"escape.tar",
lambda tar: _add_symlink(tar, "leak", "../../etc/passwd"),
)
extract_dir = tmp_path / "out_rel"
extract_dir.mkdir()
with pytest.raises(ValueError, match="Refusing to extract link member"):
component._unpack_bundle(bundle, extract_dir)
assert list(extract_dir.iterdir()) == []
def test_tar_with_hardlink_is_rejected(tmp_path, component):
"""Hardlinks have the same arbitrary-target risk as symlinks."""
def populate(tar):
_add_file(tar, "real.txt", b"ok")
_add_hardlink(tar, "leak", "../etc/passwd")
bundle = _build_tar(tmp_path, "hardlink.tar", populate)
extract_dir = tmp_path / "out_hl"
extract_dir.mkdir()
with pytest.raises(ValueError, match="Refusing to extract link member"):
component._unpack_bundle(bundle, extract_dir)
def test_tar_with_fifo_member_is_rejected(tmp_path, component):
"""Non-regular members (FIFO/device) must be refused."""
bundle = _build_tar(tmp_path, "fifo.tar", lambda tar: _add_fifo(tar, "pipe"))
extract_dir = tmp_path / "out_fifo"
extract_dir.mkdir()
with pytest.raises(ValueError, match="Refusing to extract non-regular TAR member"):
component._unpack_bundle(bundle, extract_dir)
def test_tar_with_only_regular_files_extracts(tmp_path, component):
"""The fix must not regress benign archives."""
def populate(tar):
_add_file(tar, "a.txt", b"alpha")
_add_file(tar, "nested/b.txt", b"beta")
bundle = _build_tar(tmp_path, "ok.tar", populate)
extract_dir = tmp_path / "out_ok"
extract_dir.mkdir()
component._unpack_bundle(bundle, extract_dir)
assert (extract_dir / "a.txt").read_bytes() == b"alpha"
assert (extract_dir / "nested" / "b.txt").read_bytes() == b"beta"
def test_zip_with_only_regular_files_extracts(tmp_path, component):
"""ZIP path must remain working unchanged."""
bundle = _build_zip(tmp_path, "ok.zip", {"a.txt": b"alpha", "nested/b.txt": b"beta"})
extract_dir = tmp_path / "out_zip"
extract_dir.mkdir()
component._unpack_bundle(bundle, extract_dir)
assert (extract_dir / "a.txt").read_bytes() == b"alpha"
assert (extract_dir / "nested" / "b.txt").read_bytes() == b"beta"
def test_collect_files_skips_symlinks_in_extracted_dir(tmp_path, component):
"""Defense-in-depth check for the post-extraction iteration.
A symlink that somehow lands in an unpacked dir must not be passed to
``process_files()``. Simulated by manually planting one, since
``_safe_extract_tar`` would otherwise refuse it.
"""
extract_root = tmp_path / "extracted"
extract_root.mkdir()
real = extract_root / "doc.txt"
real.write_bytes(b"hello")
secret = tmp_path / "secret.txt"
secret.write_bytes(b"top-secret")
(extract_root / "leak").symlink_to(secret)
files = [BaseFileComponent.BaseFile(data=None, path=extract_root)]
collected = component._unpack_and_collect_files(files)
paths = {bf.path.name for bf in collected}
assert "doc.txt" in paths
assert "leak" not in paths
def test_unpack_bundle_rejects_unsupported_format(tmp_path, component):
"""An input that is neither zip nor tar still raises clearly."""
bogus = tmp_path / "not-a-bundle.bin"
bogus.write_bytes(b"not an archive")
extract_dir = tmp_path / "out_bogus"
extract_dir.mkdir()
with pytest.raises(ValueError, match="Unsupported bundle format"):
component._unpack_bundle(bogus, extract_dir)
def test_real_filesystem_symlink_in_a_tar_via_tarfile_is_rejected(tmp_path, component):
"""End-to-end repro of the advisory's PoC archive shape.
Builds the tar from a real filesystem symlink (the way the reporter's
archive was produced) and confirms extraction is refused.
"""
target = tmp_path / "host_secret"
target.write_bytes(b"x")
workdir = tmp_path / "src"
workdir.mkdir()
(workdir / "leak").symlink_to(target)
bundle = tmp_path / "from_fs.tar"
with tarfile.open(bundle, "w") as tar:
tar.add(workdir / "leak", arcname="leak")
extract_dir = tmp_path / "out_fs"
extract_dir.mkdir()
with pytest.raises(ValueError, match="Refusing to extract link member"):
component._unpack_bundle(bundle, extract_dir)
assert list(extract_dir.iterdir()) == []

View File

@ -0,0 +1,120 @@
"""Tests for LCModelComponent._handle_stream session_id handling.
When a streaming LLM is wired to ChatOutput, the streaming path tries to persist
a placeholder Message via send_message. With no session_id (e.g. ``lfx run`` with
NoopSession and no ``--session-id``), astore_message rejects the empty value.
The fallback in _handle_stream invokes the model non-streamingly so the run can
still complete.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from lfx.base.models.model import LCModelComponent
class _StreamableProbe(LCModelComponent):
"""Minimal LCModelComponent subclass usable without going through Component init."""
display_name = "Probe"
description = "test"
def build_model(self): # pragma: no cover - abstract stub
raise NotImplementedError
def _make_probe(*, connected: bool, session_id, event_manager=None):
probe = _StreamableProbe.__new__(_StreamableProbe)
probe.is_connected_to_chat_output = MagicMock(return_value=connected)
# ``graph`` is a read-only property on Component (-> self._vertex.graph),
# so wire the underlying _vertex instead of trying to assign graph directly.
probe._vertex = SimpleNamespace(graph=SimpleNamespace(session_id=session_id, flow_id=None))
probe.icon = "brain"
probe._id = "probe-1"
probe._event_manager = event_manager
probe.send_message = AsyncMock()
probe._build_source = MagicMock(return_value=None)
return probe
@pytest.mark.asyncio
async def test_handle_stream_falls_back_to_invoke_when_no_session_id():
"""Empty graph.session_id must not call send_message; falls back to ainvoke."""
probe = _make_probe(connected=True, session_id="", event_manager=MagicMock())
runnable = SimpleNamespace(
astream=MagicMock(),
ainvoke=AsyncMock(return_value=SimpleNamespace(content="hi from invoke")),
)
lf_message, result, ai_message = await probe._handle_stream(runnable, "input")
runnable.ainvoke.assert_awaited_once_with("input")
runnable.astream.assert_not_called()
probe.send_message.assert_not_awaited()
assert lf_message is None
assert result == "hi from invoke"
assert isinstance(result, str)
assert ai_message is not None
@pytest.mark.asyncio
async def test_handle_stream_falls_back_to_invoke_when_no_event_manager():
"""Without an event_manager (e.g. lfx run) the chunk iterator would never be drained.
The fallback prevents send_message from storing a Message whose text is an unconsumed
AsyncIterator, which previously surfaced as an empty result downstream.
"""
probe = _make_probe(connected=True, session_id="sess-123", event_manager=None)
runnable = SimpleNamespace(
astream=MagicMock(),
ainvoke=AsyncMock(return_value=SimpleNamespace(content="batched")),
)
lf_message, result, ai_message = await probe._handle_stream(runnable, "input")
runnable.ainvoke.assert_awaited_once_with("input")
runnable.astream.assert_not_called()
probe.send_message.assert_not_awaited()
assert lf_message is None
assert result == "batched"
# Lock in that the fallback returns plain text, not the unconsumed AsyncIterator
# that astream would have produced — the original bug surfaced as empty downstream
# text because the iterator was stored verbatim.
assert isinstance(result, str)
assert ai_message is not None
@pytest.mark.asyncio
async def test_handle_stream_uses_send_message_when_session_id_and_event_manager_present():
"""Both session_id and event_manager present -> original streaming + persistence path."""
probe = _make_probe(connected=True, session_id="sess-123", event_manager=MagicMock())
probe.send_message.return_value = SimpleNamespace(text="streamed text")
runnable = SimpleNamespace(astream=MagicMock(), ainvoke=AsyncMock())
lf_message, result, ai_message = await probe._handle_stream(runnable, "input")
probe.send_message.assert_awaited_once()
runnable.ainvoke.assert_not_awaited()
assert lf_message is not None
assert result == "streamed text"
assert ai_message is None
@pytest.mark.asyncio
async def test_handle_stream_invokes_directly_when_not_connected_to_chat_output():
"""Pre-existing branch: not connected -> ainvoke regardless of session_id."""
probe = _make_probe(connected=False, session_id="sess-123", event_manager=MagicMock())
runnable = SimpleNamespace(
astream=MagicMock(),
ainvoke=AsyncMock(return_value=SimpleNamespace(content="direct")),
)
lf_message, result, _ = await probe._handle_stream(runnable, "input")
runnable.ainvoke.assert_awaited_once_with("input")
probe.send_message.assert_not_awaited()
assert lf_message is None
assert result == "direct"

View File

@ -208,7 +208,7 @@ class TestGraphExecution:
# Mock graph and async iterator
mock_result = MagicMock(results={"text": "Test result"})
async def mock_async_start(inputs): # noqa: ARG001
async def mock_async_start(inputs, **kwargs): # noqa: ARG001
yield mock_result
mock_graph = MagicMock()
@ -229,7 +229,7 @@ class TestGraphExecution:
# Ensure results attribute doesn't exist
delattr(mock_result, "results")
async def mock_async_start(inputs): # noqa: ARG001
async def mock_async_start(inputs, **kwargs): # noqa: ARG001
yield mock_result
mock_graph = MagicMock()
@ -244,7 +244,7 @@ class TestGraphExecution:
async def test_execute_graph_with_capture_error(self):
"""Test graph execution with error."""
async def mock_async_start_error(inputs): # noqa: ARG001
async def mock_async_start_error(inputs, **kwargs): # noqa: ARG001
msg = "Execution failed"
raise RuntimeError(msg)
yield # This line never executes but makes it an async generator
@ -255,6 +255,160 @@ class TestGraphExecution:
with pytest.raises(RuntimeError, match="Execution failed"):
await execute_graph_with_capture(mock_graph, "test input")
@pytest.mark.asyncio
async def test_execute_graph_with_capture_autogenerates_session_id(self):
"""Auto-generate a session_id when none is provided.
Message-store validators reject empty session_id, so the helper assigns one
to keep streaming/persistence paths functional in lfx serve.
"""
async def mock_async_start(inputs, **kwargs): # noqa: ARG001
yield MagicMock(results={"text": "ok"})
mock_graph = MagicMock()
mock_graph.async_start = mock_async_start
await execute_graph_with_capture(mock_graph, "test input")
assert mock_graph.session_id, "session_id should be auto-generated"
assert isinstance(mock_graph.session_id, str)
@pytest.mark.asyncio
async def test_execute_graph_with_capture_preserves_caller_session_id(self):
"""An explicit session_id wins over auto-generation."""
async def mock_async_start(inputs, **kwargs): # noqa: ARG001
yield MagicMock(results={"text": "ok"})
mock_graph = MagicMock()
mock_graph.async_start = mock_async_start
await execute_graph_with_capture(mock_graph, "test input", session_id="fixed-session")
assert mock_graph.session_id == "fixed-session"
@pytest.mark.asyncio
async def test_execute_graph_propagates_session_id_to_vertices(self):
"""Session_id must reach Memory/MessageHistory inputs on the lfx serve path.
``execute_graph_with_capture`` uses ``graph.async_start``, which bypasses
the propagation loop in ``Graph._run``. The helper has to replicate it
so served ``/run`` and ``/stream`` requests behave like the playground.
"""
async def mock_async_start(inputs, **kwargs): # noqa: ARG001
yield MagicMock(results={"text": "ok"})
mock_graph = MagicMock()
mock_graph.async_start = mock_async_start
memory_vertex = MagicMock()
memory_vertex.raw_params = {}
memory_vertex.update_raw_params = MagicMock()
mock_graph.has_session_id_vertices = ["memory-1"]
mock_graph.get_vertex = MagicMock(return_value=memory_vertex)
await execute_graph_with_capture(mock_graph, "test input", session_id="my-conversation")
memory_vertex.update_raw_params.assert_called_once_with({"session_id": "my-conversation"}, overwrite=True)
@pytest.mark.asyncio
async def test_execute_graph_does_not_overwrite_hardcoded_session_id(self):
"""Hardcoded session_id on a Memory component (set in flow JSON) wins over the request value.
Mirrors Langflow's playground precedence in ``build_graph_from_data``.
"""
async def mock_async_start(inputs, **kwargs): # noqa: ARG001
yield MagicMock(results={"text": "ok"})
mock_graph = MagicMock()
mock_graph.async_start = mock_async_start
pinned_vertex = MagicMock()
pinned_vertex.raw_params = {"session_id": "hardcoded-in-flow"}
pinned_vertex.update_raw_params = MagicMock()
mock_graph.has_session_id_vertices = ["memory-pinned"]
mock_graph.get_vertex = MagicMock(return_value=pinned_vertex)
await execute_graph_with_capture(mock_graph, "test input", session_id="from-request")
pinned_vertex.update_raw_params.assert_not_called()
@pytest.mark.asyncio
async def test_execute_graph_autogenerates_user_id_when_unset(self):
"""When the graph arrives without a user_id (typical for lfx serve), assign a UUID.
AgentComponent's variable lookup precheck requires a non-empty user_id; the
env-fallback variable service does not use it for scoping, so a random UUID is
ceremonial but necessary.
"""
async def mock_async_start(inputs, **kwargs): # noqa: ARG001
yield MagicMock(results={"text": "ok"})
mock_graph = MagicMock()
mock_graph.async_start = mock_async_start
mock_graph.user_id = None
await execute_graph_with_capture(mock_graph, "test input")
assert mock_graph.user_id, "user_id should be auto-assigned when graph has none"
assert isinstance(mock_graph.user_id, str)
@pytest.mark.asyncio
async def test_execute_graph_preserves_existing_user_id(self):
"""A user_id already set on the graph (e.g., by an upstream caller) is left alone."""
async def mock_async_start(inputs, **kwargs): # noqa: ARG001
yield MagicMock(results={"text": "ok"})
mock_graph = MagicMock()
mock_graph.async_start = mock_async_start
mock_graph.user_id = "preset-user-uuid"
await execute_graph_with_capture(mock_graph, "test input")
assert mock_graph.user_id == "preset-user-uuid"
@pytest.mark.asyncio
async def test_execute_graph_passes_fallback_from_settings_default(self):
"""Default settings (fallback_to_env_var=True) reach async_start.
Lets components fall through to os.environ when a load_from_db variable
has no DB row — matching langflow's API path behavior.
"""
captured: dict = {}
async def mock_async_start(inputs, **kwargs): # noqa: ARG001
captured.update(kwargs)
yield MagicMock(results={"text": "ok"})
mock_graph = MagicMock()
mock_graph.async_start = mock_async_start
await execute_graph_with_capture(mock_graph, "test input")
assert captured.get("fallback_to_env_vars") is True
@pytest.mark.asyncio
async def test_execute_graph_respects_disabled_fallback_setting(self):
"""When the user opts out of env fallback in settings, the flag is False."""
captured: dict = {}
async def mock_async_start(inputs, **kwargs): # noqa: ARG001
captured.update(kwargs)
yield MagicMock(results={"text": "ok"})
mock_graph = MagicMock()
mock_graph.async_start = mock_async_start
mock_settings = MagicMock()
mock_settings.settings.fallback_to_env_var = False
with patch("lfx.run._defaults.get_settings_service", return_value=mock_settings):
await execute_graph_with_capture(mock_graph, "test input")
assert captured.get("fallback_to_env_vars") is False
class TestResultExtraction:
"""Test result data extraction."""

View File

@ -4,7 +4,7 @@ import contextlib
import json
import tempfile
from pathlib import Path
from unittest.mock import patch
from unittest.mock import AsyncMock, patch
import pytest
import typer
@ -155,6 +155,10 @@ chat_input = ChatInput(
def test_execute_python_script_success(self, simple_chat_script, capsys):
"""Test executing a valid Python script."""
# Test that Python script execution either succeeds or fails gracefully
# ``run`` is a typer command, so any parameter with a ``typer.Option(...)`` default
# (e.g. session_id) needs to be passed explicitly when invoking outside typer's
# parser — otherwise the default evaluates to a ``typer.models.OptionInfo``
# sentinel and propagates downstream.
with contextlib.suppress(typer.Exit):
run(
script_path=simple_chat_script,
@ -164,6 +168,7 @@ chat_input = ChatInput(
output_format="json",
flow_json=None,
stdin=False,
session_id=None,
)
# Test passes as long as no unhandled exceptions occur
@ -181,6 +186,7 @@ chat_input = ChatInput(
def test_execute_python_script_verbose(self, simple_chat_script, capsys):
"""Test executing a Python script with verbose output."""
# Test that verbose mode execution either succeeds or fails gracefully
# See note in ``test_execute_python_script_success`` re: session_id=None.
with contextlib.suppress(typer.Exit):
run(
script_path=simple_chat_script,
@ -190,6 +196,7 @@ chat_input = ChatInput(
output_format="json",
flow_json=None,
stdin=False,
session_id=None,
)
# Test passes as long as no unhandled exceptions occur
@ -320,7 +327,8 @@ chat_input = ChatInput(
flow_json_str = json.dumps(simple_json_flow)
mock_stdin.read.return_value = flow_json_str
# Test that stdin execution either succeeds or fails gracefully
# Test that stdin execution either succeeds or fails gracefully.
# See note in ``test_execute_python_script_success`` re: session_id=None.
with pytest.raises(typer.Exit) as exc_info:
run(
script_path=None,
@ -330,6 +338,7 @@ chat_input = ChatInput(
output_format="json",
flow_json=None,
stdin=True,
session_id=None,
)
# Check that stdin was read and function exited cleanly
@ -436,6 +445,7 @@ chat_input = ChatInput(
def test_execute_verbose_error_output(self, invalid_script, capsys):
"""Test that verbose mode shows error details."""
# See note in ``test_execute_python_script_success`` re: session_id=None.
with pytest.raises(typer.Exit) as exc_info:
run(
script_path=invalid_script,
@ -445,6 +455,7 @@ chat_input = ChatInput(
output_format="json",
flow_json=None,
stdin=False,
session_id=None,
)
assert exc_info.value.exit_code == 1
@ -453,6 +464,55 @@ chat_input = ChatInput(
error_output = captured.out + captured.err
assert "graph" in error_output.lower() or "variable" in error_output.lower()
def test_session_id_cli_flag_plumbs_through_to_run_flow(self, simple_chat_script):
"""--session-id is wired from typer through `run` into `run_flow(session_id=...)`.
Locks in the typer Option name so a rename or wiring typo at lfx.cli.run.run
is caught here (the unit tests on run_flow itself only cover the function
contract, not this CLI layer).
"""
captured = {}
async def fake_run_flow(**kwargs):
captured.update(kwargs)
return {"success": True, "result": "ok", "logs": ""}
with patch("lfx.cli.run.run_flow", new=AsyncMock(side_effect=fake_run_flow)):
run(
script_path=simple_chat_script,
input_value="hi",
input_value_option=None,
verbose=False,
output_format="json",
flow_json=None,
stdin=False,
session_id="my-fixed-session",
)
assert captured.get("session_id") == "my-fixed-session"
def test_session_id_cli_flag_omitted_passes_none(self, simple_chat_script):
"""No --session-id => `run_flow` receives session_id=None (auto-gen happens downstream)."""
captured = {}
async def fake_run_flow(**kwargs):
captured.update(kwargs)
return {"success": True, "result": "ok", "logs": ""}
with patch("lfx.cli.run.run_flow", new=AsyncMock(side_effect=fake_run_flow)):
run(
script_path=simple_chat_script,
input_value="hi",
input_value_option=None,
verbose=False,
output_format="json",
flow_json=None,
stdin=False,
session_id=None,
)
assert captured.get("session_id") is None
def test_execute_without_input_value(self, simple_chat_script, capsys):
"""Test executing without providing input value."""
# Test that execution without input either succeeds or fails gracefully

View File

@ -118,13 +118,13 @@ class TestCreateServeApp:
def real_graph(self, simple_chat_json):
"""Create a real graph using Graph.from_payload to match serve_app expectations."""
# Create graph using from_payload with real test data
return Graph.from_payload(simple_chat_json, flow_id="test-flow-id")
return Graph.from_payload(simple_chat_json, flow_id="00000000-0000-0000-0000-000000000001")
@pytest.fixture
def mock_meta(self):
"""Create mock flow metadata."""
return FlowMeta(
id="test-flow-id",
id="00000000-0000-0000-0000-000000000001",
relative_path="test.json",
title="Test Flow",
description="A test flow",
@ -132,8 +132,8 @@ class TestCreateServeApp:
def test_create_multi_serve_app_single_flow(self, real_graph, mock_meta):
"""Test creating app with single flow."""
graphs = {"test-flow-id": real_graph}
metas = {"test-flow-id": mock_meta}
graphs = {"00000000-0000-0000-0000-000000000001": real_graph}
metas = {"00000000-0000-0000-0000-000000000001": mock_meta}
verbose_print = Mock()
app = create_multi_serve_app(
@ -150,7 +150,7 @@ class TestCreateServeApp:
routes = [route.path for route in app.routes]
assert "/health" in routes
assert "/flows" in routes # Multi-flow always has this
assert "/flows/test-flow-id/run" in routes # Flow-specific endpoint
assert "/flows/00000000-0000-0000-0000-000000000001/run" in routes # Flow-specific endpoint
def test_create_multi_serve_app_multiple_flows(self, real_graph, mock_meta, simple_chat_json):
"""Test creating app with multiple flows."""
@ -164,8 +164,8 @@ class TestCreateServeApp:
description="Second flow",
)
graphs = {"test-flow-id": real_graph, "flow-2": graph2}
metas = {"test-flow-id": mock_meta, "flow-2": meta2}
graphs = {"00000000-0000-0000-0000-000000000001": real_graph, "flow-2": graph2}
metas = {"00000000-0000-0000-0000-000000000001": mock_meta, "flow-2": meta2}
verbose_print = Mock()
app = create_multi_serve_app(
@ -182,14 +182,14 @@ class TestCreateServeApp:
routes = [route.path for route in app.routes]
assert "/health" in routes
assert "/flows" in routes
assert "/flows/test-flow-id/run" in routes
assert "/flows/test-flow-id/info" in routes
assert "/flows/00000000-0000-0000-0000-000000000001/run" in routes
assert "/flows/00000000-0000-0000-0000-000000000001/info" in routes
assert "/flows/flow-2/run" in routes
assert "/flows/flow-2/info" in routes
def test_create_multi_serve_app_mismatched_keys(self, real_graph, mock_meta):
"""Test error when graphs and metas have different keys."""
graphs = {"test-flow-id": real_graph}
graphs = {"00000000-0000-0000-0000-000000000001": real_graph}
metas = {"different-id": mock_meta}
verbose_print = Mock()
@ -217,13 +217,13 @@ class TestServeAppEndpoints:
def real_graph_with_async(self, simple_chat_json):
"""Create a real graph with async execution capability."""
# Create graph using from_payload with real test data
graph = Graph.from_payload(simple_chat_json, flow_id="test-flow-id")
graph = Graph.from_payload(simple_chat_json, flow_id="00000000-0000-0000-0000-000000000001")
# Store original async_start to restore later if needed
original_async_start = graph.async_start
# Mock successful execution with real ResultData
async def mock_async_start(inputs): # noqa: ARG001
async def mock_async_start(inputs, **kwargs): # noqa: ARG001
# Create real Message and ResultData objects
message = Message(text="Hello from flow")
result_data = ResultData(
@ -251,14 +251,14 @@ class TestServeAppEndpoints:
from lfx.services.deps import get_settings_service
meta = FlowMeta(
id="test-flow-id",
id="00000000-0000-0000-0000-000000000001",
relative_path="test.json",
title="Test Flow",
description="A test flow",
)
graphs = {"test-flow-id": real_graph_with_async}
metas = {"test-flow-id": meta}
graphs = {"00000000-0000-0000-0000-000000000001": real_graph_with_async}
metas = {"00000000-0000-0000-0000-000000000001": meta}
verbose_print = Mock()
app = create_multi_serve_app(
@ -282,14 +282,14 @@ class TestServeAppEndpoints:
# Create second real graph using the same JSON structure
graph2 = Graph.from_payload(simple_chat_json, flow_id="flow-2")
async def mock_async_start2(inputs): # noqa: ARG001
async def mock_async_start2(inputs, **kwargs): # noqa: ARG001
# Return empty results for this test
yield MagicMock(outputs=[])
graph2.async_start = mock_async_start2
meta1 = FlowMeta(
id="test-flow-id",
id="00000000-0000-0000-0000-000000000001",
relative_path="test.json",
title="Test Flow",
description="First flow",
@ -301,8 +301,8 @@ class TestServeAppEndpoints:
description="Second flow",
)
graphs = {"test-flow-id": real_graph_with_async, "flow-2": graph2}
metas = {"test-flow-id": meta1, "flow-2": meta2}
graphs = {"00000000-0000-0000-0000-000000000001": real_graph_with_async, "flow-2": graph2}
metas = {"00000000-0000-0000-0000-000000000001": meta1, "flow-2": meta2}
verbose_print = Mock()
app = create_multi_serve_app(
@ -340,7 +340,9 @@ class TestServeAppEndpoints:
"type": "message",
"component": "TestComponent",
}
response = app_client.post("/flows/test-flow-id/run", json=request_data, headers=headers)
response = app_client.post(
"/flows/00000000-0000-0000-0000-000000000001/run", json=request_data, headers=headers
)
assert response.status_code == 200
data = response.json()
@ -353,7 +355,7 @@ class TestServeAppEndpoints:
request_data = {"input_value": "Test input"}
with patch.dict(os.environ, {"LANGFLOW_API_KEY": "test-api-key"}): # pragma: allowlist secret
response = app_client.post("/flows/test-flow-id/run", json=request_data)
response = app_client.post("/flows/00000000-0000-0000-0000-000000000001/run", json=request_data)
assert response.status_code == 401
assert response.json()["detail"] == "API key required"
@ -364,7 +366,9 @@ class TestServeAppEndpoints:
headers = {"x-api-key": "wrong-key"}
with patch.dict(os.environ, {"LANGFLOW_API_KEY": "test-api-key"}): # pragma: allowlist secret
response = app_client.post("/flows/test-flow-id/run", json=request_data, headers=headers)
response = app_client.post(
"/flows/00000000-0000-0000-0000-000000000001/run", json=request_data, headers=headers
)
assert response.status_code == 401
assert response.json()["detail"] == "Invalid API key"
@ -376,15 +380,15 @@ class TestServeAppEndpoints:
"""Test that /run fails closed before execution when custom components are blocked."""
real_graph_with_async.raw_graph_data = _blocked_raw_graph()
meta = FlowMeta(
id="test-flow-id",
id="00000000-0000-0000-0000-000000000001",
relative_path="test.json",
title="Test Flow",
description="A test flow",
)
app = create_multi_serve_app(
root_dir=Path("/test"),
graphs={"test-flow-id": real_graph_with_async},
metas={"test-flow-id": meta},
graphs={"00000000-0000-0000-0000-000000000001": real_graph_with_async},
metas={"00000000-0000-0000-0000-000000000001": meta},
verbose_print=Mock(),
)
headers = {"x-api-key": "test-api-key"}
@ -411,7 +415,7 @@ class TestServeAppEndpoints:
):
client = TestClient(app)
response = client.post(
"/flows/test-flow-id/run",
"/flows/00000000-0000-0000-0000-000000000001/run",
json={"input_value": "Test input"},
headers=headers,
)
@ -435,7 +439,9 @@ class TestServeAppEndpoints:
"type": "message",
"component": "TestComponent",
}
response = app_client.post("/flows/test-flow-id/run?x-api-key=test-api-key", json=request_data)
response = app_client.post(
"/flows/00000000-0000-0000-0000-000000000001/run?x-api-key=test-api-key", json=request_data
)
assert response.status_code == 200
assert response.json()["success"] is True
@ -446,7 +452,7 @@ class TestServeAppEndpoints:
headers = {"x-api-key": "test-api-key"}
# Mock execute_graph_with_capture to raise an error
async def mock_execute_error(graph, input_value): # noqa: ARG001
async def mock_execute_error(graph, input_value, session_id=None): # noqa: ARG001
msg = "Flow execution failed"
raise RuntimeError(msg)
@ -454,7 +460,9 @@ class TestServeAppEndpoints:
patch.dict(os.environ, {"LANGFLOW_API_KEY": "test-api-key"}), # pragma: allowlist secret
patch("lfx.cli.serve_app.execute_graph_with_capture", mock_execute_error),
):
response = app_client.post("/flows/test-flow-id/run", json=request_data, headers=headers)
response = app_client.post(
"/flows/00000000-0000-0000-0000-000000000001/run", json=request_data, headers=headers
)
assert response.status_code == 200 # Returns 200 with error in response body
data = response.json()
@ -471,14 +479,16 @@ class TestServeAppEndpoints:
headers = {"x-api-key": "test-api-key"}
# Mock execute_graph_with_capture to return empty results
async def mock_execute_empty(graph, input_value): # noqa: ARG001
async def mock_execute_empty(graph, input_value, session_id=None): # noqa: ARG001
return [], "" # Empty results and logs
with (
patch.dict(os.environ, {"LANGFLOW_API_KEY": "test-api-key"}), # pragma: allowlist secret
patch("lfx.cli.serve_app.execute_graph_with_capture", mock_execute_empty),
):
response = app_client.post("/flows/test-flow-id/run", json=request_data, headers=headers)
response = app_client.post(
"/flows/00000000-0000-0000-0000-000000000001/run", json=request_data, headers=headers
)
assert response.status_code == 200
data = response.json()
@ -486,6 +496,53 @@ class TestServeAppEndpoints:
assert data["success"] is False
assert data["type"] == "error"
def test_run_endpoint_forwards_session_id(self, app_client):
"""The /run endpoint must forward session_id from RunRequest to the executor."""
captured: dict = {}
async def mock_execute_capture(graph, input_value, session_id=None): # noqa: ARG001
captured["session_id"] = session_id
return [], ""
request_data = {"input_value": "Test input", "session_id": "my-conversation"}
headers = {"x-api-key": "test-api-key"}
with (
patch.dict(os.environ, {"LANGFLOW_API_KEY": "test-api-key"}), # pragma: allowlist secret
patch("lfx.cli.serve_app.execute_graph_with_capture", mock_execute_capture),
):
response = app_client.post(
"/flows/00000000-0000-0000-0000-000000000001/run", json=request_data, headers=headers
)
assert response.status_code == 200
assert captured["session_id"] == "my-conversation"
def test_stream_endpoint_forwards_session_id(self, app_client):
"""The /stream endpoint must forward session_id from StreamRequest to the executor."""
captured: dict = {}
async def mock_execute_capture(graph, input_value, session_id=None): # noqa: ARG001
captured["session_id"] = session_id
return [], ""
request_data = {"input_value": "Test input", "session_id": "my-stream-conversation"}
headers = {"x-api-key": "test-api-key"}
with (
patch.dict(os.environ, {"LANGFLOW_API_KEY": "test-api-key"}), # pragma: allowlist secret
patch("lfx.cli.serve_app.execute_graph_with_capture", mock_execute_capture),
# Drain the streaming response so the background task completes before we assert.
app_client.stream(
"POST", "/flows/00000000-0000-0000-0000-000000000001/stream", json=request_data, headers=headers
) as response,
):
assert response.status_code == 200
for _ in response.iter_bytes():
pass
assert captured["session_id"] == "my-stream-conversation"
def test_list_flows_endpoint(self, multi_flow_client):
"""Test listing flows in multi-flow mode."""
response = multi_flow_client.get("/flows")
@ -493,7 +550,7 @@ class TestServeAppEndpoints:
assert response.status_code == 200
flows = response.json()
assert len(flows) == 2
assert any(f["id"] == "test-flow-id" for f in flows)
assert any(f["id"] == "00000000-0000-0000-0000-000000000001" for f in flows)
assert any(f["id"] == "flow-2" for f in flows)
def test_flow_info_endpoint(self, multi_flow_client):
@ -501,11 +558,11 @@ class TestServeAppEndpoints:
headers = {"x-api-key": "test-api-key"}
with patch.dict(os.environ, {"LANGFLOW_API_KEY": "test-api-key"}): # pragma: allowlist secret
response = multi_flow_client.get("/flows/test-flow-id/info", headers=headers)
response = multi_flow_client.get("/flows/00000000-0000-0000-0000-000000000001/info", headers=headers)
assert response.status_code == 200
info = response.json()
assert info["id"] == "test-flow-id"
assert info["id"] == "00000000-0000-0000-0000-000000000001"
assert info["title"] == "Test Flow"
assert info["description"] == "First flow"
@ -524,7 +581,9 @@ class TestServeAppEndpoints:
"type": "message",
"component": "TestComponent",
}
response = multi_flow_client.post("/flows/test-flow-id/run", json=request_data, headers=headers)
response = multi_flow_client.post(
"/flows/00000000-0000-0000-0000-000000000001/run", json=request_data, headers=headers
)
assert response.status_code == 200
data = response.json()
@ -536,7 +595,7 @@ class TestServeAppEndpoints:
headers = {"x-api-key": "test-api-key"}
with patch.dict(os.environ, {"LANGFLOW_API_KEY": "test-api-key"}): # pragma: allowlist secret
response = app_client.post("/flows/test-flow-id/run", json={}, headers=headers)
response = app_client.post("/flows/00000000-0000-0000-0000-000000000001/run", json={}, headers=headers)
assert response.status_code == 422 # Validation error
@ -544,7 +603,7 @@ class TestServeAppEndpoints:
"""Test flow execution with message-type output."""
# Create a real message output scenario
async def mock_async_start_message(inputs): # noqa: ARG001
async def mock_async_start_message(inputs, **kwargs): # noqa: ARG001
# Create real Message and ResultData objects
message = Message(text="Message output")
result_data = ResultData(
@ -576,7 +635,9 @@ class TestServeAppEndpoints:
"type": "message",
"component": "Chat Output",
}
response = app_client.post("/flows/test-flow-id/run", json=request_data, headers=headers)
response = app_client.post(
"/flows/00000000-0000-0000-0000-000000000001/run", json=request_data, headers=headers
)
assert response.status_code == 200
data = response.json()

View File

@ -249,7 +249,7 @@ def create_real_graph():
"""Helper function to create a real LFX graph with nodes/edges for serve_app."""
# Load real JSON data and create graph using from_payload
json_data = simple_chat_json()
return Graph.from_payload(json_data, flow_id="test-flow-id")
return Graph.from_payload(json_data, flow_id="00000000-0000-0000-0000-000000000001")
class TestFastAPIAppCreation:

View File

@ -0,0 +1,97 @@
"""Regression tests for CugaAgent session_id fallback (cuga_agent.py).
The agent's ``message_response`` builds an internal ``Message`` with
``session_id=self.graph.session_id or str(uuid.uuid4())``. The fallback exists
because some flows (notably ``lfx run`` without --session-id, before run_flow
auto-generates one) had a graph with an empty session_id, which crashed
``astore_message`` validation. The ``str(...)`` wrap matches the rest of the
file (every other ``uuid.uuid4()`` is wrapped) and the codebase's expectation
that Message.session_id renders as a hex string in logs / persistence.
These tests halt the agent's execution at the Message construction site so we
can verify the constructed kwargs without mocking the rest of the agent.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# CugaComponent's class body looks up MODELS_METADATA["OpenAI"], so it can only be
# imported when an OpenAI provider is registered (depends on optional deps in the env).
# Skip the whole module if the import side-effect fails.
try:
from lfx.components.cuga import cuga_agent
except Exception as exc:
pytest.skip(f"cuga_agent module not importable in this env: {exc}", allow_module_level=True)
class _Halt(Exception): # noqa: N818
"""Sentinel raised by the patched Message constructor to stop further execution."""
@pytest.fixture
def captured_message_kwargs():
"""Patch cuga_agent.Message to capture constructor kwargs and halt execution."""
captured: dict = {}
def fake_message(*_args, **kwargs):
captured.update(kwargs)
raise _Halt
with patch.object(cuga_agent, "Message", side_effect=fake_message):
yield captured
def _make_agent(*, graph_session_id):
"""Build a minimally-initialized CugaComponent that reaches Message construction."""
agent = cuga_agent.CugaComponent.__new__(cuga_agent.CugaComponent)
agent.input_value = "hello"
# ``graph`` is a read-only property on Component (-> self._vertex.graph),
# so wire the underlying _vertex instead of trying to assign graph directly.
agent._vertex = SimpleNamespace(graph=SimpleNamespace(session_id=graph_session_id))
agent.is_connected_to_chat_output = MagicMock(return_value=True)
agent.get_agent_requirements = AsyncMock(return_value=(MagicMock(), [], []))
return agent
@pytest.mark.asyncio
async def test_session_id_falls_back_to_string_uuid_when_graph_session_id_empty(captured_message_kwargs):
"""Empty graph.session_id must produce a non-empty string session_id (not a UUID object)."""
agent = _make_agent(graph_session_id="")
with pytest.raises(_Halt):
await agent.message_response()
assert "session_id" in captured_message_kwargs
session_id = captured_message_kwargs["session_id"]
assert isinstance(session_id, str), (
f"Expected str (use `str(uuid.uuid4())` not bare `uuid.uuid4()`), got {type(session_id).__name__}"
)
assert session_id, "Generated session_id should be non-empty"
@pytest.mark.asyncio
async def test_session_id_falls_back_when_graph_session_id_none(captured_message_kwargs):
"""None graph.session_id is also covered by the `or` fallback."""
agent = _make_agent(graph_session_id=None)
with pytest.raises(_Halt):
await agent.message_response()
session_id = captured_message_kwargs["session_id"]
assert isinstance(session_id, str)
assert session_id
@pytest.mark.asyncio
async def test_session_id_preserved_when_graph_session_id_set(captured_message_kwargs):
"""Explicit graph.session_id wins over the auto-generated fallback."""
agent = _make_agent(graph_session_id="caller-supplied")
with pytest.raises(_Halt):
await agent.message_response()
assert captured_message_kwargs["session_id"] == "caller-supplied"

View File

@ -297,6 +297,437 @@ chat_input = ChatInput()
assert "No 'graph' variable found" in str(exc_info.value)
class TestRunFlowSessionId:
"""Tests for run_flow session_id handling."""
@staticmethod
def _mock_graph():
graph = MagicMock()
graph.context = {}
graph.vertices = []
graph.edges = []
graph.prepare = MagicMock()
async def _async_start(_inputs, **_kwargs):
yield
graph.async_start = _async_start
return graph
@pytest.mark.asyncio
async def test_session_id_autogenerated_when_not_provided(self, tmp_path):
"""run_flow must assign a session_id so memory-store paths don't fail validation."""
script_path = tmp_path / "test.py"
script_path.write_text("graph = None")
mock_graph = self._mock_graph()
with (
patch("lfx.run.base.find_graph_variable") as mock_find,
patch("lfx.run.base.load_graph_from_script") as mock_load,
patch("lfx.run.base.validate_global_variables_for_env") as mock_validate,
patch("lfx.run.base.extract_structured_result") as mock_extract,
):
mock_find.return_value = {"line_number": 1, "type": "Graph", "source_line": "graph = Graph(...)"}
mock_load.return_value = mock_graph
mock_validate.return_value = []
mock_extract.return_value = {"success": True, "result": "test"}
await run_flow(script_path=script_path)
assert mock_graph.session_id, "session_id should be auto-generated when not provided"
assert isinstance(mock_graph.session_id, str)
@pytest.mark.asyncio
async def test_caller_session_id_is_preserved(self, tmp_path):
"""Caller-supplied session_id wins over auto-generation."""
script_path = tmp_path / "test.py"
script_path.write_text("graph = None")
mock_graph = self._mock_graph()
with (
patch("lfx.run.base.find_graph_variable") as mock_find,
patch("lfx.run.base.load_graph_from_script") as mock_load,
patch("lfx.run.base.validate_global_variables_for_env") as mock_validate,
patch("lfx.run.base.extract_structured_result") as mock_extract,
):
mock_find.return_value = {"line_number": 1, "type": "Graph", "source_line": "graph = Graph(...)"}
mock_load.return_value = mock_graph
mock_validate.return_value = []
mock_extract.return_value = {"success": True, "result": "test"}
await run_flow(script_path=script_path, session_id="my-fixed-session")
assert mock_graph.session_id == "my-fixed-session"
@pytest.mark.asyncio
async def test_autogenerated_session_ids_are_unique_across_runs(self, tmp_path):
"""Each run without a session_id should produce a distinct value."""
script_path = tmp_path / "test.py"
script_path.write_text("graph = None")
graph_a = self._mock_graph()
graph_b = self._mock_graph()
with (
patch("lfx.run.base.find_graph_variable") as mock_find,
patch("lfx.run.base.load_graph_from_script") as mock_load,
patch("lfx.run.base.validate_global_variables_for_env") as mock_validate,
patch("lfx.run.base.extract_structured_result") as mock_extract,
):
mock_find.return_value = {"line_number": 1, "type": "Graph", "source_line": "graph = Graph(...)"}
mock_validate.return_value = []
mock_extract.return_value = {"success": True, "result": "test"}
mock_load.return_value = graph_a
await run_flow(script_path=script_path)
mock_load.return_value = graph_b
await run_flow(script_path=script_path)
assert graph_a.session_id != graph_b.session_id
@pytest.mark.asyncio
async def test_explicit_session_id_carries_across_consecutive_runs(self, tmp_path):
"""The same caller-supplied session_id reaches both graphs — Memory continuity surface.
Counterpart to ``test_autogenerated_session_ids_are_unique_across_runs``: with an
explicit session_id, two consecutive run_flow invocations must both stamp the
graph with that exact value (otherwise --session-id would not actually achieve
conversational continuity).
"""
script_path = tmp_path / "test.py"
script_path.write_text("graph = None")
graph_a = self._mock_graph()
graph_b = self._mock_graph()
with (
patch("lfx.run.base.find_graph_variable") as mock_find,
patch("lfx.run.base.load_graph_from_script") as mock_load,
patch("lfx.run.base.validate_global_variables_for_env") as mock_validate,
patch("lfx.run.base.extract_structured_result") as mock_extract,
):
mock_find.return_value = {"line_number": 1, "type": "Graph", "source_line": "graph = Graph(...)"}
mock_validate.return_value = []
mock_extract.return_value = {"success": True, "result": "test"}
mock_load.return_value = graph_a
await run_flow(script_path=script_path, session_id="continuity-session")
mock_load.return_value = graph_b
await run_flow(script_path=script_path, session_id="continuity-session")
assert graph_a.session_id == "continuity-session"
assert graph_b.session_id == "continuity-session"
@pytest.mark.asyncio
@pytest.mark.parametrize("bad_value", ["", " ", "\t\n"])
async def test_empty_or_whitespace_session_id_is_rejected(self, tmp_path, bad_value):
"""Empty/whitespace session_id surfaces as RunError instead of silently auto-generating.
The --session-id flag's purpose is Memory/MessageHistory continuity. If a shell
quirk or env-var typo collapsed the value to empty, silently auto-generating
a fresh UUID would mask the error: subsequent runs would not see prior state
and the user would only notice via missing memory. Validate up-front instead.
"""
script_path = tmp_path / "test.py"
script_path.write_text("graph = None")
with pytest.raises(RunError) as exc_info:
await run_flow(script_path=script_path, session_id=bad_value)
assert "session_id" in str(exc_info.value)
@pytest.mark.asyncio
@pytest.mark.parametrize("bad_value", ["", " "])
async def test_empty_or_whitespace_user_id_is_rejected(self, tmp_path, bad_value):
"""Same validation as session_id, applied to user_id (variable-scoping in DB-backed setups)."""
script_path = tmp_path / "test.py"
script_path.write_text("graph = None")
with pytest.raises(RunError) as exc_info:
await run_flow(script_path=script_path, user_id=bad_value)
assert "user_id" in str(exc_info.value)
class TestRunFlowSessionIdPropagation:
"""Session_id must reach Memory/MessageHistory components on the lfx run path.
lfx run uses ``graph.async_start`` (not ``graph.arun``), so it bypasses the
``has_session_id_vertices`` propagation loop in ``Graph._run``. ``run_flow``
must replicate that loop after assigning ``graph.session_id`` so components
that read ``self.session_id`` from their input field (Memory.retrieve_messages
etc.) actually see the configured value. Mirrors what
``langflow/api/utils/flow_utils.build_graph_from_data`` does for the playground.
"""
@staticmethod
def _mock_graph_with_vertices(vertex_specs):
"""Build a mock graph whose has_session_id_vertices loop drives ``vertex_specs``.
Each spec is (vertex_id, raw_params_dict). Returns the graph plus the
list of vertex mocks so tests can assert on update_raw_params calls.
"""
graph = MagicMock()
graph.context = {}
graph.vertices = []
graph.edges = []
graph.prepare = MagicMock()
async def _async_start(_inputs, **_kwargs):
yield
graph.async_start = _async_start
vertex_mocks = {}
for vertex_id, raw_params in vertex_specs:
vertex = MagicMock()
vertex.raw_params = dict(raw_params)
vertex.update_raw_params = MagicMock()
vertex_mocks[vertex_id] = vertex
graph.has_session_id_vertices = list(vertex_mocks.keys())
graph.get_vertex = MagicMock(side_effect=lambda vid: vertex_mocks.get(vid))
return graph, vertex_mocks
@staticmethod
def _patches():
return (
patch("lfx.run.base.find_graph_variable"),
patch("lfx.run.base.load_graph_from_script"),
patch("lfx.run.base.validate_global_variables_for_env"),
patch("lfx.run.base.extract_structured_result"),
)
@pytest.mark.asyncio
async def test_session_id_propagates_to_vertex_with_empty_input(self, tmp_path):
"""A Memory vertex with no hardcoded session_id receives the run's session_id."""
script_path = tmp_path / "test.py"
script_path.write_text("graph = None")
graph, vertices = self._mock_graph_with_vertices([("memory-1", {})])
find_p, load_p, validate_p, extract_p = self._patches()
with find_p as mock_find, load_p as mock_load, validate_p as mock_validate, extract_p as mock_extract:
mock_find.return_value = {"line_number": 1, "type": "Graph", "source_line": "graph = Graph(...)"}
mock_load.return_value = graph
mock_validate.return_value = []
mock_extract.return_value = {"success": True, "result": "test"}
await run_flow(script_path=script_path, session_id="from-cli")
vertices["memory-1"].update_raw_params.assert_called_once_with({"session_id": "from-cli"}, overwrite=True)
@pytest.mark.asyncio
async def test_session_id_does_not_overwrite_hardcoded_vertex_value(self, tmp_path):
"""If the flow JSON pinned session_id on the Memory component, the CLI must not clobber it.
Matches Langflow's playground behavior: ``build_graph_from_data`` only writes
when ``raw_params.get("session_id")`` is falsy.
"""
script_path = tmp_path / "test.py"
script_path.write_text("graph = None")
graph, vertices = self._mock_graph_with_vertices([("memory-pinned", {"session_id": "hardcoded-in-flow"})])
find_p, load_p, validate_p, extract_p = self._patches()
with find_p as mock_find, load_p as mock_load, validate_p as mock_validate, extract_p as mock_extract:
mock_find.return_value = {"line_number": 1, "type": "Graph", "source_line": "graph = Graph(...)"}
mock_load.return_value = graph
mock_validate.return_value = []
mock_extract.return_value = {"success": True, "result": "test"}
await run_flow(script_path=script_path, session_id="from-cli")
vertices["memory-pinned"].update_raw_params.assert_not_called()
@pytest.mark.asyncio
async def test_session_id_propagation_handles_missing_vertex(self, tmp_path):
"""A stale vertex_id in has_session_id_vertices (get_vertex returns None) is skipped, not raised."""
script_path = tmp_path / "test.py"
script_path.write_text("graph = None")
graph, vertices = self._mock_graph_with_vertices([("real-vertex", {})])
graph.has_session_id_vertices = ["real-vertex", "ghost-vertex"]
find_p, load_p, validate_p, extract_p = self._patches()
with find_p as mock_find, load_p as mock_load, validate_p as mock_validate, extract_p as mock_extract:
mock_find.return_value = {"line_number": 1, "type": "Graph", "source_line": "graph = Graph(...)"}
mock_load.return_value = graph
mock_validate.return_value = []
mock_extract.return_value = {"success": True, "result": "test"}
await run_flow(script_path=script_path, session_id="from-cli")
vertices["real-vertex"].update_raw_params.assert_called_once()
class TestRunFlowUserId:
"""user_id auto-generation on the lfx run path.
AgentComponent (and any component that resolves variables) hits a precheck
in ``custom_component.get_variable`` that requires a non-empty ``self.user_id``.
lfx run has no notion of authenticated users, but the precheck still has to
pass so the env-fallback variable service can answer. ``run_flow`` therefore
auto-generates a ceremonial UUID when none is supplied; the value is unused
for variable scoping in lfx (env vars are process-global) but exists to keep
component initialization unblocked.
"""
@staticmethod
def _mock_graph():
graph = MagicMock()
graph.context = {}
graph.vertices = []
graph.edges = []
graph.prepare = MagicMock()
async def _async_start(_inputs, **_kwargs):
yield
graph.async_start = _async_start
return graph
@pytest.mark.asyncio
async def test_user_id_autogenerated_when_not_provided(self, tmp_path):
"""run_flow assigns a UUID user_id so the component precheck passes."""
script_path = tmp_path / "test.py"
script_path.write_text("graph = None")
mock_graph = self._mock_graph()
with (
patch("lfx.run.base.find_graph_variable") as mock_find,
patch("lfx.run.base.load_graph_from_script") as mock_load,
patch("lfx.run.base.validate_global_variables_for_env") as mock_validate,
patch("lfx.run.base.extract_structured_result") as mock_extract,
):
mock_find.return_value = {"line_number": 1, "type": "Graph", "source_line": "graph = Graph(...)"}
mock_load.return_value = mock_graph
mock_validate.return_value = []
mock_extract.return_value = {"success": True, "result": "test"}
await run_flow(script_path=script_path)
assert mock_graph.user_id, "user_id should be auto-generated when not provided"
assert isinstance(mock_graph.user_id, str)
@pytest.mark.asyncio
async def test_caller_user_id_is_preserved(self, tmp_path):
"""An explicit user_id wins over auto-generation."""
script_path = tmp_path / "test.py"
script_path.write_text("graph = None")
mock_graph = self._mock_graph()
with (
patch("lfx.run.base.find_graph_variable") as mock_find,
patch("lfx.run.base.load_graph_from_script") as mock_load,
patch("lfx.run.base.validate_global_variables_for_env") as mock_validate,
patch("lfx.run.base.extract_structured_result") as mock_extract,
):
mock_find.return_value = {"line_number": 1, "type": "Graph", "source_line": "graph = Graph(...)"}
mock_load.return_value = mock_graph
mock_validate.return_value = []
mock_extract.return_value = {"success": True, "result": "test"}
await run_flow(script_path=script_path, user_id="real-user-uuid")
assert mock_graph.user_id == "real-user-uuid"
@pytest.mark.asyncio
async def test_autogenerated_user_ids_are_unique_across_runs(self, tmp_path):
"""Each run without a user_id should produce a distinct value (ceremonial UUIDs differ)."""
script_path = tmp_path / "test.py"
script_path.write_text("graph = None")
graph_a = self._mock_graph()
graph_b = self._mock_graph()
with (
patch("lfx.run.base.find_graph_variable") as mock_find,
patch("lfx.run.base.load_graph_from_script") as mock_load,
patch("lfx.run.base.validate_global_variables_for_env") as mock_validate,
patch("lfx.run.base.extract_structured_result") as mock_extract,
):
mock_find.return_value = {"line_number": 1, "type": "Graph", "source_line": "graph = Graph(...)"}
mock_validate.return_value = []
mock_extract.return_value = {"success": True, "result": "test"}
mock_load.return_value = graph_a
await run_flow(script_path=script_path)
mock_load.return_value = graph_b
await run_flow(script_path=script_path)
assert graph_a.user_id != graph_b.user_id
class TestRunFlowFallbackToEnvVars:
"""run_flow must plumb fallback_to_env_vars into ``graph.async_start``.
Without this, a langflow ``DatabaseVariableService`` registered alongside
``database_service`` would raise ``variable not found`` for any
``load_from_db=True`` field whose user_id has no Variable row (e.g., the
ceremonial UUID lfx auto-generates). The flag tells
``loading.update_params_with_load_from_db_fields`` to fall back to
``os.environ`` when the DB lookup misses — same behavior as the langflow
API path in ``processing.process.run_graph_internal``.
"""
@staticmethod
def _mock_graph_capturing_kwargs(captured: dict):
graph = MagicMock()
graph.context = {}
graph.vertices = []
graph.edges = []
graph.prepare = MagicMock()
async def _async_start(_inputs, **kwargs):
captured.update(kwargs)
yield
graph.async_start = _async_start
return graph
@pytest.mark.asyncio
async def test_passes_fallback_from_settings_default(self, tmp_path):
"""Setting defaults True; run_flow forwards True into async_start."""
script_path = tmp_path / "test.py"
script_path.write_text("graph = None")
captured: dict = {}
mock_graph = self._mock_graph_capturing_kwargs(captured)
with (
patch("lfx.run.base.find_graph_variable") as mock_find,
patch("lfx.run.base.load_graph_from_script") as mock_load,
patch("lfx.run.base.validate_global_variables_for_env") as mock_validate,
patch("lfx.run.base.extract_structured_result") as mock_extract,
):
mock_find.return_value = {"line_number": 1, "type": "Graph", "source_line": "graph = Graph(...)"}
mock_load.return_value = mock_graph
mock_validate.return_value = []
mock_extract.return_value = {"success": True, "result": "test"}
await run_flow(script_path=script_path)
assert captured.get("fallback_to_env_vars") is True
@pytest.mark.asyncio
async def test_respects_settings_when_disabled(self, tmp_path):
"""When LANGFLOW_FALLBACK_TO_ENV_VAR=false, the flag plumbs through as False."""
script_path = tmp_path / "test.py"
script_path.write_text("graph = None")
captured: dict = {}
mock_graph = self._mock_graph_capturing_kwargs(captured)
mock_settings = MagicMock()
mock_settings.settings.fallback_to_env_var = False
with (
patch("lfx.run.base.find_graph_variable") as mock_find,
patch("lfx.run.base.load_graph_from_script") as mock_load,
patch("lfx.run.base.validate_global_variables_for_env") as mock_validate,
patch("lfx.run.base.extract_structured_result") as mock_extract,
patch("lfx.run._defaults.get_settings_service", return_value=mock_settings),
):
mock_find.return_value = {"line_number": 1, "type": "Graph", "source_line": "graph = Graph(...)"}
mock_load.return_value = mock_graph
mock_validate.return_value = []
mock_extract.return_value = {"success": True, "result": "test"}
await run_flow(script_path=script_path)
assert captured.get("fallback_to_env_vars") is False
class TestRunFlowGlobalVariables:
"""Tests for run_flow global variables injection."""

View File

@ -482,7 +482,7 @@ class TestEnvironmentVariableIntegration:
asyncio.run(manager.teardown())
def test_variable_service_uses_env(self, clean_manager):
async def test_variable_service_uses_env(self, clean_manager):
"""Test that variable service reads from environment."""
from lfx.services.variable.service import VariableService
@ -491,12 +491,12 @@ class TestEnvironmentVariableIntegration:
os.environ["TEST_API_KEY"] = "test_value_123" # pragma: allowlist secret
try:
variables = clean_manager.get(ServiceType.VARIABLE_SERVICE)
value = variables.get_variable("TEST_API_KEY")
value = await variables.get_variable("TEST_API_KEY")
assert value == "test_value_123"
finally:
del os.environ["TEST_API_KEY"]
def test_variable_service_in_memory_overrides_env(self, clean_manager):
async def test_variable_service_in_memory_overrides_env(self, clean_manager):
"""Test that in-memory variables override environment."""
from lfx.services.variable.service import VariableService
@ -506,7 +506,7 @@ class TestEnvironmentVariableIntegration:
try:
variables = clean_manager.get(ServiceType.VARIABLE_SERVICE)
variables.set_variable("TEST_VAR", "memory_value")
value = variables.get_variable("TEST_VAR")
value = await variables.get_variable("TEST_VAR")
assert value == "memory_value"
finally:
del os.environ["TEST_VAR"]

View File

@ -206,31 +206,31 @@ class TestVariableService:
assert variables.ready is True
assert variables.name == "variable_service"
def test_set_and_get_variable(self, variables):
async def test_set_and_get_variable(self, variables):
"""Test setting and getting a variable."""
variables.set_variable("test_key", "test_value")
value = variables.get_variable("test_key")
value = await variables.get_variable("test_key")
assert value == "test_value"
def test_get_from_environment(self, variables):
async def test_get_from_environment(self, variables):
"""Test getting variable from environment."""
os.environ["TEST_ENV_VAR"] = "env_value"
try:
value = variables.get_variable("TEST_ENV_VAR")
value = await variables.get_variable("TEST_ENV_VAR")
assert value == "env_value"
finally:
del os.environ["TEST_ENV_VAR"]
def test_get_nonexistent_variable(self, variables):
async def test_get_nonexistent_variable(self, variables):
"""Test getting a variable that doesn't exist."""
value = variables.get_variable("nonexistent_key")
value = await variables.get_variable("nonexistent_key")
assert value is None
def test_delete_variable(self, variables):
async def test_delete_variable(self, variables):
"""Test deleting a variable."""
variables.set_variable("test_key", "test_value")
variables.delete_variable("test_key")
value = variables.get_variable("test_key")
value = await variables.get_variable("test_key")
assert value is None
def test_list_variables(self, variables):
@ -242,24 +242,55 @@ class TestVariableService:
assert "key1" in vars_list
assert "key2" in vars_list
def test_in_memory_overrides_env(self, variables):
async def test_in_memory_overrides_env(self, variables):
"""Test that in-memory variables override environment."""
os.environ["TEST_VAR"] = "env_value"
try:
variables.set_variable("TEST_VAR", "memory_value")
value = variables.get_variable("TEST_VAR")
value = await variables.get_variable("TEST_VAR")
assert value == "memory_value"
finally:
del os.environ["TEST_VAR"]
@pytest.mark.asyncio
async def test_get_variable_is_coroutine(self, variables):
"""get_variable must be async to match the langflow call site.
Callers use ``await variable_service.get_variable(...)`` (see
``Component.get_variable`` in custom_component.py); awaiting a non-coroutine
TypeErrors. lfx's implementation does no I/O — the async wrapper exists
purely to match the interface langflow's DatabaseVariableService uses.
"""
import inspect
assert inspect.iscoroutinefunction(variables.get_variable)
coro = variables.get_variable("anything")
assert inspect.iscoroutine(coro)
await coro # avoid 'coroutine never awaited' warning
async def test_get_variable_absorbs_extra_kwargs(self, variables):
"""Extra kwargs from the langflow call site must not crash the lfx implementation.
Langflow calls ``await variable_service.get_variable(user_id=..., name=...,
field=..., session=...)``. lfx's implementation must accept and ignore the
extras so flows behave identically as long as the variable can be resolved
by name.
"""
os.environ["LFX_KWARG_TEST"] = "value-from-env"
try:
value = await variables.get_variable(
user_id="random-uuid", name="LFX_KWARG_TEST", field="value", session=None
)
assert value == "value-from-env"
finally:
del os.environ["LFX_KWARG_TEST"]
async def test_teardown(self, variables):
"""Test service teardown clears variables."""
variables.set_variable("test_key", "test_value")
await variables.teardown()
# Variables should be cleared (verify via public API)
assert variables.list_variables() == []
assert variables.get_variable("test_key") is None
assert await variables.get_variable("test_key") is None
class TestMinimalServicesIntegration:

View File

@ -427,7 +427,7 @@ class TestRealWorldScenarios:
await service_manager.teardown()
assert ServiceType.STORAGE_SERVICE not in service_manager.services
def test_multiple_services_working_together(self, service_manager):
async def test_multiple_services_working_together(self, service_manager):
"""Test multiple services can coexist and work together."""
# Register all minimal services
service_manager.register_service_class(ServiceType.STORAGE_SERVICE, LocalStorageService)
@ -450,7 +450,7 @@ class TestRealWorldScenarios:
# All should be usable
tracing.add_log("test_trace", {"message": "test"})
variables.set_variable("TEST_KEY", "test_value")
assert variables.get_variable("TEST_KEY") == "test_value"
assert await variables.get_variable("TEST_KEY") == "test_value"
def test_config_file_with_all_minimal_services(self, service_manager, temp_config_dir):
"""Test loading all minimal services from config file."""

1433
uv.lock generated

File diff suppressed because it is too large Load Diff