mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-23 21:21:20 +08:00
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:
File diff suppressed because it is too large
Load Diff
@ -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.
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
@ -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.
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1522,7 +1522,7 @@
|
||||
},
|
||||
{
|
||||
"name": "langchain_core",
|
||||
"version": "1.3.1"
|
||||
"version": "1.3.2"
|
||||
},
|
||||
{
|
||||
"name": "lfx",
|
||||
|
||||
@ -2086,7 +2086,7 @@
|
||||
},
|
||||
{
|
||||
"name": "langchain_core",
|
||||
"version": "1.3.1"
|
||||
"version": "1.3.2"
|
||||
}
|
||||
],
|
||||
"total_dependencies": 3
|
||||
|
||||
@ -1196,7 +1196,7 @@
|
||||
},
|
||||
{
|
||||
"name": "langchain_core",
|
||||
"version": "1.3.1"
|
||||
"version": "1.3.2"
|
||||
}
|
||||
],
|
||||
"total_dependencies": 3
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1206,7 +1206,7 @@
|
||||
},
|
||||
{
|
||||
"name": "langchain_core",
|
||||
"version": "1.3.1"
|
||||
"version": "1.3.2"
|
||||
}
|
||||
],
|
||||
"total_dependencies": 3
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -1255,7 +1255,7 @@
|
||||
},
|
||||
{
|
||||
"name": "langchain_core",
|
||||
"version": "1.3.1"
|
||||
"version": "1.3.2"
|
||||
}
|
||||
],
|
||||
"total_dependencies": 3
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1625,7 +1625,7 @@
|
||||
},
|
||||
{
|
||||
"name": "langchain_core",
|
||||
"version": "1.3.1"
|
||||
"version": "1.3.2"
|
||||
}
|
||||
],
|
||||
"total_dependencies": 3
|
||||
|
||||
@ -2825,7 +2825,7 @@
|
||||
},
|
||||
{
|
||||
"name": "langchain_core",
|
||||
"version": "1.3.1"
|
||||
"version": "1.3.2"
|
||||
}
|
||||
],
|
||||
"total_dependencies": 3
|
||||
|
||||
@ -907,7 +907,7 @@
|
||||
},
|
||||
{
|
||||
"name": "langchain_core",
|
||||
"version": "1.3.1"
|
||||
"version": "1.3.2"
|
||||
}
|
||||
],
|
||||
"total_dependencies": 3
|
||||
|
||||
@ -956,7 +956,7 @@
|
||||
},
|
||||
{
|
||||
"name": "langchain_core",
|
||||
"version": "1.3.1"
|
||||
"version": "1.3.2"
|
||||
}
|
||||
],
|
||||
"total_dependencies": 3
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -954,7 +954,7 @@
|
||||
},
|
||||
{
|
||||
"name": "langchain_core",
|
||||
"version": "1.3.1"
|
||||
"version": "1.3.2"
|
||||
}
|
||||
],
|
||||
"total_dependencies": 3
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
1640
src/backend/base/uv.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
||||
331
src/backend/tests/unit/api/v1/test_deployments_telemetry.py
Normal file
331
src/backend/tests/unit/api/v1/test_deployments_telemetry.py
Normal 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"
|
||||
@ -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}"
|
||||
)
|
||||
|
||||
@ -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."""
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"}
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
|
||||
|
||||
277
src/frontend/package-lock.json
generated
277
src/frontend/package-lock.json
generated
@ -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
@ -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()):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
},
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
132
src/lfx/src/lfx/run/_defaults.py
Normal file
132
src/lfx/src/lfx/run/_defaults.py
Normal 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)
|
||||
@ -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}")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -2,7 +2,7 @@ from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class FeatureFlags(BaseSettings):
|
||||
wxo_deployments: bool = True
|
||||
wxo_deployments: bool = False
|
||||
"""
|
||||
Enable Watsonx Orchestrate deployments.
|
||||
"""
|
||||
|
||||
@ -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
|
||||
|
||||
236
src/lfx/tests/unit/base/data/test_base_file_unpack.py
Normal file
236
src/lfx/tests/unit/base/data/test_base_file_unpack.py
Normal 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()) == []
|
||||
120
src/lfx/tests/unit/base/models/test_handle_stream.py
Normal file
120
src/lfx/tests/unit/base/models/test_handle_stream.py
Normal 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"
|
||||
@ -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."""
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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:
|
||||
|
||||
0
src/lfx/tests/unit/components/cuga/__init__.py
Normal file
0
src/lfx/tests/unit/components/cuga/__init__.py
Normal 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"
|
||||
@ -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."""
|
||||
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user